{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ring-carousel",
  "type": "registry:ui",
  "title": "Ring Carousel",
  "description": "A 3D ring of cards you can drag, flick and snap, with keyboard control and a flat reduced-motion fallback.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/ring-carousel.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, motion, useMotionValue, type AnimationPlaybackControls } 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 RingCarouselItem {\n  id: string;\n  content: React.ReactNode;\n  /** Short title used in announcements (\"Item 3 of 8: title\") and slide names. */\n  label: string;\n}\n\nexport interface RingCarouselProps extends Omit<React.ComponentPropsWithoutRef<\"section\">, \"children\"> {\n  items: RingCarouselItem[];\n  /** Accessible name of the carousel. */\n  label?: string;\n  /** Card width in px; the ring radius is derived from it, the gap and the item count. */\n  cardWidth?: number;\n  /** Card height in px. Defaults to 1.3 × cardWidth. */\n  cardHeight?: number;\n  /** Space between neighbouring cards around the ring, in px. */\n  gap?: number;\n  /** CSS perspective in px — lower is more dramatic. */\n  perspective?: number;\n  /** Ring tilt in degrees; negative looks down onto the ring. */\n  tilt?: number;\n  /** Step to the next card on an interval (pauses on hover, focus, offscreen and hidden tabs). */\n  autoRotate?: boolean;\n  /** Auto-rotate interval in ms. */\n  interval?: number;\n  /** Front-card ring, focus and control colour (any CSS colour). */\n  accent?: string;\n  /** Previous / pause / next buttons and the counter. */\n  showControls?: boolean;\n  /** Initially centred card. */\n  defaultIndex?: number;\n  /** Called when the front card changes. */\n  onIndexChange?: (index: number) => void;\n}\n\nconst SPRING = { type: \"spring\", stiffness: 150, damping: 24, mass: 1 } as const;\nconst mod = (n: number, m: number) => ((n % m) + m) % m;\n\nfunction subscribeVisibility(callback: () => void) {\n  document.addEventListener(\"visibilitychange\", callback);\n  return () => document.removeEventListener(\"visibilitychange\", callback);\n}\n\nconst iconButton =\n  \"grid size-9 place-items-center rounded-full border border-zinc-900/12 bg-white text-zinc-700 outline-none transition-colors hover:border-zinc-900/25 hover:text-zinc-950 focus-visible:ring-2 focus-visible:ring-[var(--rc-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-transparent 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\n/**\n * A ring of cards in CSS 3D: each card is rotated around a cylinder whose radius is computed\n * from the card width and count. Drag to spin (with inertia and snap), use the buttons or the\n * arrow keys, or let it step automatically. Only the front card is interactive; the rest are\n * inert and hidden from assistive tech. Reduced motion swaps the ring for a flat scroller.\n */\nexport function RingCarousel({\n  items,\n  label = \"Featured\",\n  cardWidth = 220,\n  cardHeight: cardHeightProp,\n  gap = 28,\n  perspective = 1200,\n  tilt = -8,\n  autoRotate = true,\n  interval = 3200,\n  accent = \"#FF7448\",\n  showControls = true,\n  defaultIndex = 0,\n  onIndexChange,\n  className = \"\",\n  style,\n  ...props\n}: RingCarouselProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const n = items.length;\n  const step = n > 0 ? 360 / n : 360;\n  const cardHeight = cardHeightProp ?? Math.round(cardWidth * 1.3);\n  // Chord between neighbouring card centres = cardWidth + gap → r = (c / 2) / tan(π / n).\n  const radius = n > 1 ? Math.round((cardWidth + gap) / 2 / Math.tan(Math.PI / n)) : 0;\n  const startIndex = n ? mod(Math.round(defaultIndex), n) : 0;\n  // Tilting lifts the back of the ring above the front card: reserve that height (plus breathing room).\n  const verticalRoom = Math.round(2 * radius * Math.sin((Math.abs(tilt) * Math.PI) / 180) + 36);\n\n  const rootRef = React.useRef<HTMLElement>(null);\n  const stageRef = React.useRef<HTMLDivElement>(null);\n  const cardRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const scrollerRef = React.useRef<HTMLUListElement>(null);\n  const angle = useMotionValue(-startIndex * step);\n  const anim = React.useRef<AnimationPlaybackControls | null>(null);\n  const target = React.useRef<number | null>(null);\n  const scale = React.useRef(1);\n  const indexRef = React.useRef(startIndex);\n  const onIndexChangeRef = React.useRef(onIndexChange);\n  const drag = React.useRef<{ id: number; x: number; t: number; startX: number; start: number; v: number; moved: boolean } | null>(null);\n  const suppressClick = React.useRef(false);\n\n  const [index, setIndex] = React.useState(startIndex);\n  const [dragging, setDragging] = React.useState(false);\n  const [hovering, setHovering] = React.useState(false);\n  const [focusWithin, setFocusWithin] = React.useState(false);\n  const [userPaused, setUserPaused] = React.useState(false);\n  const [inView, setInView] = React.useState(true);\n  const hidden = React.useSyncExternalStore(subscribeVisibility, () => document.hidden, () => false);\n\n  const front = n ? Math.min(index, n - 1) : 0;\n  const flat = reduceMotion;\n  const running = autoRotate && !flat && n > 1 && !userPaused && !hovering && !focusWithin && !dragging && inView && !hidden;\n\n  React.useEffect(() => {\n    onIndexChangeRef.current = onIndexChange;\n  });\n\n  // Track the front card and depth shading from the angle — DOM writes, no per-frame state.\n  React.useEffect(() => {\n    if (!n) return;\n    const update = (a: number) => {\n      cardRefs.current.forEach((el, i) => {\n        if (!el) return;\n        const rel = ((i * step + a) * Math.PI) / 180;\n        el.style.setProperty(\"--rc-dim\", ((1 - Math.cos(rel)) * 0.3).toFixed(3));\n      });\n      const idx = mod(Math.round(-a / step), n);\n      if (idx !== indexRef.current) {\n        indexRef.current = idx;\n        setIndex(idx);\n        onIndexChangeRef.current?.(idx);\n      }\n    };\n    // When the item count changes, re-seat the ring on the (clamped) current card.\n    const seat = -Math.min(indexRef.current, n - 1) * step;\n    if (Math.abs(mod(angle.get() - seat + 180, 360) - 180) > 0.01) angle.set(seat);\n    update(angle.get());\n    return angle.on(\"change\", update);\n  }, [angle, step, n]);\n\n  // Pause when offscreen; fit the ring to narrow containers by scaling the scene.\n  React.useEffect(() => {\n    const root = rootRef.current;\n    const stage = stageRef.current;\n    if (!root) return;\n    const io = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting));\n    io.observe(root);\n    const ro = new ResizeObserver(() => {\n      if (!stage) return;\n      const s = Math.min(1, stage.clientWidth / (radius * 1.3 + cardWidth + 32));\n      scale.current = s;\n      root.style.setProperty(\"--rc-scale\", s.toFixed(3));\n    });\n    if (stage) ro.observe(stage);\n    return () => {\n      io.disconnect();\n      ro.disconnect();\n    };\n  }, [radius, cardWidth, flat]);\n\n  const spinTo = (to: number, velocity = 0) => {\n    anim.current?.stop();\n    target.current = to;\n    anim.current = animate(angle, to, { ...SPRING, velocity });\n  };\n\n  /** Steps by whole cards from the current target (so quick repeated presses accumulate). */\n  const stepBy = (delta: number) => {\n    const base = target.current ?? angle.get();\n    spinTo(Math.round(base / step) * step - delta * step);\n  };\n\n  const goTo = (i: number) => {\n    const base = -i * step;\n    const current = angle.get();\n    spinTo(base + 360 * Math.round((current - base) / 360));\n  };\n\n  // Auto-rotation: one card per interval while nothing asks it to pause.\n  React.useEffect(() => {\n    if (!running) return;\n    const t = setInterval(() => {\n      anim.current?.stop();\n      const base = target.current ?? angle.get();\n      const to = Math.round(base / step) * step - step;\n      target.current = to;\n      anim.current = animate(angle, to, SPRING);\n    }, Math.max(1200, interval));\n    return () => clearInterval(t);\n  }, [running, interval, angle, step]);\n\n  React.useEffect(() => () => anim.current?.stop(), []);\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, x: e.clientX, t: e.timeStamp, startX: e.clientX, start: angle.get(), 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      // Only now capture, so a plain click still reaches the card underneath.\n      d.moved = true;\n      e.currentTarget.setPointerCapture(e.pointerId);\n      anim.current?.stop();\n      target.current = null;\n      d.start = angle.get();\n      d.startX = e.clientX;\n      setDragging(true);\n    }\n    const degPerPx = step / ((cardWidth + gap) * scale.current);\n    const dt = Math.max(1, e.timeStamp - d.t) / 1000;\n    d.v = d.v * 0.6 + (((e.clientX - d.x) * degPerPx) / dt) * 0.4;\n    d.x = e.clientX;\n    d.t = e.timeStamp;\n    angle.set(d.start + (e.clientX - d.startX) * degPerPx);\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 a = angle.get();\n    // Project the flick a little, snap to a card, and let the spring carry the velocity.\n    const maxSteps = Math.max(1, Math.floor(n / 2));\n    const projected = Math.max(-maxSteps * step, Math.min(maxSteps * step, velocity * 0.18));\n    spinTo(Math.round((a + projected) / step) * step, velocity);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    if (e.target !== e.currentTarget) return;\n    const actions: Record<string, () => void> = {\n      ArrowLeft: () => stepBy(-1),\n      ArrowRight: () => stepBy(1),\n      Home: () => goTo(0),\n      End: () => goTo(n - 1),\n    };\n    const action = actions[e.key];\n    if (!action) return;\n    e.preventDefault();\n    action();\n  };\n\n  const scrollFlat = (dir: 1 | -1) => {\n    const el = scrollerRef.current;\n    if (el) el.scrollLeft += dir * (cardWidth + gap);\n  };\n\n  if (!n) return null;\n  const current = items[front];\n\n  return (\n    <section\n      ref={rootRef}\n      aria-roledescription=\"carousel\"\n      aria-label={label}\n      className={`relative w-full ${className}`}\n      style={{ [\"--rc-accent\" as string]: accent, ...style }}\n      onPointerEnter={(e) => e.pointerType === \"mouse\" && setHovering(true)}\n      onPointerLeave={() => setHovering(false)}\n      onFocus={(e) => {\n        // Keyboard focus pauses rotation; a mouse click on a button doesn't.\n        if (e.target.matches(\":focus-visible\")) setFocusWithin(true);\n      }}\n      onBlur={(e) => {\n        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocusWithin(false);\n      }}\n      {...props}\n    >\n      {flat ? (\n        <ul\n          ref={scrollerRef}\n          aria-label={`${label} — scroll horizontally`}\n          className=\"flex snap-x snap-mandatory overflow-x-auto overscroll-x-contain px-4 pb-3 [scrollbar-width:thin]\"\n          style={{ gap }}\n        >\n          {items.map((item, i) => (\n            <li\n              key={item.id}\n              aria-label={`${i + 1} of ${n}: ${item.label}`}\n              className=\"shrink-0 snap-center overflow-hidden rounded-2xl border border-zinc-900/10 bg-white dark:border-white/10 dark:bg-zinc-900\"\n              style={{ width: cardWidth, height: cardHeight }}\n            >\n              {item.content}\n            </li>\n          ))}\n        </ul>\n      ) : (\n        <div\n          ref={stageRef}\n          role=\"group\"\n          aria-label={`${label}: use the Left and Right arrow keys to rotate`}\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 card must not also click it.\n            if (suppressClick.current) {\n              e.preventDefault();\n              e.stopPropagation();\n              suppressClick.current = false;\n            }\n          }}\n          className={`relative grid w-full touch-pan-y select-none place-items-center overflow-hidden rounded-2xl outline-none focus-visible:shadow-[inset_0_0_0_2px_var(--rc-accent)] ${\n            dragging ? \"cursor-grabbing\" : \"cursor-grab\"\n          }`}\n          style={{ height: `calc(${cardHeight + verticalRoom}px * var(--rc-scale, 1) + 1rem)` }}\n        >\n          {/* Floor shadow under the ring. */}\n          <div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute left-1/2 top-1/2 h-10 -translate-x-1/2 rounded-[50%] bg-black/[0.12] blur-2xl dark:bg-black/50\"\n            style={{ width: `calc(${radius * 1.6 + cardWidth}px * var(--rc-scale, 1))`, marginTop: `calc(${cardHeight / 2}px * var(--rc-scale, 1))` }}\n          />\n          <div className=\"relative [transform-style:preserve-3d]\" style={{ width: cardWidth, height: cardHeight, perspective, scale: \"var(--rc-scale, 1)\" }}>\n            <motion.div\n              className=\"absolute inset-0 [transform-style:preserve-3d]\"\n              style={{ z: -radius, rotateX: tilt, rotateY: angle }}\n            >\n              {items.map((item, i) => {\n                const isFront = i === front;\n                const rel = ((i * step - startIndex * step) * Math.PI) / 180;\n                return (\n                  <div\n                    key={item.id}\n                    ref={(el) => {\n                      cardRefs.current[i] = el;\n                    }}\n                    aria-hidden={!isFront}\n                    onClick={() => !isFront && goTo(i)}\n                    className=\"absolute inset-0 [transform-style:preserve-3d]\"\n                    style={{\n                      transform: `rotateY(${i * step}deg) translateZ(${radius}px)`,\n                      [\"--rc-dim\" as string]: ((1 - Math.cos(rel)) * 0.3).toFixed(3),\n                    }}\n                  >\n                    <div\n                      role=\"group\"\n                      aria-roledescription=\"slide\"\n                      aria-label={`${i + 1} of ${n}: ${item.label}`}\n                      inert={!isFront}\n                      className={`absolute inset-0 overflow-hidden rounded-2xl border bg-white transition-[border-color,box-shadow] duration-300 [backface-visibility:hidden] dark:bg-zinc-900 ${\n                        isFront\n                          ? \"border-[var(--rc-accent)] shadow-[0_24px_60px_-28px_rgba(0,0,0,0.45),0_0_0_3px_color-mix(in_srgb,var(--rc-accent)_22%,transparent)]\"\n                          : \"cursor-pointer border-zinc-900/10 dark:border-white/10\"\n                      }`}\n                    >\n                      {item.content}\n                      <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 bg-zinc-950\" style={{ opacity: \"var(--rc-dim)\" }} />\n                    </div>\n                    {/* Back face: a plain panel instead of mirrored content. */}\n                    <div\n                      aria-hidden=\"true\"\n                      className=\"absolute inset-0 rounded-2xl border border-zinc-900/10 bg-zinc-200 [backface-visibility:hidden] [transform:rotateY(180deg)] dark:border-white/10 dark:bg-zinc-800\"\n                    />\n                  </div>\n                );\n              })}\n            </motion.div>\n          </div>\n        </div>\n      )}\n\n      {showControls && (\n        <div className=\"mt-4 flex items-center justify-center gap-2\">\n          <button type=\"button\" aria-label=\"Previous item\" onClick={() => (flat ? scrollFlat(-1) : stepBy(-1))} className={iconButton}>\n            <Chevron dir=\"left\" />\n          </button>\n          {autoRotate && !flat && (\n            <button\n              type=\"button\"\n              aria-label={userPaused ? \"Start automatic rotation\" : \"Stop automatic rotation\"}\n              onClick={() => setUserPaused((p) => !p)}\n              className={iconButton}\n            >\n              <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"currentColor\">\n                {userPaused ? <path d=\"M5 3.2v9.6c0 .5.5.8.9.5l7.2-4.8c.4-.3.4-.8 0-1L5.9 2.7c-.4-.3-.9 0-.9.5Z\" /> : <path d=\"M4.5 3h2.2v10H4.5zM9.3 3h2.2v10H9.3z\" />}\n              </svg>\n            </button>\n          )}\n          {!flat && (\n            <span aria-hidden=\"true\" className=\"min-w-[9rem] 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(front + 1).padStart(2, \"0\")}</span> / {String(n).padStart(2, \"0\")} ·{\" \"}\n              {current.label}\n            </span>\n          )}\n          <button type=\"button\" aria-label=\"Next item\" onClick={() => (flat ? scrollFlat(1) : stepBy(1))} className={iconButton}>\n            <Chevron dir=\"right\" />\n          </button>\n        </div>\n      )}\n\n      {/* Announces the front card; silent while rotating automatically. */}\n      {!flat && (\n        <div className=\"sr-only\" aria-live={running ? \"off\" : \"polite\"} aria-atomic=\"true\">\n          {`Item ${front + 1} of ${n}: ${current.label}`}\n        </div>\n      )}\n    </section>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "3d"
  ],
  "docs": "Documentation: https://wancraft.my/components/ring-carousel"
}
