Portrait Burst
Hover a word and a burst of portraits flies out, then tucks back in.
Customize
<PortraitBurst items={items} />Changes show live and stay in the address, so a link keeps them.
Code
"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>
);
}
The component. Installs to components/dimaac/portrait-burst.tsx.
Install
With the shadcn CLI
npx shadcn@latest add https://ui.dimaac.com/r/portrait-burst.jsonIt adds components/dimaac/portrait-burst.tsx and installs what it imports. With the @dimaac namespace in components.json (Docs) it's just npx shadcn@latest add @dimaac/portrait-burst.
By hand
npm install gsap @gsap/react- Install the packages it uses: gsap, @gsap/react.
- Copy the component file below into components/dimaac/portrait-burst.tsx.
- It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
- Import it with import { PortraitBurst } from "@/components/dimaac/portrait-burst" and give its parent a size: it fills it.
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. |
Use with AI
Agent prompt
Paste it into Claude Code, Cursor or any coding agent, or open it straight in a chat. It carries your Customize settings.
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.txtFiles agents read
- llms.txt
- Every component and how to install it
- portrait-burst.md
- This page as Markdown: install, props and the full source
- portrait-burst.json
- The shadcn registry item
About
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.
- Installs to
components/dimaac/portrait-burst.tsx- Uses
- gsap, @gsap/react
- Tags
- gsap, hover, portraits, text, typography
Portrait by West Kast, Axel Photography, Axel Photography, Semihan Dosunmu, Amino, West Kast, Semihan Dosunmu, West Kast, Bernardo Lickfold, Semihan Dosunmu, Kuba, studiomiklus, Edita, Shaun Duval, SHIHO, Amino, Steph Meade, Lloyd Creates, Amino, West Kast, Benginur Hajjaj, Dynamic Wang and SHIHO on Lummi (Lummi License).