{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cube-carousel",
  "type": "registry:ui",
  "title": "Cube Carousel",
  "description": "Slides on the faces of a 3D cube that turns with a spring — drag, keys, buttons or auto-rotate.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/cube-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\nfunction subscribeVisibility(callback: () => void) {\n  document.addEventListener(\"visibilitychange\", callback);\n  return () => document.removeEventListener(\"visibilitychange\", callback);\n}\n\nexport interface CubeCarouselItem {\n  id: string;\n  content: React.ReactNode;\n  /** Short title used in announcements (\"Face 2 of 4: title\") and slide names. */\n  label: string;\n}\n\nexport interface CubeSpring {\n  stiffness: number;\n  damping: number;\n  mass: number;\n}\n\nexport interface CubeCarouselProps extends Omit<React.ComponentPropsWithoutRef<\"section\">, \"children\"> {\n  /**\n   * Slides. The cube has four physical side faces; with more than four items the faces are\n   * reused — the two faces beside the front one are refilled with the previous/next items\n   * whenever the cube settles on a new face, and the hidden back face is refilled silently.\n   */\n  items: CubeCarouselItem[];\n  /** Accessible name of the carousel. */\n  label?: string;\n  /** Face width in px (also the cube's depth). */\n  width?: number;\n  /** Face height in px. Defaults to 0.7 × width. */\n  height?: number;\n  /** CSS perspective in px — lower is more dramatic. */\n  perspective?: number;\n  /** Cube tilt in degrees; negative looks down onto the top. */\n  tilt?: number;\n  /** Spring used when the cube turns and snaps. */\n  spring?: CubeSpring;\n  /** Turn to the next face on an interval (pauses on hover, focus, offscreen and hidden tabs). */\n  autoRotate?: boolean;\n  /** Auto-rotate interval in ms. */\n  interval?: number;\n  /** Previous / pause / next buttons and the counter. */\n  showControls?: boolean;\n  /** Front-face edge, focus and control colour (any CSS colour). */\n  accent?: string;\n  /** Initially shown item. */\n  defaultIndex?: number;\n  /** Called when the front item changes. */\n  onIndexChange?: (index: number) => void;\n}\n\nconst FACES = [0, 1, 2, 3] as const;\nconst mod = (n: number, m: number) => ((n % m) + m) % m;\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 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--cc-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\n/**\n * A CSS 3D cube whose four side faces hold slides. Turn it with the buttons, the arrow keys\n * (while the stage is focused) or by dragging — the release velocity carries into a spring that\n * snaps to the nearest face. Only the front face is interactive; the others are inert and hidden\n * from assistive tech. With reduced motion the cube becomes a flat panel that crossfades.\n */\nexport function CubeCarousel({\n  items,\n  label = \"Featured\",\n  width = 300,\n  height: heightProp,\n  perspective = 1100,\n  tilt = -8,\n  spring = { stiffness: 170, damping: 22, mass: 1 },\n  autoRotate = true,\n  interval = 3600,\n  showControls = true,\n  accent = \"#FF7448\",\n  defaultIndex = 0,\n  onIndexChange,\n  className = \"\",\n  style,\n  ...props\n}: CubeCarouselProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const n = items.length;\n  const height = heightProp ?? Math.round(width * 0.7);\n  const half = width / 2;\n  const startPos = n ? mod(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 faceRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n  // Rotation in degrees. Position p (an unbounded integer) sits at -p × 90°.\n  const angle = useMotionValue(-startPos * 90);\n  const anim = React.useRef<AnimationPlaybackControls | null>(null);\n  const target = React.useRef<number | null>(null);\n  const scale = React.useRef(1);\n  const posRef = React.useRef(startPos);\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 [pos, setPos] = React.useState(startPos);\n  const [flatIndex, setFlatIndex] = React.useState(startPos);\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 flat = reduceMotion;\n  const front = n ? (flat ? mod(flatIndex, n) : mod(pos, n)) : 0;\n  const frontFace = mod(pos, 4);\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 settled position and face shading from the angle — DOM writes, no per-frame state.\n  React.useEffect(() => {\n    if (!n) return;\n    const update = (a: number) => {\n      faceRefs.current.forEach((el, f) => {\n        if (!el) return;\n        const rel = ((f * 90 + a) * Math.PI) / 180;\n        el.style.setProperty(\"--cc-dim\", Math.min(0.6, (1 - Math.cos(rel)) * 0.4).toFixed(3));\n      });\n      const p = Math.round(-a / 90);\n      if (p !== posRef.current) {\n        const prevIndex = mod(posRef.current, n);\n        posRef.current = p;\n        setPos(p);\n        if (mod(p, n) !== prevIndex) onIndexChangeRef.current?.(mod(p, n));\n      }\n    };\n    update(angle.get());\n    return angle.on(\"change\", update);\n  }, [angle, n]);\n\n  // Pause when offscreen; fit the cube 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 / (width * 1.5 + 24));\n      scale.current = s;\n      root.style.setProperty(\"--cc-scale\", s.toFixed(3));\n    });\n    if (stage) ro.observe(stage);\n    return () => {\n      io.disconnect();\n      ro.disconnect();\n    };\n  }, [width, flat]);\n\n  const turnTo = (to: number, velocity = 0) => {\n    anim.current?.stop();\n    target.current = to;\n    anim.current = animate(angle, to, { type: \"spring\", stiffness, damping, mass, velocity });\n  };\n\n  /** Turns by whole faces from the current target (so quick repeated presses accumulate). */\n  const turnBy = (delta: number) => {\n    if (n < 2) return;\n    const base = target.current ?? angle.get();\n    turnTo(Math.round(base / 90) * 90 - delta * 90);\n  };\n\n  const step = (delta: number) => {\n    if (flat) setFlatIndex((i) => mod(i + delta, n));\n    else turnBy(delta);\n  };\n\n  // Auto-rotation: one face 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 / 90) * 90 - 90;\n      target.current = to;\n      anim.current = animate(angle, to, { type: \"spring\", stiffness, damping, mass });\n    }, Math.max(1500, interval));\n    return () => clearInterval(t);\n  }, [running, interval, angle, stiffness, damping, mass]);\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 face content.\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 = 90 / (width * 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 by at most one face from where the drag started, then snap.\n    const projected = Math.max(-60, Math.min(60, velocity * 0.12));\n    const snapped = Math.round((a + projected) / 90) * 90;\n    const from = Math.round(d.start / 90) * 90;\n    turnTo(Math.max(from - 90, Math.min(from + 90, snapped)), velocity);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    if (e.target !== e.currentTarget) return;\n    if (e.key !== \"ArrowLeft\" && e.key !== \"ArrowRight\") return;\n    e.preventDefault();\n    step(e.key === \"ArrowRight\" ? 1 : -1);\n  };\n\n  if (!n) return null;\n  const current = items[front];\n\n  /** Item shown on physical face f: front, its neighbours (±1) and the hidden back (+2). */\n  const itemForFace = (f: number) => {\n    const offset = mod(f - pos, 4);\n    return mod(pos + (offset === 3 ? -1 : offset), n);\n  };\n\n  return (\n    <section\n      ref={rootRef}\n      aria-roledescription=\"carousel\"\n      aria-label={label}\n      className={`relative w-full ${className}`}\n      style={{ [\"--cc-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        <div\n          role=\"group\"\n          aria-label={`${label}: use the Left and Right arrow keys to change slides`}\n          tabIndex={0}\n          onKeyDown={onKeyDown}\n          className=\"mx-auto grid max-w-full overflow-hidden rounded-md border border-zinc-900/10 bg-white outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--cc-accent)] dark:border-white/10 dark:bg-zinc-900\"\n          style={{ width, height }}\n        >\n          {items.map((item, i) => (\n            <div\n              key={item.id}\n              role=\"group\"\n              aria-roledescription=\"slide\"\n              aria-label={`${i + 1} of ${n}: ${item.label}`}\n              aria-hidden={i !== front}\n              inert={i !== front}\n              className={`[grid-area:1/1] min-h-0 min-w-0 transition-opacity duration-300 ${i === front ? \"opacity-100\" : \"opacity-0\"}`}\n            >\n              {item.content}\n            </div>\n          ))}\n        </div>\n      ) : (\n        <div\n          ref={stageRef}\n          role=\"group\"\n          aria-label={`${label}: use the Left and Right arrow keys to turn the cube`}\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 face 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-hidden focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--cc-accent)] ${\n            n > 1 ? (dragging ? \"cursor-grabbing\" : \"cursor-grab\") : \"\"\n          }`}\n          style={{ height: `calc(${Math.round(height + width * 0.45)}px * var(--cc-scale, 1) + 1rem)` }}\n        >\n          {/* Floor shadow. */}\n          <div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute left-1/2 top-1/2 h-8 -translate-x-1/2 rounded-[50%] bg-black/[0.14] blur-2xl dark:bg-black/55\"\n            style={{ width: `calc(${Math.round(width * 1.3)}px * var(--cc-scale, 1))`, marginTop: `calc(${Math.round(height / 2 + 10)}px * var(--cc-scale, 1))` }}\n          />\n          <div className=\"relative [transform-style:preserve-3d]\" style={{ width, height, perspective, scale: \"var(--cc-scale, 1)\" }}>\n            <motion.div className=\"absolute inset-0 [transform-style:preserve-3d]\" style={{ z: -half, rotateX: tilt, rotateY: angle }}>\n              {FACES.map((f) => {\n                const isFront = f === frontFace;\n                const index = itemForFace(f);\n                const item = items[index];\n                return (\n                  <div\n                    key={f}\n                    ref={(el) => {\n                      faceRefs.current[f] = el;\n                    }}\n                    role=\"group\"\n                    aria-roledescription=\"slide\"\n                    aria-label={`${index + 1} of ${n}: ${item.label}`}\n                    aria-hidden={!isFront}\n                    inert={!isFront}\n                    className={`absolute inset-0 overflow-hidden rounded-[3px] border bg-white [backface-visibility:hidden] dark:bg-zinc-900 ${\n                      isFront ? \"border-[color-mix(in_srgb,var(--cc-accent)_70%,transparent)]\" : \"border-zinc-900/12 dark:border-white/12\"\n                    }`}\n                    style={{ transform: `rotateY(${f * 90}deg) translateZ(${half}px)` }}\n                  >\n                    <div key={item.id} className=\"h-full w-full\">\n                      {item.content}\n                    </div>\n                    {/* Face shading by angle to the viewer. */}\n                    <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 bg-zinc-950\" style={{ opacity: \"var(--cc-dim, 0)\" }} />\n                  </div>\n                );\n              })}\n              {/* Top and bottom caps, so a tilted cube reads as solid. */}\n              {[90, -90].map((rx) => (\n                <div\n                  key={rx}\n                  aria-hidden=\"true\"\n                  className=\"absolute left-0 rounded-[3px] border border-zinc-900/10 bg-zinc-100 [backface-visibility:hidden] dark:border-white/10 dark:bg-zinc-800\"\n                  style={{ top: (height - width) / 2, width, height: width, transform: `rotateX(${rx}deg) translateZ(${height / 2}px)` }}\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 slide\" onClick={() => step(-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          <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\")} · {current.label}\n          </span>\n          <button type=\"button\" aria-label=\"Next slide\" onClick={() => step(1)} className={iconButton}>\n            <Chevron dir=\"right\" />\n          </button>\n        </div>\n      )}\n\n      {/* Announces the front face; silent while rotating automatically. */}\n      <div className=\"sr-only\" aria-live={running ? \"off\" : \"polite\"} aria-atomic=\"true\">\n        {`Face ${front + 1} of ${n}: ${current.label}`}\n      </div>\n    </section>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "3d"
  ],
  "docs": "Documentation: https://wancraft.my/components/cube-carousel"
}
