---
title: Portrait Burst
description: Hover a word and a burst of portraits flies out, then tucks back in.
category: Cursor
tier: free
url: https://ui.dimaac.com/components/portrait-burst
registry: https://ui.dimaac.com/r/portrait-burst.json
---

# Portrait Burst

Keywords inside a sentence that throw out a burst of portraits. Hover a word (or tab to it) and its portraits fly to random spots around it, tilted a little, then tuck back into the word when you leave. Each word has its own set of photos, and you choose how many portraits, how far they fly, how much they tilt and how long it takes. Portraits stay inside the box, a tap opens a word on touch screens, and with reduced motion they simply fade in around the word. Rebuilt from a YouTube tutorial demo as one GSAP timeline per word.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/portrait-burst.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/portrait-burst.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/portrait-burst.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/portrait-burst`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/portrait-burst.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { PortraitBurst } from "@/components/dimaac/portrait-burst" and give its parent a size: it fills it.

## Usage

```tsx
import { PortraitBurst } from "@/components/dimaac/portrait-burst";

<PortraitBurst items={items} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` (required) | `BurstItem[]` | - | The words, in reading order, each with its own portraits. |
| `ending` | `string` | `""` | Plain text after the last word. |
| `count` | `number` | `6` | How many portraits burst out of one word. |
| `spread` | `number` | `260` | How far the portraits fly from the word, in pixels. |
| `rotate` | `number` | `12` | The most a portrait is tilted either way, in degrees. |
| `duration` | `number` | `0.8` | How long the burst takes, in seconds. The tuck back takes a bit less. |
| `imageWidth` | `number` | `150` | Width of each portrait, in pixels (they are 4:5). It shrinks on narrow screens. |
| `className` | `string` | `""` | Extra classes for the outer box. It centres the sentence and keeps the portraits inside it, so give it a size. |
| `textClassName` | `string` | `"text-center text-[clamp(2rem,5vw,3.25rem)] leading-[1.28]"` | Extra classes for the sentence, e.g. its font size and colour. |
| `wordClassName` | `string` | `""` | Extra classes for each word, e.g. a hover colour. |
| `imageClassName` | `string` | `"rounded-[3px] shadow-[0_18px_40px_-16px_rgba(0,0,0,0.45)]"` | Extra classes for each portrait, e.g. a radius or a shadow. |

## Credits

- Portrait: West Kast, Lummi (https://www.lummi.ai/photo/monochrome-trio-style-va9g1), Lummi License
- Portrait: Axel Photography, Lummi (https://www.lummi.ai/photo/futuristic-fashion-pose-6nymn), Lummi License
- Portrait: Axel Photography, Lummi (https://www.lummi.ai/photo/modern-fashion-pose-zsdav), Lummi License
- Portrait: Semihan Dosunmu, Lummi (https://www.lummi.ai/photo/avant-garde-fashion-pose-1yqoo), Lummi License
- Portrait: Amino, Lummi (https://www.lummi.ai/photo/stylish-studio-portrait-d9xnl), Lummi License
- Portrait: West Kast, Lummi (https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql), Lummi License
- Portrait: Semihan Dosunmu, Lummi (https://www.lummi.ai/photo/avant-garde-fashion-portrait-rjvlr), Lummi License
- Portrait: West Kast, Lummi (https://www.lummi.ai/photo/futuristic-gold-profile-jnio5), Lummi License
- Portrait: Bernardo Lickfold, Lummi (https://www.lummi.ai/photo/portrait-through-orange-frame-kld5i), Lummi License
- Portrait: Semihan Dosunmu, Lummi (https://www.lummi.ai/photo/chic-fashion-portrait-wojpk), Lummi License
- Portrait: Kuba, Lummi (https://www.lummi.ai/photo/abstract-eye-close-up-uamae), Lummi License
- Portrait: studiomiklus, Lummi (https://www.lummi.ai/photo/striking-portrait-jodiu), Lummi License
- Portrait: Edita, Lummi (https://www.lummi.ai/photo/striking-portrait-kfent), Lummi License
- Portrait: Shaun Duval, Lummi (https://www.lummi.ai/photo/portrait-with-neon-sunglasses-gindg), Lummi License
- Portrait: SHIHO, Lummi (https://www.lummi.ai/photo/vibrant-woman-portrait-htofy), Lummi License
- Portrait: Amino, Lummi (https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow), Lummi License
- Portrait: Steph Meade, Lummi (https://www.lummi.ai/photo/expressive-minimal-portrait-jrfyc), Lummi License
- Portrait: Lloyd Creates, Lummi (https://www.lummi.ai/photo/poised-portrait-ooi9b), Lummi License
- Portrait: Amino, Lummi (https://www.lummi.ai/photo/stylish-portrait-8inth), Lummi License
- Portrait: West Kast, Lummi (https://www.lummi.ai/photo/monochrome-portrait-of-a-young-woman-shb7s), Lummi License
- Portrait: Benginur Hajjaj, Lummi (https://www.lummi.ai/photo/calm-portrait-d1zqo), Lummi License
- Portrait: Dynamic Wang, Lummi (https://www.lummi.ai/photo/profile-portrait-of-woman-xyyue), Lummi License
- Portrait: SHIHO, Lummi (https://www.lummi.ai/photo/mystery-portrait-cffyo), Lummi License

## Source

### portrait-burst.tsx → `components/dimaac/portrait-burst.tsx`

```tsx
"use client";

import { Fragment, useRef } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";

// Words in a sentence that throw out a burst of portraits when you hover (or tab to) them. The
// portraits fly from the word to random spots around it, then tuck back into it when you leave.
// On touch screens a tap on the word opens it, and a tap anywhere else closes it.

export type BurstItem = {
  /** The hoverable word (or short phrase) */
  word: string;
  /** Portraits that burst out of this word. If `count` is higher than this list, the list repeats. */
  images: string[];
  /** Plain text that comes before the word. Use "\n" for a line break. */
  before?: string;
};

export type PortraitBurstProps = {
  /** The words, in reading order, each with its own portraits. */
  items: BurstItem[];
  /** Plain text after the last word. */
  ending?: string;
  /** How many portraits burst out of one word. */
  count?: number;
  /** How far the portraits fly from the word, in pixels. */
  spread?: number;
  /** The most a portrait is tilted either way, in degrees. */
  rotate?: number;
  /** How long the burst takes, in seconds. The tuck back takes a bit less. */
  duration?: number;
  /** Width of each portrait, in pixels (they are 4:5). It shrinks on narrow screens. */
  imageWidth?: number;
  /** Extra classes for the outer box. It centres the sentence and keeps the portraits inside it, so give it a size. */
  className?: string;
  /** Extra classes for the sentence, e.g. its font size and colour. */
  textClassName?: string;
  /** Extra classes for each word, e.g. a hover colour. */
  wordClassName?: string;
  /** Extra classes for each portrait, e.g. a radius or a shadow. */
  imageClassName?: string;
};

type Spot = { x: number; y: number; rotation: number };

// Spread the portraits round the word like petals: an even split of the circle, nudged randomly,
// each at a random distance. The circle is squashed on a wide box, because a screen is wider than it is tall.
// Spots are kept inside the box so nothing flies off the edge.
function pickSpots(count: number, spread: number, rotate: number, centre: { x: number; y: number }, box: { width: number; height: number }, size: { width: number; height: number }): Spot[] {
  const startAngle = Math.random() * Math.PI * 2;
  const slice = (Math.PI * 2) / count;
  const squash = box.height > box.width ? 1 : 0.7;
  const marginX = size.width / 2 + 8;
  const marginY = size.height / 2 + 8;
  return Array.from({ length: count }, (_, index) => {
    const angle = startAngle + index * slice + (Math.random() - 0.5) * slice * 0.6;
    const reach = spread * (0.6 + Math.random() * 0.4);
    const x = centre.x + Math.cos(angle) * reach;
    const y = centre.y + Math.sin(angle) * reach * squash;
    return {
      x: Math.min(Math.max(x, marginX), Math.max(marginX, box.width - marginX)),
      y: Math.min(Math.max(y, marginY), Math.max(marginY, box.height - marginY)),
      rotation: (Math.random() * 2 - 1) * rotate,
    };
  });
}

export function PortraitBurst({
  items,
  ending = "",
  count = 6,
  spread = 260,
  rotate = 12,
  duration = 0.8,
  imageWidth = 150,
  className = "",
  textClassName = "text-center text-[clamp(2rem,5vw,3.25rem)] leading-[1.28]",
  wordClassName = "",
  imageClassName = "rounded-[3px] shadow-[0_18px_40px_-16px_rgba(0,0,0,0.45)]",
}: PortraitBurstProps) {
  const boxRef = useRef<HTMLDivElement>(null);
  const portraitCount = Math.max(1, Math.round(count));

  useGSAP(
    (_context, contextSafe) => {
      const box = boxRef.current;
      if (!box || !contextSafe) return;

      const mm = gsap.matchMedia();
      mm.add({ reduced: "(prefers-reduced-motion: reduce)", normal: "(prefers-reduced-motion: no-preference)" }, (media) => {
        const reduced = Boolean(media.conditions?.reduced);
        const safe = media.contextSafe ?? contextSafe;

        const words = Array.from(box.querySelectorAll<HTMLButtonElement>("[data-burst-word]"));
        const portraitsOf = (index: number) => Array.from(box.querySelectorAll<HTMLElement>(`[data-burst-portrait="${index}"]`));

        // Park every portrait, hidden and tiny, at the box's top left. A burst moves it to its word first.
        gsap.set(box.querySelectorAll("[data-burst-portrait]"), { xPercent: -50, yPercent: -50, x: 0, y: 0, scale: 0.2, rotation: 0, opacity: 0, force3D: true });

        const timelines = new Map<number, gsap.core.Timeline>();
        const opened = new Set<number>();

        // Where the word's middle is, measured from the box's top left corner
        const centreOf = (word: HTMLElement) => {
          const wordRect = word.getBoundingClientRect();
          const boxRect = box.getBoundingClientRect();
          return { x: wordRect.left - boxRect.left + wordRect.width / 2, y: wordRect.top - boxRect.top + wordRect.height / 2 };
        };

        const open = (index: number) => {
          const word = words[index];
          const portraits = portraitsOf(index);
          if (!word || portraits.length === 0 || opened.has(index)) return;
          opened.add(index);
          timelines.get(index)?.kill();

          const centre = centreOf(word);
          const boxRect = box.getBoundingClientRect();
          const size = { width: portraits[0]!.offsetWidth, height: portraits[0]!.offsetHeight };
          const spots = pickSpots(portraits.length, spread, reduced ? 0 : rotate, centre, { width: boxRect.width, height: boxRect.height }, size);

          // A portrait that is still on its way back in carries on from where it is. A hidden one starts inside the word.
          portraits.forEach((portrait, k) => {
            if (Number(gsap.getProperty(portrait, "opacity")) > 0.02) return;
            gsap.set(portrait, { x: reduced ? spots[k]!.x : centre.x, y: reduced ? spots[k]!.y : centre.y, scale: reduced ? 1 : 0.2, rotation: 0, zIndex: k });
          });

          const timeline = gsap.timeline();
          timelines.set(index, timeline);
          if (reduced) {
            timeline.to(portraits, { opacity: 1, x: (k: number) => spots[k]!.x, y: (k: number) => spots[k]!.y, scale: 1, rotation: 0, duration: 0.25, ease: "none" });
            return;
          }
          const each = 0.04;
          timeline
            .to(portraits, { x: (k: number) => spots[k]!.x, y: (k: number) => spots[k]!.y, duration, ease: "power4.out", stagger: each }, 0)
            .to(portraits, { scale: 1, rotation: (k: number) => spots[k]!.rotation, duration: duration * 0.9, ease: "back.out(1.6)", stagger: each }, 0)
            .to(portraits, { opacity: 1, duration: duration * 0.3, ease: "power1.out", stagger: each }, 0);
        };

        const close = (index: number) => {
          const word = words[index];
          const portraits = portraitsOf(index);
          if (!word || portraits.length === 0 || !opened.has(index)) return;
          opened.delete(index);
          timelines.get(index)?.kill();

          const timeline = gsap.timeline();
          timelines.set(index, timeline);
          if (reduced) {
            timeline.to(portraits, { opacity: 0, duration: 0.15, ease: "none" });
            return;
          }
          // The last portrait out goes back in first, like a hand sweeping them up
          const centre = centreOf(word);
          const each = { each: 0.025, from: "end" } as const;
          const back = duration * 0.7;
          timeline
            .to(portraits, { x: centre.x, y: centre.y, scale: 0.2, rotation: 0, duration: back, ease: "power3.in", stagger: each }, 0)
            .to(portraits, { opacity: 0, duration: back * 0.4, ease: "power1.in", stagger: each }, back * 0.6);
        };

        const closeAll = () => opened.forEach((index) => close(index));

        // The ways a word can be reached: a mouse hovers, a keyboard focuses, a finger taps
        let tapped = false;
        const removers = words.map((word, index) => {
          const onEnter = safe((event: PointerEvent) => {
            if (event.pointerType !== "touch") open(index);
          });
          const onLeave = safe((event: PointerEvent) => {
            // A word focused with the keyboard stays open until you tab away from it
            if (event.pointerType !== "touch" && !word.matches(":focus-visible")) close(index);
          });
          const onFocus = safe(() => {
            if (word.matches(":focus-visible")) open(index);
          });
          const onBlur = safe(() => close(index));
          const onPointerDown = safe((event: PointerEvent) => {
            tapped = event.pointerType === "touch";
          });
          const onClick = safe(() => {
            // Only a tap toggles. Enter on a focused word is a click too, and it is already open.
            if (!tapped) return;
            tapped = false;
            if (opened.has(index)) close(index);
            else {
              closeAll();
              open(index);
            }
          });
          word.addEventListener("pointerenter", onEnter);
          word.addEventListener("pointerleave", onLeave);
          word.addEventListener("focus", onFocus);
          word.addEventListener("blur", onBlur);
          word.addEventListener("pointerdown", onPointerDown);
          word.addEventListener("click", onClick);
          return () => {
            word.removeEventListener("pointerenter", onEnter);
            word.removeEventListener("pointerleave", onLeave);
            word.removeEventListener("focus", onFocus);
            word.removeEventListener("blur", onBlur);
            word.removeEventListener("pointerdown", onPointerDown);
            word.removeEventListener("click", onClick);
          };
        });

        // A finger tapping anywhere that is not a word puts the portraits away
        const onDocumentPointerDown = safe((event: PointerEvent) => {
          if (event.pointerType === "touch" && !(event.target instanceof Node && words.some((word) => word.contains(event.target as Node)))) closeAll();
        });
        document.addEventListener("pointerdown", onDocumentPointerDown);

        // The spots were worked out for one box size, so a resize puts everything away
        let firstCall = true;
        const resizeObserver = new ResizeObserver(() => {
          if (firstCall) {
            firstCall = false;
            return;
          }
          closeAll();
        });
        resizeObserver.observe(box);

        return () => {
          removers.forEach((remove) => remove());
          document.removeEventListener("pointerdown", onDocumentPointerDown);
          resizeObserver.disconnect();
          timelines.forEach((timeline) => timeline.kill());
          timelines.clear();
        };
      });

      return () => mm.revert();
    },
    { scope: boxRef, dependencies: [items, portraitCount, spread, rotate, duration, imageWidth], revertOnUpdate: true },
  );

  return (
    <div ref={boxRef} className={`relative isolate flex h-full w-full items-center justify-center ${className}`}>
      <p className={`m-0 ${textClassName}`} style={{ whiteSpace: "pre-line" }}>
        {items.map((item, index) => (
          <Fragment key={`${index}-${item.word}`}>
            {item.before}
            <button
              type="button"
              data-burst-word={index}
              className={`relative z-[2] m-0 cursor-default border-0 bg-transparent p-0 align-baseline font-[inherit] leading-[inherit] tracking-[inherit] whitespace-nowrap text-[inherit] underline decoration-dotted decoration-1 underline-offset-[0.22em] focus-visible:outline-1 focus-visible:outline-offset-[6px] focus-visible:outline-dotted ${wordClassName}`}
              style={{ touchAction: "manipulation", WebkitTapHighlightColor: "transparent" }}
            >
              {item.word}
            </button>
          </Fragment>
        ))}
        {ending}
      </p>

      {/* The portraits are decoration, so a screen reader skips them. They sit above the sentence and below the words. */}
      <div aria-hidden="true" className="pointer-events-none absolute inset-0 z-[1] overflow-hidden">
        {items.map((item, index) =>
          Array.from({ length: portraitCount }, (_, k) => (
            <img
              key={`${index}-${k}`}
              data-burst-portrait={index}
              src={item.images[k % Math.max(1, item.images.length)]}
              alt=""
              draggable={false}
              decoding="async"
              className={`absolute top-0 left-0 block aspect-[4/5] object-cover opacity-0 ${imageClassName}`}
              style={{ width: `min(${imageWidth}px, 30vw)`, willChange: "transform, opacity" }}
            />
          )),
        )}
      </div>
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { PortraitBurst, type BurstItem, type PortraitBurstProps } from "@/components/dimaac/portrait-burst";

// Photos from the DiMaac media set (Lummi License: free for personal and commercial use)
const fashion = ["black-trio", "grey-suit-stride", "mid-air-suit", "chunky-knit", "varsity-jacket", "orange-visor", "woven-corset", "gold-visor", "orange-ring", "purple-hoodie"].map((name) => media(`fashion/${name}-4x5.jpg`));
const faces = ["light-streaks", "platinum-blonde", "glossy-lowkey", "neon-profile", "tinted-glasses", "lime-blazer", "red-brushstroke", "red-coat", "burgundy-blazer", "mono-profile", "calm-studio", "warm-cool-profile"].map((name) => media(`portraits/${name}-4x5.jpg`));
const mixed = [
  "https://ui.dimaac.com/media/portraits/red-brushstroke-4x5.jpg",
  "https://ui.dimaac.com/media/fashion/orange-ring-4x5.jpg",
  "https://ui.dimaac.com/media/portraits/burgundy-blazer-4x5.jpg",
  "https://ui.dimaac.com/media/portraits/tinted-glasses-4x5.jpg",
  "https://ui.dimaac.com/media/fashion/varsity-jacket-4x5.jpg",
  "https://ui.dimaac.com/media/portraits/mono-profile-4x5.jpg",
  "https://ui.dimaac.com/media/fashion/gold-visor-4x5.jpg",
  "https://ui.dimaac.com/media/portraits/lime-blazer-4x5.jpg",
  "https://ui.dimaac.com/media/fashion/purple-hoodie-4x5.jpg",
  "https://ui.dimaac.com/media/portraits/neon-profile-4x5.jpg",
  "https://ui.dimaac.com/media/fashion/woven-corset-4x5.jpg",
  "https://ui.dimaac.com/media/portraits/platinum-blonde-4x5.jpg",
];

const items: BurstItem[] = [
  { before: "We cut in ", word: "small runs", images: fashion },
  { before: ",\nrefuse the ", word: "throwaway rhythm", images: faces },
  { before: ",\nand ", word: "design", images: mixed },
];

export default function Demo(props: Partial<PortraitBurstProps>) {
  return (
    <div className="relative h-full w-full overflow-hidden bg-[#f3f0eb] text-[#1c1917] dark:bg-[#0a0a0a] dark:text-[#ece8e1]">
      {/* Google Fonts stylesheet; React hoists it into the head */}
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@500&display=swap" precedence="default" />
      <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_55%_at_50%_40%,rgba(255,255,255,0.7),transparent_70%)] dark:bg-[radial-gradient(ellipse_80%_55%_at_50%_40%,rgba(255,255,255,0.05),transparent_70%)]" />

      <PortraitBurst
        items={items}
        ending=" for years on the body"
        {...props}
        className={`absolute inset-0 px-6 ${props.className ?? ""}`}
        textClassName="font-['DM_Serif_Display',Georgia,serif] text-center text-[clamp(2rem,5vw,3.25rem)] leading-[1.28] tracking-[-0.02em]"
        wordClassName="transition-colors duration-200 hover:text-[#8a6f4d] focus-visible:text-[#8a6f4d] dark:hover:text-[#e8dcc8] dark:focus-visible:text-[#e8dcc8]"
      />

      <p
        className="pointer-events-none absolute inset-x-6 bottom-[clamp(1.5rem,5vh,3rem)] z-[3] m-0 text-center text-[11px] tracking-[0.12em] uppercase opacity-45"
        style={{ fontFamily: '"DM Sans", ui-sans-serif, system-ui, sans-serif' }}
      >
        Hover or tab to a dotted word · tap on touch
      </p>
    </div>
  );
}
```

## Agent prompt

```text
Add the Portrait Burst component (<PortraitBurst />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/portrait-burst
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/portrait-burst.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/portrait-burst.json

Then use it like this:
import { PortraitBurst } from "@/components/dimaac/portrait-burst";
<PortraitBurst items={items} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/portrait-burst.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```
