{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "card-stack",
  "type": "registry:ui",
  "title": "3D Card Stack",
  "description": "A perspective card stack you can swipe, drag, click or arrow through — with spring physics.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/card-stack.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion, useMotionValue, useReducedMotion, useTransform, type PanInfo, type Transition } from \"motion/react\";\n\nexport interface CardStackItem {\n  id: string | number;\n  content: React.ReactNode;\n  /** Short name announced with the position, e.g. \"Card 2 of 5: Pricing\". */\n  label?: string;\n}\n\ntype SpringConfig = { stiffness: number; damping: number; mass: number };\n\nexport interface CardStackProps extends Omit<React.ComponentPropsWithoutRef<\"section\">, \"children\"> {\n  /** Cards to show. Alternatively pass each card as a child. */\n  items?: CardStackItem[];\n  children?: React.ReactNode;\n  /** Vertical offset between stacked cards in px. */\n  spacing?: number;\n  /** Rotation in degrees added per card of depth (alternating direction). */\n  rotation?: number;\n  /** Scale reduction per card of depth. */\n  scaleStep?: number;\n  /** How many cards peek out behind the front card. */\n  visible?: number;\n  /** Wrap around at the ends. */\n  loop?: boolean;\n  spring?: SpringConfig;\n  /** Drag distance in px needed to change card (fast flicks also count). */\n  threshold?: number;\n  /** Show previous/next buttons and the position counter. */\n  showControls?: boolean;\n  accent?: string;\n  /** Accessible name of the carousel. */\n  label?: string;\n  defaultIndex?: number;\n  onIndexChange?: (index: number) => void;\n  /** Extra classes for every card surface. */\n  cardClassName?: string;\n}\n\ninterface CardProps {\n  depth: number;\n  total: number;\n  isFront: boolean;\n  spacing: number;\n  rotation: number;\n  scaleStep: number;\n  visible: number;\n  transition: Transition;\n  reduceMotion: boolean;\n  draggable: boolean;\n  onDragEnd: (info: PanInfo) => void;\n  className: string;\n  ariaLabel: string;\n  children: React.ReactNode;\n}\n\nfunction StackCard({\n  depth,\n  total,\n  isFront,\n  spacing,\n  rotation,\n  scaleStep,\n  visible,\n  transition,\n  reduceMotion,\n  draggable,\n  onDragEnd,\n  className,\n  ariaLabel,\n  children,\n}: CardProps) {\n  const x = useMotionValue(0);\n  // Tilt follows the drag like a card held at its base.\n  const tilt = useTransform(x, [-260, 0, 260], reduceMotion ? [0, 0, 0] : [-12, 0, 12]);\n\n  let target;\n  if (depth < 0) {\n    // Already passed (non-looping stacks): slip away to the left.\n    target = { x: -90, y: 0, scale: 0.94, rotateZ: -8, opacity: 0 };\n  } else {\n    const d = Math.min(depth, visible);\n    target = {\n      x: 0,\n      y: d * spacing,\n      scale: 1 - d * scaleStep,\n      rotateZ: depth === 0 ? 0 : (depth % 2 ? 1 : -1) * rotation * Math.min(depth, visible),\n      opacity: depth > visible ? 0 : 1 - d * 0.08,\n    };\n  }\n\n  return (\n    <motion.div\n      role=\"group\"\n      aria-roledescription=\"card\"\n      aria-label={ariaLabel}\n      aria-hidden={!isFront}\n      inert={!isFront}\n      className={`absolute inset-0 overflow-hidden rounded-2xl border border-zinc-900/10 bg-white shadow-[0_1px_0_rgba(255,255,255,0.6)_inset,0_24px_48px_-28px_rgba(0,0,0,0.45)] dark:border-white/10 dark:bg-zinc-900 dark:shadow-[0_1px_0_rgba(255,255,255,0.06)_inset,0_24px_48px_-24px_rgba(0,0,0,0.8)] ${\n        draggable ? \"cursor-grab touch-pan-y active:cursor-grabbing\" : \"\"\n      } ${isFront ? \"\" : \"pointer-events-none\"} ${className}`}\n      style={{ x, rotate: tilt, zIndex: total - Math.abs(depth), transformOrigin: \"50% 85%\" }}\n      initial={false}\n      animate={target}\n      transition={transition}\n      drag={draggable ? \"x\" : false}\n      dragSnapToOrigin\n      dragElastic={0.65}\n      onDragEnd={(_, info) => onDragEnd(info)}\n    >\n      {children}\n    </motion.div>\n  );\n}\n\n/**\n * A stack of cards in perspective. Swipe or drag the front card, use the arrow keys on the\n * focused stack, or the buttons; the current position is announced politely.\n */\nexport function CardStack({\n  items,\n  children,\n  spacing = 14,\n  rotation = 3,\n  scaleStep = 0.05,\n  visible = 3,\n  loop = true,\n  spring = { stiffness: 320, damping: 30, mass: 0.9 },\n  threshold = 90,\n  showControls = true,\n  accent = \"#FF7448\",\n  label = \"Cards\",\n  defaultIndex = 0,\n  onIndexChange,\n  cardClassName = \"\",\n  className = \"\",\n  style,\n  ...props\n}: CardStackProps) {\n  const reduceMotion = useReducedMotion() ?? false;\n  const hintId = React.useId();\n  const cards: CardStackItem[] =\n    items ??\n    React.Children.toArray(children).map((child, i) => ({\n      id: React.isValidElement(child) && child.key !== null ? child.key : i,\n      content: child,\n    }));\n  const total = cards.length;\n  const [index, setIndex] = React.useState(() => Math.max(0, Math.min(defaultIndex, Math.max(0, total - 1))));\n  const [announce, setAnnounce] = React.useState(false);\n  const current = total ? Math.min(index, total - 1) : 0;\n\n  const go = (next: number) => {\n    if (!total) return;\n    const target = loop ? ((next % total) + total) % total : Math.max(0, Math.min(total - 1, next));\n    if (target === current) return;\n    setIndex(target);\n    setAnnounce(true);\n    onIndexChange?.(target);\n  };\n\n  const canPrev = loop ? total > 1 : current > 0;\n  const canNext = loop ? total > 1 : current < total - 1;\n\n  const handleDragEnd = (info: PanInfo) => {\n    const swipe = Math.abs(info.offset.x) > threshold || Math.abs(info.velocity.x) > 650;\n    if (!swipe) return;\n    // Throwing the card left reveals the next one; throwing it right brings the previous back.\n    if (info.offset.x < 0 && canNext) go(current + 1);\n    else if (info.offset.x > 0 && canPrev) go(current - 1);\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    if (event.target !== event.currentTarget) return;\n    const keys: Record<string, () => void> = {\n      ArrowLeft: () => go(current - 1),\n      ArrowRight: () => go(current + 1),\n      Home: () => go(0),\n      End: () => go(total - 1),\n    };\n    const action = keys[event.key];\n    if (!action) return;\n    event.preventDefault();\n    action();\n  };\n\n  const transition: Transition = reduceMotion ? { duration: 0 } : { type: \"spring\", ...spring };\n  const position = (i: number) => `Card ${i + 1} of ${total}${cards[i]?.label ? `: ${cards[i].label}` : \"\"}`;\n  const pad = (n: number) => String(n).padStart(2, \"0\");\n\n  return (\n    <section\n      aria-roledescription=\"carousel\"\n      aria-label={label}\n      className={`w-full ${className}`}\n      style={{ [\"--cs-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <div\n        tabIndex={0}\n        aria-describedby={hintId}\n        onKeyDown={handleKeyDown}\n        className=\"relative aspect-[4/5] w-full rounded-2xl outline-none [perspective:1200px] focus-visible:ring-2 focus-visible:ring-[var(--cs-accent)] focus-visible:ring-offset-4 focus-visible:ring-offset-transparent\"\n        style={{ marginBottom: Math.min(visible, Math.max(0, total - 1)) * spacing + 8 }}\n      >\n        {cards.map((card, i) => {\n          const depth = loop ? (((i - current) % total) + total) % total : i - current;\n          const isFront = depth === 0;\n          return (\n            <StackCard\n              key={card.id}\n              depth={depth}\n              total={total}\n              isFront={isFront}\n              spacing={spacing}\n              rotation={rotation}\n              scaleStep={scaleStep}\n              visible={visible}\n              transition={transition}\n              reduceMotion={reduceMotion}\n              draggable={isFront && total > 1}\n              onDragEnd={handleDragEnd}\n              className={cardClassName}\n              ariaLabel={position(i)}\n            >\n              {card.content}\n            </StackCard>\n          );\n        })}\n      </div>\n\n      <p id={hintId} className=\"sr-only\">\n        Use the left and right arrow keys, or swipe the front card, to move through the stack.\n      </p>\n      <p className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n        {announce && total ? position(current) : \"\"}\n      </p>\n\n      {showControls && total > 1 ? (\n        <div className=\"mt-4 flex items-center justify-between gap-3\">\n          <button\n            type=\"button\"\n            aria-label=\"Previous card\"\n            disabled={!canPrev}\n            onClick={() => go(current - 1)}\n            className=\"inline-flex size-9 items-center justify-center rounded-full border border-zinc-900/15 text-zinc-700 transition-colors hover:border-zinc-900/40 hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--cs-accent)] disabled:cursor-not-allowed disabled:opacity-35 dark:border-white/15 dark:text-zinc-300 dark:hover:border-white/40 dark:hover:text-white\"\n          >\n            <svg viewBox=\"0 0 12 12\" className=\"size-3\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" aria-hidden=\"true\">\n              <path d=\"M7.5 2.5 4 6l3.5 3.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n            </svg>\n          </button>\n          <div className=\"flex items-center gap-3\" aria-hidden=\"true\">\n            <span className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 tabular-nums dark:text-zinc-400\">\n              <span className=\"text-zinc-900 dark:text-zinc-100\">{pad(current + 1)}</span> / {pad(total)}\n            </span>\n            <span className=\"flex items-center gap-1\">\n              {cards.map((card, i) => (\n                <span\n                  key={card.id}\n                  className={`h-1 rounded-full transition-all duration-300 ${\n                    i === current ? \"w-4 bg-[var(--cs-accent)]\" : \"w-1 bg-zinc-900/20 dark:bg-white/20\"\n                  }`}\n                />\n              ))}\n            </span>\n          </div>\n          <button\n            type=\"button\"\n            aria-label=\"Next card\"\n            disabled={!canNext}\n            onClick={() => go(current + 1)}\n            className=\"inline-flex size-9 items-center justify-center rounded-full border border-zinc-900/15 text-zinc-700 transition-colors hover:border-zinc-900/40 hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--cs-accent)] disabled:cursor-not-allowed disabled:opacity-35 dark:border-white/15 dark:text-zinc-300 dark:hover:border-white/40 dark:hover:text-white\"\n          >\n            <svg viewBox=\"0 0 12 12\" className=\"size-3\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" aria-hidden=\"true\">\n              <path d=\"M4.5 2.5 8 6 4.5 9.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n            </svg>\n          </button>\n        </div>\n      ) : null}\n    </section>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "3d"
  ],
  "docs": "Documentation: https://wancraft.my/components/card-stack"
}
