{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "coverflow",
  "type": "registry:ui",
  "title": "Coverflow",
  "description": "The classic coverflow: a forward-facing centre cover with the rest turned away in depth, on one spring.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/coverflow.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, motion, useMotionValue, useTransform, type AnimationPlaybackControls, type MotionValue } from \"motion/react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nexport interface CoverflowItem {\n  id: string;\n  content: React.ReactNode;\n  /** Short title, used in announcements (\"Album 3 of 9: title\") and slide names. */\n  label: string;\n}\n\nexport interface CoverflowSpring {\n  stiffness: number;\n  damping: number;\n  mass: number;\n}\n\nexport interface CoverflowProps extends Omit<React.ComponentPropsWithoutRef<\"section\">, \"children\"> {\n  items: CoverflowItem[];\n  /** Accessible name of the carousel. */\n  label?: string;\n  /** Noun used in announcements, e.g. \"Album\" → \"Album 3 of 9: title\". */\n  itemName?: string;\n  /** Cover size (width and height) in px. */\n  size?: number;\n  /** Rotation of the side covers, in degrees. */\n  angle?: number;\n  /** Distance between stacked side covers, as a fraction of the cover size. */\n  spacing?: number;\n  /** Spring used when the row moves and snaps. */\n  spring?: CoverflowSpring;\n  /** A faded mirror image under each cover. */\n  reflection?: boolean;\n  /** Previous / next buttons and the counter. */\n  showControls?: boolean;\n  /** Focus ring, centre-cover edge and control colour (any CSS colour). */\n  accent?: string;\n  /** Initially centred item. */\n  defaultIndex?: number;\n  /** Called when the centred item changes. */\n  onIndexChange?: (index: number) => void;\n}\n\nconst VISIBLE_SIDE = 4; // covers shown on each side before they fade out\nconst r2 = (n: number) => Math.round(n * 100) / 100;\nconst clampIndex = (i: number, n: number) => Math.max(0, Math.min(n - 1, i));\n\nconst iconButton =\n  \"grid size-9 place-items-center rounded-full border border-zinc-900/12 bg-white text-zinc-700 outline-hidden transition-colors hover:border-zinc-900/25 hover:text-zinc-950 aria-disabled:opacity-35 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--cf-accent)] dark:border-white/12 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-white/25 dark:hover:text-white\";\n\nfunction Chevron({ dir }: { dir: \"left\" | \"right\" }) {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"size-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n      <path d={dir === \"left\" ? \"M10 3.5 5.5 8l4.5 4.5\" : \"M6 3.5 10.5 8 6 12.5\"} />\n    </svg>\n  );\n}\n\ninterface Geometry {\n  size: number;\n  angle: number;\n  gap: number;\n  step: number;\n  depth: number;\n}\n\n/**\n * One cover. Its transform is derived from the shared position motion value, so the whole row\n * moves on one spring without re-rendering React. Side covers are aria-hidden with inert content;\n * a click on one (hit-testing falls through the inert content to this wrapper) centres it.\n */\nfunction CoverflowCard({\n  item,\n  index,\n  count,\n  pos,\n  geo,\n  centred,\n  reflection,\n  onSelect,\n}: {\n  item: CoverflowItem;\n  index: number;\n  count: number;\n  pos: MotionValue<number>;\n  geo: Geometry;\n  centred: boolean;\n  reflection: boolean;\n  onSelect: (index: number) => void;\n}) {\n  const offset = useTransform(pos, (p) => index - p);\n  const x = useTransform(offset, (o) => {\n    const a = Math.abs(o);\n    return r2(Math.sign(o) * (Math.min(1, a) * geo.gap + Math.max(0, a - 1) * geo.step));\n  });\n  const z = useTransform(offset, (o) => r2(-Math.min(1, Math.abs(o)) * geo.depth));\n  const rotateY = useTransform(offset, (o) => r2(-Math.sign(o) * Math.min(1, Math.abs(o)) * geo.angle));\n  const zIndex = useTransform(offset, (o) => 100 - Math.round(Math.abs(o) * 10));\n  const opacity = useTransform(offset, (o) => r2(Math.max(0, Math.min(1, VISIBLE_SIDE + 1 - Math.abs(o)))));\n  const shade = useTransform(offset, (o) => r2(Math.min(1, Math.abs(o)) * 0.42));\n\n  return (\n    <motion.div\n      role=\"group\"\n      aria-roledescription=\"slide\"\n      aria-label={`${index + 1} of ${count}: ${item.label}`}\n      aria-hidden={!centred}\n      onClick={centred ? undefined : () => onSelect(index)}\n      className={`absolute left-1/2 top-0 ${centred ? \"\" : \"cursor-pointer\"}`}\n      style={{ width: geo.size, height: geo.size, marginLeft: -geo.size / 2, x, z, rotateY, zIndex, opacity }}\n    >\n      <div\n        inert={!centred}\n        className={`relative h-full w-full overflow-hidden rounded-[4px] border bg-white shadow-[0_18px_40px_-22px_rgb(0_0_0/0.55)] dark:bg-zinc-900 ${\n          centred ? \"border-[color-mix(in_srgb,var(--cf-accent)_55%,transparent)]\" : \"border-zinc-900/10 dark:border-white/10\"\n        }`}\n      >\n        {item.content}\n        <motion.div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 bg-zinc-950\" style={{ opacity: shade }} />\n      </div>\n      {reflection && (\n        <div\n          aria-hidden=\"true\"\n          inert\n          className=\"pointer-events-none absolute inset-x-0 top-full mt-1.5 h-full -scale-y-100 overflow-hidden rounded-[4px] opacity-35 [mask-image:linear-gradient(to_top,#000,transparent_42%)] dark:opacity-30\"\n        >\n          {item.content}\n          <motion.div className=\"absolute inset-0 bg-zinc-950\" style={{ opacity: shade }} />\n        </div>\n      )}\n    </motion.div>\n  );\n}\n\n/**\n * The classic coverflow: the centred cover faces forward, the others turn away on rotateY and\n * stack in depth. Navigate with drag or swipe (the flick carries into a spring), a horizontal\n * wheel or trackpad swipe, the arrow keys while the stage is focused, the buttons, or by clicking\n * a side cover. Only the centred cover is interactive; the rest are inert and hidden from\n * assistive tech, and the centred one is announced as \"Album 3 of 9: title\".\n */\nexport function Coverflow({\n  items,\n  label = \"Coverflow\",\n  itemName = \"Item\",\n  size = 220,\n  angle = 60,\n  spacing = 0.26,\n  spring = { stiffness: 220, damping: 30, mass: 1 },\n  reflection = true,\n  showControls = true,\n  accent = \"#FF7448\",\n  defaultIndex = 0,\n  onIndexChange,\n  className = \"\",\n  style,\n  ...props\n}: CoverflowProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const n = items.length;\n  const start = n ? clampIndex(Math.round(defaultIndex), n) : 0;\n  const { stiffness, damping, mass } = spring;\n\n  const rootRef = React.useRef<HTMLElement>(null);\n  const stageRef = React.useRef<HTMLDivElement>(null);\n  const pos = useMotionValue(start);\n  const anim = React.useRef<AnimationPlaybackControls | null>(null);\n  const target = React.useRef(start);\n  const indexRef = React.useRef(start);\n  const scale = React.useRef(1);\n  const onIndexChangeRef = React.useRef(onIndexChange);\n  const drag = React.useRef<{ id: number; startX: number; start: number; x: number; t: number; v: number; moved: boolean } | null>(null);\n  const suppressClick = React.useRef(false);\n\n  const [index, setIndex] = React.useState(start);\n  const [dragging, setDragging] = React.useState(false);\n\n  const geo: Geometry = {\n    size,\n    angle: Math.max(0, Math.min(89, angle)),\n    gap: size * 0.7,\n    step: size * Math.max(0.05, spacing),\n    depth: size * 0.6,\n  };\n\n  React.useEffect(() => {\n    onIndexChangeRef.current = onIndexChange;\n  });\n\n  // The centred index follows the position (also mid-drag), without per-frame React renders.\n  React.useEffect(() => {\n    if (!n) return;\n    return pos.on(\"change\", (p) => {\n      const i = clampIndex(Math.round(p), n);\n      if (i !== indexRef.current) {\n        indexRef.current = i;\n        setIndex(i);\n        onIndexChangeRef.current?.(i);\n      }\n    });\n  }, [pos, n]);\n\n  // Scale the scene down to fit narrow containers.\n  React.useEffect(() => {\n    const stage = stageRef.current;\n    const root = rootRef.current;\n    if (!stage || !root) return;\n    const ro = new ResizeObserver(() => {\n      const s = Math.min(1, stage.clientWidth / (size * 3.1));\n      scale.current = s;\n      root.style.setProperty(\"--cf-scale\", s.toFixed(3));\n    });\n    ro.observe(stage);\n    return () => ro.disconnect();\n  }, [size]);\n\n  React.useEffect(() => () => anim.current?.stop(), []);\n\n  const goTo = (to: number, velocity = 0) => {\n    if (!n) return;\n    const next = clampIndex(to, n);\n    target.current = next;\n    anim.current?.stop();\n    if (reduceMotion) pos.set(next);\n    else anim.current = animate(pos, next, { type: \"spring\", stiffness, damping, mass, velocity });\n  };\n  /** Steps from the current target, so quick repeated presses accumulate. */\n  const stepBy = (delta: number) => goTo(target.current + delta);\n\n  // The wheel listener is attached once; it calls the latest goTo through a ref.\n  const goToRef = React.useRef(goTo);\n  React.useEffect(() => {\n    goToRef.current = goTo;\n  });\n\n  // Wheel: horizontal deltas (trackpad swipes, Shift + wheel) step through the covers. Vertical\n  // wheel scrolling is left to the page.\n  React.useEffect(() => {\n    const stage = stageRef.current;\n    if (!stage || n < 2) return;\n    let acc = 0;\n    let idle = 0;\n    const onWheel = (e: WheelEvent) => {\n      const dx = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.shiftKey ? e.deltaY : 0;\n      if (!dx) return;\n      e.preventDefault(); // also stops the browser's swipe-to-go-back gesture\n      acc += e.deltaMode === 1 ? dx * 16 : dx;\n      while (Math.abs(acc) >= 60) {\n        const dir = Math.sign(acc);\n        acc -= dir * 60;\n        goToRef.current(target.current + dir);\n      }\n      clearTimeout(idle);\n      idle = window.setTimeout(() => (acc = 0), 180);\n    };\n    stage.addEventListener(\"wheel\", onWheel, { passive: false });\n    return () => {\n      stage.removeEventListener(\"wheel\", onWheel);\n      clearTimeout(idle);\n    };\n  }, [n]);\n\n  /* --------------------------------- Drag --------------------------------- */\n\n  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (e.button !== 0 || n < 2) return;\n    suppressClick.current = false;\n    drag.current = { id: e.pointerId, startX: e.clientX, start: pos.get(), x: e.clientX, t: e.timeStamp, v: 0, moved: false };\n  };\n\n  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d || d.id !== e.pointerId) return;\n    if (!d.moved) {\n      if (Math.abs(e.clientX - d.startX) < 6) return;\n      // Capture only once it is a drag, so plain clicks still reach the covers.\n      d.moved = true;\n      e.currentTarget.setPointerCapture(e.pointerId);\n      anim.current?.stop();\n      d.start = pos.get();\n      d.startX = e.clientX;\n      setDragging(true);\n    }\n    const perItem = geo.size * 0.45 * scale.current;\n    const dt = Math.max(1, e.timeStamp - d.t) / 1000;\n    d.v = d.v * 0.6 + ((-(e.clientX - d.x) / perItem) / dt) * 0.4;\n    d.x = e.clientX;\n    d.t = e.timeStamp;\n    // Rubber-band past either end.\n    let p = d.start - (e.clientX - d.startX) / perItem;\n    if (p < 0) p *= 0.35;\n    if (p > n - 1) p = n - 1 + (p - (n - 1)) * 0.35;\n    pos.set(p);\n  };\n\n  const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d || d.id !== e.pointerId) return;\n    drag.current = null;\n    if (!d.moved) return;\n    suppressClick.current = true;\n    setDragging(false);\n    const velocity = e.timeStamp - d.t > 80 ? 0 : d.v; // a pause before release means no flick\n    const projected = pos.get() + Math.max(-3, Math.min(3, velocity * 0.18));\n    goTo(Math.round(projected), velocity);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    if (e.target !== e.currentTarget) return;\n    const keys: Record<string, () => void> = {\n      ArrowLeft: () => stepBy(-1),\n      ArrowRight: () => stepBy(1),\n      Home: () => goTo(0),\n      End: () => goTo(n - 1),\n    };\n    const action = keys[e.key];\n    if (!action) return;\n    e.preventDefault();\n    action();\n  };\n\n  if (!n) return null;\n  const current = items[index];\n  const stageHeight = Math.round(size * (reflection ? 1.5 : 1.12) + 24);\n\n  return (\n    <section\n      ref={rootRef}\n      aria-roledescription=\"carousel\"\n      aria-label={label}\n      className={`relative w-full ${className}`}\n      style={{ [\"--cf-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <div\n        ref={stageRef}\n        role=\"group\"\n        aria-label={`${label}: use the Left and Right arrow keys to browse`}\n        tabIndex={0}\n        onKeyDown={onKeyDown}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={endDrag}\n        onPointerCancel={endDrag}\n        onClickCapture={(e) => {\n          // A drag that ends over a cover must not also click it.\n          if (suppressClick.current) {\n            e.preventDefault();\n            e.stopPropagation();\n            suppressClick.current = false;\n          }\n        }}\n        className={`relative w-full touch-pan-y select-none overflow-hidden rounded-2xl outline-hidden focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--cf-accent)] ${\n          dragging ? \"cursor-grabbing\" : n > 1 ? \"cursor-grab\" : \"\"\n        }`}\n        style={{ height: `calc(${stageHeight}px * var(--cf-scale, 1))` }}\n      >\n        {/* Covers are direct children of the perspective box and are painted in z-index order. */}\n        <div\n          className=\"absolute left-1/2 top-3 origin-top\"\n          style={{ width: 0, height: size, perspective: size * 4.2, scale: \"var(--cf-scale, 1)\" }}\n        >\n          {items.map((item, i) => (\n            <CoverflowCard\n              key={item.id}\n              item={item}\n              index={i}\n              count={n}\n              pos={pos}\n              geo={geo}\n              centred={i === index}\n              reflection={reflection}\n              onSelect={goTo}\n            />\n          ))}\n        </div>\n      </div>\n\n      {showControls && (\n        <div className=\"mt-3 flex items-center justify-center gap-2\">\n          <button type=\"button\" aria-label={`Previous ${itemName.toLowerCase()}`} onClick={() => stepBy(-1)} aria-disabled={index === 0} className={iconButton}>\n            <Chevron dir=\"left\" />\n          </button>\n          <span aria-hidden=\"true\" className=\"min-w-[10rem] truncate text-center font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n            <span className=\"text-zinc-900 dark:text-zinc-100\">{String(index + 1).padStart(2, \"0\")}</span> / {String(n).padStart(2, \"0\")} · {current.label}\n          </span>\n          <button type=\"button\" aria-label={`Next ${itemName.toLowerCase()}`} onClick={() => stepBy(1)} aria-disabled={index === n - 1} className={iconButton}>\n            <Chevron dir=\"right\" />\n          </button>\n        </div>\n      )}\n\n      <div className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n        {`${itemName} ${index + 1} of ${n}: ${current.label}`}\n      </div>\n    </section>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "3d"
  ],
  "docs": "Documentation: https://wancraft.my/components/coverflow"
}
