{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"portrait-burst","type":"registry:component","title":"Portrait Burst","description":"Hover a word and a burst of portraits flies out, then tucks back in.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["gsap","@gsap/react"],"files":[{"path":"registry/dimaac/portrait-burst/portrait-burst.tsx","type":"registry:component","target":"components/dimaac/portrait-burst.tsx","content":"\"use client\";\n\nimport { Fragment, useRef } from \"react\";\nimport { gsap } from \"gsap\";\nimport { useGSAP } from \"@gsap/react\";\n\n// Words in a sentence that throw out a burst of portraits when you hover (or tab to) them. The\n// portraits fly from the word to random spots around it, then tuck back into it when you leave.\n// On touch screens a tap on the word opens it, and a tap anywhere else closes it.\n\nexport type BurstItem = {\n  /** The hoverable word (or short phrase) */\n  word: string;\n  /** Portraits that burst out of this word. If `count` is higher than this list, the list repeats. */\n  images: string[];\n  /** Plain text that comes before the word. Use \"\\n\" for a line break. */\n  before?: string;\n};\n\nexport type PortraitBurstProps = {\n  /** The words, in reading order, each with its own portraits. */\n  items: BurstItem[];\n  /** Plain text after the last word. */\n  ending?: string;\n  /** How many portraits burst out of one word. */\n  count?: number;\n  /** How far the portraits fly from the word, in pixels. */\n  spread?: number;\n  /** The most a portrait is tilted either way, in degrees. */\n  rotate?: number;\n  /** How long the burst takes, in seconds. The tuck back takes a bit less. */\n  duration?: number;\n  /** Width of each portrait, in pixels (they are 4:5). It shrinks on narrow screens. */\n  imageWidth?: number;\n  /** Extra classes for the outer box. It centres the sentence and keeps the portraits inside it, so give it a size. */\n  className?: string;\n  /** Extra classes for the sentence, e.g. its font size and colour. */\n  textClassName?: string;\n  /** Extra classes for each word, e.g. a hover colour. */\n  wordClassName?: string;\n  /** Extra classes for each portrait, e.g. a radius or a shadow. */\n  imageClassName?: string;\n};\n\ntype Spot = { x: number; y: number; rotation: number };\n\n// Spread the portraits round the word like petals: an even split of the circle, nudged randomly,\n// each at a random distance. The circle is squashed on a wide box, because a screen is wider than it is tall.\n// Spots are kept inside the box so nothing flies off the edge.\nfunction pickSpots(count: number, spread: number, rotate: number, centre: { x: number; y: number }, box: { width: number; height: number }, size: { width: number; height: number }): Spot[] {\n  const startAngle = Math.random() * Math.PI * 2;\n  const slice = (Math.PI * 2) / count;\n  const squash = box.height > box.width ? 1 : 0.7;\n  const marginX = size.width / 2 + 8;\n  const marginY = size.height / 2 + 8;\n  return Array.from({ length: count }, (_, index) => {\n    const angle = startAngle + index * slice + (Math.random() - 0.5) * slice * 0.6;\n    const reach = spread * (0.6 + Math.random() * 0.4);\n    const x = centre.x + Math.cos(angle) * reach;\n    const y = centre.y + Math.sin(angle) * reach * squash;\n    return {\n      x: Math.min(Math.max(x, marginX), Math.max(marginX, box.width - marginX)),\n      y: Math.min(Math.max(y, marginY), Math.max(marginY, box.height - marginY)),\n      rotation: (Math.random() * 2 - 1) * rotate,\n    };\n  });\n}\n\nexport function PortraitBurst({\n  items,\n  ending = \"\",\n  count = 6,\n  spread = 260,\n  rotate = 12,\n  duration = 0.8,\n  imageWidth = 150,\n  className = \"\",\n  textClassName = \"text-center text-[clamp(2rem,5vw,3.25rem)] leading-[1.28]\",\n  wordClassName = \"\",\n  imageClassName = \"rounded-[3px] shadow-[0_18px_40px_-16px_rgba(0,0,0,0.45)]\",\n}: PortraitBurstProps) {\n  const boxRef = useRef<HTMLDivElement>(null);\n  const portraitCount = Math.max(1, Math.round(count));\n\n  useGSAP(\n    (_context, contextSafe) => {\n      const box = boxRef.current;\n      if (!box || !contextSafe) return;\n\n      const mm = gsap.matchMedia();\n      mm.add({ reduced: \"(prefers-reduced-motion: reduce)\", normal: \"(prefers-reduced-motion: no-preference)\" }, (media) => {\n        const reduced = Boolean(media.conditions?.reduced);\n        const safe = media.contextSafe ?? contextSafe;\n\n        const words = Array.from(box.querySelectorAll<HTMLButtonElement>(\"[data-burst-word]\"));\n        const portraitsOf = (index: number) => Array.from(box.querySelectorAll<HTMLElement>(`[data-burst-portrait=\"${index}\"]`));\n\n        // Park every portrait, hidden and tiny, at the box's top left. A burst moves it to its word first.\n        gsap.set(box.querySelectorAll(\"[data-burst-portrait]\"), { xPercent: -50, yPercent: -50, x: 0, y: 0, scale: 0.2, rotation: 0, opacity: 0, force3D: true });\n\n        const timelines = new Map<number, gsap.core.Timeline>();\n        const opened = new Set<number>();\n\n        // Where the word's middle is, measured from the box's top left corner\n        const centreOf = (word: HTMLElement) => {\n          const wordRect = word.getBoundingClientRect();\n          const boxRect = box.getBoundingClientRect();\n          return { x: wordRect.left - boxRect.left + wordRect.width / 2, y: wordRect.top - boxRect.top + wordRect.height / 2 };\n        };\n\n        const open = (index: number) => {\n          const word = words[index];\n          const portraits = portraitsOf(index);\n          if (!word || portraits.length === 0 || opened.has(index)) return;\n          opened.add(index);\n          timelines.get(index)?.kill();\n\n          const centre = centreOf(word);\n          const boxRect = box.getBoundingClientRect();\n          const size = { width: portraits[0]!.offsetWidth, height: portraits[0]!.offsetHeight };\n          const spots = pickSpots(portraits.length, spread, reduced ? 0 : rotate, centre, { width: boxRect.width, height: boxRect.height }, size);\n\n          // A portrait that is still on its way back in carries on from where it is. A hidden one starts inside the word.\n          portraits.forEach((portrait, k) => {\n            if (Number(gsap.getProperty(portrait, \"opacity\")) > 0.02) return;\n            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 });\n          });\n\n          const timeline = gsap.timeline();\n          timelines.set(index, timeline);\n          if (reduced) {\n            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\" });\n            return;\n          }\n          const each = 0.04;\n          timeline\n            .to(portraits, { x: (k: number) => spots[k]!.x, y: (k: number) => spots[k]!.y, duration, ease: \"power4.out\", stagger: each }, 0)\n            .to(portraits, { scale: 1, rotation: (k: number) => spots[k]!.rotation, duration: duration * 0.9, ease: \"back.out(1.6)\", stagger: each }, 0)\n            .to(portraits, { opacity: 1, duration: duration * 0.3, ease: \"power1.out\", stagger: each }, 0);\n        };\n\n        const close = (index: number) => {\n          const word = words[index];\n          const portraits = portraitsOf(index);\n          if (!word || portraits.length === 0 || !opened.has(index)) return;\n          opened.delete(index);\n          timelines.get(index)?.kill();\n\n          const timeline = gsap.timeline();\n          timelines.set(index, timeline);\n          if (reduced) {\n            timeline.to(portraits, { opacity: 0, duration: 0.15, ease: \"none\" });\n            return;\n          }\n          // The last portrait out goes back in first, like a hand sweeping them up\n          const centre = centreOf(word);\n          const each = { each: 0.025, from: \"end\" } as const;\n          const back = duration * 0.7;\n          timeline\n            .to(portraits, { x: centre.x, y: centre.y, scale: 0.2, rotation: 0, duration: back, ease: \"power3.in\", stagger: each }, 0)\n            .to(portraits, { opacity: 0, duration: back * 0.4, ease: \"power1.in\", stagger: each }, back * 0.6);\n        };\n\n        const closeAll = () => opened.forEach((index) => close(index));\n\n        // The ways a word can be reached: a mouse hovers, a keyboard focuses, a finger taps\n        let tapped = false;\n        const removers = words.map((word, index) => {\n          const onEnter = safe((event: PointerEvent) => {\n            if (event.pointerType !== \"touch\") open(index);\n          });\n          const onLeave = safe((event: PointerEvent) => {\n            // A word focused with the keyboard stays open until you tab away from it\n            if (event.pointerType !== \"touch\" && !word.matches(\":focus-visible\")) close(index);\n          });\n          const onFocus = safe(() => {\n            if (word.matches(\":focus-visible\")) open(index);\n          });\n          const onBlur = safe(() => close(index));\n          const onPointerDown = safe((event: PointerEvent) => {\n            tapped = event.pointerType === \"touch\";\n          });\n          const onClick = safe(() => {\n            // Only a tap toggles. Enter on a focused word is a click too, and it is already open.\n            if (!tapped) return;\n            tapped = false;\n            if (opened.has(index)) close(index);\n            else {\n              closeAll();\n              open(index);\n            }\n          });\n          word.addEventListener(\"pointerenter\", onEnter);\n          word.addEventListener(\"pointerleave\", onLeave);\n          word.addEventListener(\"focus\", onFocus);\n          word.addEventListener(\"blur\", onBlur);\n          word.addEventListener(\"pointerdown\", onPointerDown);\n          word.addEventListener(\"click\", onClick);\n          return () => {\n            word.removeEventListener(\"pointerenter\", onEnter);\n            word.removeEventListener(\"pointerleave\", onLeave);\n            word.removeEventListener(\"focus\", onFocus);\n            word.removeEventListener(\"blur\", onBlur);\n            word.removeEventListener(\"pointerdown\", onPointerDown);\n            word.removeEventListener(\"click\", onClick);\n          };\n        });\n\n        // A finger tapping anywhere that is not a word puts the portraits away\n        const onDocumentPointerDown = safe((event: PointerEvent) => {\n          if (event.pointerType === \"touch\" && !(event.target instanceof Node && words.some((word) => word.contains(event.target as Node)))) closeAll();\n        });\n        document.addEventListener(\"pointerdown\", onDocumentPointerDown);\n\n        // The spots were worked out for one box size, so a resize puts everything away\n        let firstCall = true;\n        const resizeObserver = new ResizeObserver(() => {\n          if (firstCall) {\n            firstCall = false;\n            return;\n          }\n          closeAll();\n        });\n        resizeObserver.observe(box);\n\n        return () => {\n          removers.forEach((remove) => remove());\n          document.removeEventListener(\"pointerdown\", onDocumentPointerDown);\n          resizeObserver.disconnect();\n          timelines.forEach((timeline) => timeline.kill());\n          timelines.clear();\n        };\n      });\n\n      return () => mm.revert();\n    },\n    { scope: boxRef, dependencies: [items, portraitCount, spread, rotate, duration, imageWidth], revertOnUpdate: true },\n  );\n\n  return (\n    <div ref={boxRef} className={`relative isolate flex h-full w-full items-center justify-center ${className}`}>\n      <p className={`m-0 ${textClassName}`} style={{ whiteSpace: \"pre-line\" }}>\n        {items.map((item, index) => (\n          <Fragment key={`${index}-${item.word}`}>\n            {item.before}\n            <button\n              type=\"button\"\n              data-burst-word={index}\n              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}`}\n              style={{ touchAction: \"manipulation\", WebkitTapHighlightColor: \"transparent\" }}\n            >\n              {item.word}\n            </button>\n          </Fragment>\n        ))}\n        {ending}\n      </p>\n\n      {/* The portraits are decoration, so a screen reader skips them. They sit above the sentence and below the words. */}\n      <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 z-[1] overflow-hidden\">\n        {items.map((item, index) =>\n          Array.from({ length: portraitCount }, (_, k) => (\n            <img\n              key={`${index}-${k}`}\n              data-burst-portrait={index}\n              src={item.images[k % Math.max(1, item.images.length)]}\n              alt=\"\"\n              draggable={false}\n              decoding=\"async\"\n              className={`absolute top-0 left-0 block aspect-[4/5] object-cover opacity-0 ${imageClassName}`}\n              style={{ width: `min(${imageWidth}px, 30vw)`, willChange: \"transform, opacity\" }}\n            />\n          )),\n        )}\n      </div>\n    </div>\n  );\n}\n"}],"categories":["cursor","gsap","hover","portraits","text","typography"],"docs":"import { PortraitBurst } from \"@/components/dimaac/portrait-burst\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/portrait-burst","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/portrait-burst","credits":[{"by":"West Kast","url":"https://www.lummi.ai/photo/monochrome-trio-style-va9g1","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Axel Photography","url":"https://www.lummi.ai/photo/futuristic-fashion-pose-6nymn","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Axel Photography","url":"https://www.lummi.ai/photo/modern-fashion-pose-zsdav","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Semihan Dosunmu","url":"https://www.lummi.ai/photo/avant-garde-fashion-pose-1yqoo","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Amino","url":"https://www.lummi.ai/photo/stylish-studio-portrait-d9xnl","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"West Kast","url":"https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Semihan Dosunmu","url":"https://www.lummi.ai/photo/avant-garde-fashion-portrait-rjvlr","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"West Kast","url":"https://www.lummi.ai/photo/futuristic-gold-profile-jnio5","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Bernardo Lickfold","url":"https://www.lummi.ai/photo/portrait-through-orange-frame-kld5i","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Semihan Dosunmu","url":"https://www.lummi.ai/photo/chic-fashion-portrait-wojpk","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Kuba","url":"https://www.lummi.ai/photo/abstract-eye-close-up-uamae","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"studiomiklus","url":"https://www.lummi.ai/photo/striking-portrait-jodiu","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Edita","url":"https://www.lummi.ai/photo/striking-portrait-kfent","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Shaun Duval","url":"https://www.lummi.ai/photo/portrait-with-neon-sunglasses-gindg","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"SHIHO","url":"https://www.lummi.ai/photo/vibrant-woman-portrait-htofy","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Amino","url":"https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Steph Meade","url":"https://www.lummi.ai/photo/expressive-minimal-portrait-jrfyc","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Lloyd Creates","url":"https://www.lummi.ai/photo/poised-portrait-ooi9b","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Amino","url":"https://www.lummi.ai/photo/stylish-portrait-8inth","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"West Kast","url":"https://www.lummi.ai/photo/monochrome-portrait-of-a-young-woman-shb7s","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Benginur Hajjaj","url":"https://www.lummi.ai/photo/calm-portrait-d1zqo","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Dynamic Wang","url":"https://www.lummi.ai/photo/profile-portrait-of-woman-xyyue","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"SHIHO","url":"https://www.lummi.ai/photo/mystery-portrait-cffyo","what":"Portrait","source":"Lummi","licence":"Lummi License"}]}}