{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"orbit-gallery","type":"registry:component","title":"Orbit gallery","description":"Small photographs turn slowly round a name; hover one and the ring stops and makes room, click it and it grows into the middle while the rest bloom outward.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/orbit-gallery.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { usePreviewPlay } from \"@/lib/preview-play\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n// After Gionatannese's GN.D portfolio (https://x.com/gionatannese): a ring\n// of small photographs turning slowly round a name. Hover one and the ring\n// eases to a stop, the card lifts and straightens, and its neighbours step\n// aside; click it and it glides into the middle and grows while the others\n// bloom outward. Click anywhere, or press Escape, to send them home.\n\ntype Item = { name: string; note: string; photo: string };\n\n// Photos from Unsplash (free to use under the Unsplash License). Each is\n// cropped by the CDN to the card's shape: small for the ring, large for an\n// open card.\nconst unsplash = (id: string, w: number) =>\n  `https://images.unsplash.com/${id}?w=${w}&h=${Math.round(w * 1.3)}&fit=crop&crop=entropy&q=80&auto=format`;\n\nconst ITEMS: Item[] = [\n  { name: \"Hemp oil\", note: \"Apothecary\", photo: \"photo-1611930021592-a8cfd5319ceb\" },\n  { name: \"Black label\", note: \"Bottle\", photo: \"photo-1674141838902-6beaf984f308\" },\n  { name: \"Speaker\", note: \"Hanging\", photo: \"photo-1702471897028-7839d575aef3\" },\n  { name: \"Dropper\", note: \"Serum\", photo: \"photo-1679394270822-fb37412bb6ee\" },\n  { name: \"Two candles\", note: \"Soy wax\", photo: \"photo-1616172912573-ed615bc0a14b\" },\n  { name: \"Succulent\", note: \"Clay pot\", photo: \"photo-1586652171272-8f18c34f79af\" },\n  { name: \"Sneaker\", note: \"Leather\", photo: \"photo-1625860191460-10a66c7384fb\" },\n  { name: \"Candle\", note: \"Glass jar\", photo: \"photo-1616172890963-a45e7da8de31\" },\n  { name: \"Shelf set\", note: \"Apothecary\", photo: \"photo-1611930021559-4a5cb5c38da3\" },\n  { name: \"Perfume\", note: \"On stone\", photo: \"photo-1705899853374-d91c048b81d2\" },\n  { name: \"Ceramics\", note: \"Stoneware\", photo: \"photo-1610219171722-87b3f4170557\" },\n  { name: \"Glasses\", note: \"Round frame\", photo: \"photo-1686165863154-b8f9d69add82\" },\n  { name: \"Stack\", note: \"Balanced\", photo: \"photo-1611930022073-b7a4ba5fcccd\" },\n  { name: \"Amber candle\", note: \"Wood lid\", photo: \"photo-1601248546843-33fae10e2751\" },\n];\n\n// A lap every 70 seconds: present without asking for attention.\nconst LAP = 70;\n// Card size at rest, in px. The open card grows to OPEN of the stage width.\nconst CARD_W = 50;\nconst CARD_H = 65;\nconst OPEN = 0.4;\n// A hovered card lifts this much, and how far its neighbours step aside,\n// in radians, for the first and second card either side.\nconst LIFT = 1.45;\nconst PART = [0.11, 0.045];\n// The springs: stiff enough to answer at once, with a little overshoot so\n// things settle like objects instead of stopping like a timer.\nconst STIFF = 190;\nconst DAMP = 21;\n\ntype Spring = { x: number; v: number };\nconst spring = (s: Spring, to: number, dt: number) => {\n  s.v += ((to - s.x) * STIFF - s.v * DAMP) * dt;\n  s.x += s.v * dt;\n};\n\nexport function OrbitGallery({\n  items = ITEMS,\n  title = \"Objects\",\n  className,\n}: {\n  items?: Item[];\n  title?: string;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const play = usePreviewPlay();\n  const box = useRef<HTMLDivElement>(null);\n  const cards = useRef<(HTMLButtonElement | null)[]>([]);\n  const [size, setSize] = useState(520);\n  const [hovered, setHovered] = useState<number | null>(null);\n  const [open, setOpen] = useState<number | null>(null);\n  // The index card's show moves a highlight round the ring.\n  const [shown, setShown] = useState<number | null>(null);\n  const focus = play ? shown : hovered;\n\n  // What the loop reads each frame.\n  const live = useRef({ hovered: focus, open, size });\n  const placeNow = useRef<(() => void) | null>(null);\n  useEffect(() => {\n    live.current = { hovered: focus, open, size };\n    if (reduceMotion || play === false) placeNow.current?.();\n  }, [focus, open, size, reduceMotion, play]);\n\n  useLayoutEffect(() => {\n    const el = box.current;\n    if (!el) return;\n    const measure = () => setSize(el.offsetWidth);\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  useEffect(() => {\n    const el = box.current;\n    if (!el) return;\n    const n = items.length;\n    const make = () => ({ x: 0, v: 0 });\n    const state = items.map(() => ({\n      x: make(),\n      y: make(),\n      lift: { x: 1, v: 0 },\n      grow: make(),\n      turn: make(),\n      part: make(),\n      fade: make(),\n      seeded: false,\n    }));\n    const ringSpeed = (Math.PI * 2) / LAP;\n    let angle = -Math.PI / 2;\n    let speed = ringSpeed;\n    let bloom = 0;\n    let frame = 0;\n    let last = 0;\n    let visible = false;\n    const animate = !reduceMotion && play !== false;\n\n    const place = (dt: number, instant: boolean) => {\n      const { hovered, open, size } = live.current;\n      // The ring brakes smoothly to a stop while anything is in focus.\n      const k = instant ? 1 : 1 - Math.exp(-5 * dt);\n      speed += ((hovered !== null || open !== null ? 0 : ringSpeed) - speed) * k;\n      bloom += ((open !== null ? 1 : 0) - bloom) * (instant ? 1 : 1 - Math.exp(-6 * dt));\n      if (animate) angle += speed * dt;\n      const ring = size * 0.34;\n      // Bloomed out as far as the stage allows, cards and lean included.\n      const far = size * 0.43;\n      // Cards are laid out at their open size and scaled down to sit in the\n      // ring, so the browser always has the full-size picture to draw from;\n      // growing one never stretches a small one.\n      const rest = CARD_W / (size * OPEN);\n\n      for (let i = 0; i < n; i++) {\n        const el = cards.current[i];\n        if (!el) continue;\n        const s = state[i];\n        const isOpen = open === i;\n        // Neighbours of the hovered card step aside along the ring.\n        let push = 0;\n        if (hovered !== null && hovered !== i) {\n          let d = i - hovered;\n          if (d > n / 2) d -= n;\n          if (d < -n / 2) d += n;\n          const away = PART[Math.abs(d) - 1];\n          if (away) push = Math.sign(d) * away;\n        }\n        const a = angle + (i / n) * Math.PI * 2 + s.part.x;\n        const r = ring + (far - ring) * bloom * (i % 2 ? 1.07 : 0.95);\n        const to = {\n          x: isOpen ? 0 : Math.cos(a) * r,\n          y: isOpen ? 0 : Math.sin(a) * r,\n          lift: isOpen ? 1 : hovered === i ? LIFT : open !== null ? 0.8 : 1,\n          grow: isOpen ? 1 : 0,\n          // At rest each card leans a few degrees, alternately, like\n          // prints dropped on a table; lifted or open, it straightens.\n          turn: isOpen || hovered === i ? 0 : (i % 2 ? 1 : -1) * (3 + (i % 3)),\n          part: push,\n          fade: open !== null && !isOpen ? 1 : 0,\n        };\n        if (!s.seeded || instant) {\n          s.x.x = to.x;\n          s.y.x = to.y;\n          s.lift.x = to.lift;\n          s.grow.x = to.grow;\n          s.turn.x = to.turn;\n          s.part.x = to.part;\n          s.fade.x = to.fade;\n          s.seeded = true;\n        } else {\n          spring(s.x, to.x, dt);\n          spring(s.y, to.y, dt);\n          spring(s.lift, to.lift, dt);\n          spring(s.grow, to.grow, dt);\n          spring(s.turn, to.turn, dt);\n          spring(s.part, to.part, dt);\n          spring(s.fade, to.fade, dt);\n        }\n        const scale = s.lift.x * (rest + (1 - rest) * Math.max(0, s.grow.x));\n        el.style.transform = `translate3d(${s.x.x}px, ${s.y.x}px, 0) rotate(${s.turn.x}deg) scale(${scale})`;\n        el.style.zIndex = String(isOpen ? 30 : hovered === i ? 20 : 1);\n        const fade = Math.min(1, Math.max(0, s.fade.x));\n        el.style.opacity = String(1 - fade * 0.45);\n        el.style.filter = fade > 0.01 ? `blur(${fade * 1.5}px)` : \"\";\n      }\n    };\n\n    const loop = (now: number) => {\n      // Small fixed steps keep the springs steady even on a slow frame.\n      let dt = last ? Math.min(0.05, (now - last) / 1000) : 0;\n      last = now;\n      while (dt > 0) {\n        const step = Math.min(dt, 1 / 120);\n        place(step, false);\n        dt -= step;\n      }\n      frame = visible ? requestAnimationFrame(loop) : 0;\n    };\n\n    place(0, true);\n    // Without the loop (reduced motion, or an idle index card), hover and\n    // open still rearrange the cards, at once.\n    placeNow.current = () => place(0, true);\n    if (!animate) return;\n\n    const io = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting;\n      if (visible && !frame) {\n        last = 0;\n        frame = requestAnimationFrame(loop);\n      }\n    });\n    io.observe(el);\n    return () => {\n      cancelAnimationFrame(frame);\n      io.disconnect();\n    };\n  }, [items, play, reduceMotion]);\n\n  // The card's show: the highlight steps round the ring.\n  useEffect(() => {\n    if (!play) return;\n    let i = 0;\n    const id = setInterval(() => {\n      setShown(i % 3 === 2 ? null : (i * 5) % items.length);\n      i += 1;\n    }, 1100);\n    return () => {\n      clearInterval(id);\n      setShown(null);\n    };\n  }, [play, items.length]);\n\n  // Escape sends everything home, and focus back to the card that was open.\n  useEffect(() => {\n    if (open === null) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\") return;\n      cards.current[open]?.focus({ preventScroll: true });\n      setOpen(null);\n    };\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, [open]);\n\n  const label = focus !== null ? items[focus] : null;\n  const openH = (size * OPEN * CARD_H) / CARD_W;\n  // Each card's layout size: its open size.\n  const bigW = size * OPEN;\n  const bigH = openH;\n  // Open size over ring size.\n  const k = bigW / CARD_W;\n\n  return (\n    <div\n      ref={box}\n      className={cn(\"relative aspect-square w-[520px] max-w-full select-none\", className)}\n      onClick={(e) => {\n        // A click on the empty stage closes; clicks on cards are their own.\n        if (e.target === e.currentTarget && open !== null) setOpen(null);\n      }}\n    >\n      {/* The name in the middle: the collection's, or the card you're on. */}\n      <div className=\"pointer-events-none absolute inset-0 grid place-items-center\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {open === null && (\n            <motion.div\n              key={label?.name ?? \"title\"}\n              className=\"text-center\"\n              initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(4px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(4px)\" }}\n              transition={{ duration: 0.22, ease: [0.23, 1, 0.32, 1] }}\n            >\n              <p className=\"text-[14px] font-medium tracking-tight\">{label ? label.name : title}</p>\n              <p className=\"mt-0.5 font-mono text-[11px] text-muted-foreground tabular-nums\">\n                {label ? label.note : `${items.length} pieces`}\n              </p>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n\n      {/* The open card's caption, under it. */}\n      <AnimatePresence>\n        {open !== null && (\n          <motion.div\n            key={open}\n            // Above the bloomed cards, which can drift under it on a phone.\n            className=\"pointer-events-none absolute inset-x-0 z-40 text-center\"\n            style={{ top: `calc(50% + ${openH / 2 + 14}px)` }}\n            initial={{ opacity: 0, y: -6, filter: \"blur(4px)\" }}\n            animate={{\n              opacity: 1,\n              y: 0,\n              filter: \"blur(0px)\",\n              transition: { delay: 0.2, duration: 0.35, ease: [0.23, 1, 0.32, 1] },\n            }}\n            exit={{ opacity: 0, transition: { duration: 0.12 } }}\n          >\n            <p className=\"text-[15px] font-medium tracking-tight\">{items[open].name}</p>\n            <p className=\"mt-0.5 font-mono text-[11px] text-muted-foreground\">{items[open].note}</p>\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      {items.map((item, i) => (\n        <button\n          key={item.photo}\n          ref={(el) => {\n            cards.current[i] = el;\n          }}\n          type=\"button\"\n          aria-label={item.name}\n          aria-pressed={open === i}\n          tabIndex={play === null ? 0 : -1}\n          onPointerEnter={(e) => e.pointerType !== \"touch\" && open === null && setHovered(i)}\n          onPointerLeave={() => setHovered((h) => (h === i ? null : h))}\n          onFocus={() => open === null && setHovered(i)}\n          onBlur={() => setHovered((h) => (h === i ? null : h))}\n          onClick={() => {\n            setHovered(null);\n            setOpen((o) => (o === i ? null : i));\n          }}\n          className=\"absolute top-1/2 left-1/2 overflow-hidden bg-muted outline-hidden will-change-transform focus-visible:outline-solid focus-visible:outline-foreground\"\n          style={{\n            width: bigW,\n            height: bigH,\n            marginLeft: -bigW / 2,\n            marginTop: -bigH / 2,\n            // Corners, shadow and focus ring are drawn at open size, so\n            // they're multiplied up to come out right once scaled down.\n            borderRadius: 4 * k,\n            boxShadow: `0 ${k}px ${k}px rgb(0 0 0 / 0.1), 0 ${6 * k}px ${14 * k}px ${-6 * k}px rgb(0 0 0 / 0.35)`,\n            outlineWidth: 2 * k,\n            outlineOffset: 2 * k,\n          }}\n        >\n          {/* eslint-disable-next-line @next/next/no-img-element -- remote\n              CDN crops, sized per state, without an image config. */}\n          <img\n            // Sharp in the ring even lifted and zoomed in.\n            src={unsplash(item.photo, 280)}\n            alt=\"\"\n            loading=\"lazy\"\n            decoding=\"async\"\n            draggable={false}\n            className=\"size-full object-cover\"\n          />\n          {/* The large print, loaded only once a card is opened, so the\n              grown card stays sharp. */}\n          {open === i && (\n            <motion.img\n              src={unsplash(item.photo, 900)}\n              alt=\"\"\n              decoding=\"async\"\n              draggable={false}\n              className=\"absolute inset-0 size-full object-cover\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              transition={{ duration: 0.3 }}\n            />\n          )}\n        </button>\n      ))}\n    </div>\n  );\n}\n\nexport default function OrbitGalleryDemo() {\n  return <OrbitGallery />;\n}\n"},{"path":"lib/preview-play.ts","type":"registry:lib","target":"lib/preview-play.ts","content":"\"use client\";\n\nimport { createContext, useContext } from \"react\";\n\n// Lets a demo know whether it's showing in an index card, and whether that\n// card is hovered or focused. Demos use it to act themselves out on hover:\n// a star button stars itself, a checkbox scribbles, and so on.\n//\n// null: not in a preview (the component's own page, or real use).\n// false: in a preview, at rest.\n// true: in a preview, being hovered or focused: play the show.\nexport const PreviewPlayContext = createContext<boolean | null>(null);\n\nexport function usePreviewPlay() {\n  return useContext(PreviewPlayContext);\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"categories":["cards"],"docs":"From ui lab: https://lab.xevrion.dev/lab/orbit-gallery"}