{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"carousel-3d","type":"registry:component","title":"Carousel 3d","description":"Spin a ring of cards and flick it to land on the one you were aiming for.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/carousel-3d.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\ntype Item = { title: string; note: string };\n\nconst CARD_W = 168;\nconst CARD_H = 208;\n// Space between neighbouring cards along the ring.\nconst GAP = 24;\nconst PERSPECTIVE = 1100;\n// Dragging this far turns the ring by one card: close to the arc a front card\n// travels, so the card under the finger stays under it.\nconst PX_PER_CARD = 180;\n// Apple's scroll deceleration. Projecting the release velocity picks the card\n// a flick is heading for, not the one nearest where the finger let go.\nconst DECELERATION = 0.998;\n// Apple ships damping 0.8, response 0.4 for rotation. The small overshoot is\n// earned here: the ring only moves after a spin, a throw or a step.\nconst SPIN = { type: \"spring\", visualDuration: 0.4, bounce: 0.2 } as const;\n// Past this the pointer is dragging, not clicking a card.\nconst CLICK_SLOP = 6;\n// How much a card shrinks and dims by the time it reaches the back.\nconst BACK_SCALE = 0.9;\nconst BACK_OPACITY = 0.35;\n// Reduced motion lays the ring out flat; neighbours sit at half strength.\nconst ROW_OPACITY = 0.5;\n\nfunction project(velocity: number) {\n  return ((velocity / 1000) * DECELERATION) / (1 - DECELERATION);\n}\n\n// The copy of `index` nearest the current rotation, so clicking a card always\n// turns the short way round.\nfunction nearestTurn(index: number, rotation: number, count: number) {\n  return index + Math.round((rotation - index) / count) * count;\n}\n\nfunction wrap(value: number, count: number) {\n  return ((Math.round(value) % count) + count) % count;\n}\n\nexport function Carousel3D({\n  items,\n  label,\n  onChange,\n  className,\n}: {\n  items: Item[];\n  label: string;\n  onChange?: (index: number) => void;\n  className?: string;\n}) {\n  const reduce = useReducedMotion() ?? false;\n  const count = items.length;\n  // Counted in cards and never wrapped, so a spin can pass any number of\n  // turns and the spring still takes the direct path to its target.\n  const rotation = useMotionValue(0);\n  const target = useRef(0);\n  const [active, setActive] = useState(0);\n  const drag = useRef<{\n    id: number;\n    x: number;\n    from: number;\n    card: number | null;\n    moved: boolean;\n  } | null>(null);\n\n  const radius = (CARD_W + GAP) / 2 / Math.tan(Math.PI / count);\n\n  const goTo = (next: number, velocity = rotation.getVelocity()) => {\n    target.current = next;\n    const index = wrap(next, count);\n    setActive(index);\n    onChange?.(index);\n    if (reduce) {\n      rotation.jump(next);\n      return;\n    }\n    animate(rotation, next, { ...SPIN, velocity });\n  };\n\n  // Presses stack: two quick presses aim two cards ahead instead of\n  // restarting from wherever the ring happens to be mid-turn.\n  const step = (direction: 1 | -1) => goTo(target.current + direction);\n\n  const settle = () => goTo(Math.round(rotation.get()));\n\n  useEffect(() => () => rotation.stop(), [rotation]);\n\n  return (\n    <div\n      className={cn(\n        \"flex w-[min(520px,100%)] flex-col items-center gap-5\",\n        className,\n      )}\n    >\n      <div\n        role=\"group\"\n        aria-roledescription=\"carousel\"\n        aria-label={label}\n        tabIndex={0}\n        // pan-y: a vertical swipe still scrolls the page on touch screens.\n        className=\"relative h-[260px] w-full cursor-grab touch-pan-y overflow-hidden rounded-2xl outline-hidden select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:cursor-grabbing\"\n        style={{ perspective: PERSPECTIVE }}\n        onKeyDown={(e) => {\n          if (e.key !== \"ArrowLeft\" && e.key !== \"ArrowRight\") return;\n          e.preventDefault();\n          step(e.key === \"ArrowLeft\" ? -1 : 1);\n        }}\n        onPointerDown={(e) => {\n          // A second finger mid-drag would make the ring jump to it.\n          if (e.button !== 0 || drag.current) return;\n          // Grabbing a spinning ring stops it exactly where it is.\n          rotation.stop();\n          const card = (e.target as HTMLElement).closest<HTMLElement>(\n            \"[data-card]\",\n          );\n          drag.current = {\n            id: e.pointerId,\n            x: e.clientX,\n            from: rotation.get(),\n            card: card ? Number(card.dataset.card) : null,\n            moved: false,\n          };\n          e.currentTarget.setPointerCapture(e.pointerId);\n        }}\n        onPointerMove={(e) => {\n          const d = drag.current;\n          if (!d || d.id !== e.pointerId) return;\n          const dx = e.clientX - d.x;\n          if (!d.moved) {\n            if (Math.abs(dx) < CLICK_SLOP) return;\n            d.moved = true;\n            // Start from the finger, not from where the slop ran out.\n            d.x = e.clientX;\n            return;\n          }\n          // Dragging right brings the card on the left forward.\n          rotation.set(d.from - dx / PX_PER_CARD);\n        }}\n        onPointerUp={(e) => {\n          const d = drag.current;\n          if (!d || d.id !== e.pointerId) return;\n          drag.current = null;\n          if (d.moved) {\n            const velocity = rotation.getVelocity();\n            // Capped at one full turn, so a wild flick never loses your place.\n            const reach = Math.max(-count, Math.min(project(velocity), count));\n            goTo(Math.round(rotation.get() + reach), velocity);\n          } else if (d.card !== null) {\n            goTo(nearestTurn(d.card, rotation.get(), count));\n          } else {\n            settle();\n          }\n        }}\n        onPointerCancel={(e) => {\n          if (drag.current?.id !== e.pointerId) return;\n          drag.current = null;\n          settle();\n        }}\n      >\n        {/* Pushed back by the radius, so the front card sits on the\n            perspective plane and renders at exactly its own size. */}\n        <div\n          className=\"absolute top-1/2 left-1/2\"\n          style={{\n            transformStyle: \"preserve-3d\",\n            transform: reduce ? \"none\" : `translateZ(${-radius}px)`,\n          }}\n        >\n          {items.map((item, i) => (\n            <Card\n              // Remounts when the preference flips, so every transform\n              // switches layout at once.\n              key={`${i}-${reduce}`}\n              item={item}\n              index={i}\n              count={count}\n              radius={radius}\n              rotation={rotation}\n              front={i === active}\n              reduce={reduce}\n            />\n          ))}\n        </div>\n      </div>\n\n      <div className=\"flex items-center gap-3\">\n        <RingButton label=\"Previous card\" onClick={() => step(-1)}>\n          <path d=\"M10 3.5 5.5 8l4.5 4.5\" />\n        </RingButton>\n        <p\n          className=\"w-16 text-center font-mono text-sm text-muted-foreground tabular-nums\"\n          aria-live=\"polite\"\n          aria-atomic\n        >\n          <span className=\"sr-only\">{items[active].title}, card </span>\n          {String(active + 1).padStart(2, \"0\")}\n          <span aria-hidden> / </span>\n          <span className=\"sr-only\"> of </span>\n          {String(count).padStart(2, \"0\")}\n        </p>\n        <RingButton label=\"Next card\" onClick={() => step(1)}>\n          <path d=\"m6 3.5 4.5 4.5L6 12.5\" />\n        </RingButton>\n      </div>\n    </div>\n  );\n}\n\nfunction Card({\n  item,\n  index,\n  count,\n  radius,\n  rotation,\n  front,\n  reduce,\n}: {\n  item: Item;\n  index: number;\n  count: number;\n  radius: number;\n  rotation: MotionValue<number>;\n  front: boolean;\n  reduce: boolean;\n}) {\n  // Signed distance from the front in cards, wrapped the short way round.\n  const offset = useTransform(rotation, (r) => {\n    const d = (((index - r) % count) + count) % count;\n    return d > count / 2 ? d - count : d;\n  });\n  // 1 facing you, -1 facing away. Every depth cue reads from this one number.\n  const facing = useTransform(offset, (o) =>\n    Math.cos((o / count) * Math.PI * 2),\n  );\n  const transform = useTransform(offset, (o) => {\n    if (reduce) return `translateX(${o * (CARD_W + GAP)}px)`;\n    const f = Math.cos((o / count) * Math.PI * 2);\n    const scale = BACK_SCALE + ((1 - BACK_SCALE) * (f + 1)) / 2;\n    return `rotateY(${(o / count) * 360}deg) translateZ(${radius}px) scale(${scale})`;\n  });\n  const opacity = useTransform(offset, (o) => {\n    if (reduce) return Math.abs(o) < 0.5 ? 1 : ROW_OPACITY;\n    const f = Math.cos((o / count) * Math.PI * 2);\n    return BACK_OPACITY + ((1 - BACK_OPACITY) * (f + 1)) / 2;\n  });\n  // Text is gone before a card turns side-on, so nobody reads a card mirrored\n  // through the ring; the back half shows blank faces.\n  const text = useTransform(facing, (f) =>\n    reduce ? 1 : Math.min(Math.max((f - 0.1) / 0.5, 0), 1),\n  );\n\n  return (\n    <motion.div\n      data-card={index}\n      aria-hidden={!front}\n      className={cn(\n        \"absolute flex flex-col justify-between rounded-[20px] bg-background p-5 shadow-raised\",\n        // Reduced motion: the row cross-fades which card is lit instead.\n        reduce && \"transition-[opacity] duration-200 ease-out\",\n      )}\n      style={{\n        width: CARD_W,\n        height: CARD_H,\n        left: -CARD_W / 2,\n        top: -CARD_H / 2,\n        transform,\n        opacity,\n      }}\n    >\n      <motion.span\n        style={{ opacity: text }}\n        className=\"font-mono text-xs text-muted-foreground tabular-nums\"\n      >\n        {String(index + 1).padStart(2, \"0\")}\n      </motion.span>\n      <motion.p\n        style={{ opacity: text }}\n        className=\"text-lg leading-snug font-medium tracking-tight text-balance text-foreground\"\n      >\n        {item.title}\n        <span className=\"mt-1.5 block text-sm leading-normal font-normal tracking-normal text-pretty text-muted-foreground\">\n          {item.note}\n        </span>\n      </motion.p>\n    </motion.div>\n  );\n}\n\nfunction RingButton({\n  label,\n  onClick,\n  children,\n}: {\n  label: string;\n  onClick: () => void;\n  children: React.ReactNode;\n}) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      onClick={onClick}\n      className=\"flex size-11 items-center justify-center rounded-full bg-muted text-foreground shadow-raised outline-hidden transition-[scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-none\"\n    >\n      <svg\n        viewBox=\"0 0 16 16\"\n        className=\"size-4\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.5}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        aria-hidden\n      >\n        {children}\n      </svg>\n    </button>\n  );\n}\n\nconst ITEMS: Item[] = [\n  { title: \"Response\", note: \"Feedback lands on press.\" },\n  { title: \"Tracking\", note: \"Content stays under the finger.\" },\n  { title: \"Interruption\", note: \"Grab it mid-flight.\" },\n  { title: \"Momentum\", note: \"Land where the flick was heading.\" },\n  { title: \"Resistance\", note: \"Soft edges, never walls.\" },\n  { title: \"Springs\", note: \"Behaviour, not a timeline.\" },\n  { title: \"Restraint\", note: \"Bounce only after a throw.\" },\n  { title: \"Consistency\", note: \"Leave the way you came.\" },\n];\n\nexport default function Carousel3dDemo() {\n  return <Carousel3D items={ITEMS} label=\"Motion principles\" />;\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"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["cards"],"docs":"From ui lab: https://lab.xevrion.dev/lab/carousel-3d"}