{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "card-stack",
  "type": "registry:ui",
  "title": "Card Stack",
  "description": "An interactive 3D card stack carousel with fan-out animation, drag gestures, and auto-advance support.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ruixenui/card-stack.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  motion,\n  AnimatePresence,\n  useReducedMotion,\n  type PanInfo,\n} from \"motion/react\";\nimport { SquareArrowOutUpRight } from \"lucide-react\";\nimport Link from \"next/link\";\n\nfunction cn(...classes: Array<string | undefined | null | false>) {\n  return classes.filter(Boolean).join(\" \");\n}\n\n// Memoized spring config to prevent object recreation\nconst createSpringTransition = (stiffness: number, damping: number) => ({\n  type: \"spring\" as const,\n  stiffness,\n  damping,\n});\n\n// Stable drag constraints - never changes\nconst DRAG_CONSTRAINTS = { left: 0, right: 0 } as const;\n\nexport type CardStackItem = {\n  id: string | number;\n  title: string;\n  description?: string;\n  imageSrc?: string;\n  href?: string;\n  ctaLabel?: string;\n  tag?: string;\n};\n\nexport type CardStackProps<T extends CardStackItem> = {\n  items: T[];\n\n  /** Selected index on mount */\n  initialIndex?: number;\n\n  /** How many cards are visible around the active (odd recommended) */\n  maxVisible?: number;\n\n  /** Card sizing */\n  cardWidth?: number;\n  cardHeight?: number;\n\n  /** How much cards overlap each other (0..0.8). Higher = more overlap */\n  overlap?: number;\n\n  /** Total fan angle (deg). Higher = wider arc */\n  spreadDeg?: number;\n\n  /** 3D / depth feel */\n  perspectivePx?: number;\n  depthPx?: number;\n  tiltXDeg?: number;\n\n  /** Active emphasis */\n  activeLiftPx?: number;\n  activeScale?: number;\n  inactiveScale?: number;\n\n  /** Motion */\n  springStiffness?: number;\n  springDamping?: number;\n\n  /** Behavior */\n  loop?: boolean;\n  autoAdvance?: boolean;\n  intervalMs?: number;\n  pauseOnHover?: boolean;\n\n  /** UI */\n  showDots?: boolean;\n  className?: string;\n\n  /** Hooks */\n  onChangeIndex?: (index: number, item: T) => void;\n\n  /** Custom renderer (optional) */\n  renderCard?: (item: T, state: { active: boolean }) => React.ReactNode;\n};\n\nfunction wrapIndex(n: number, len: number) {\n  if (len <= 0) return 0;\n  return ((n % len) + len) % len;\n}\n\n/** Minimal signed offset from active index to i, with wrapping (for loop behavior). */\nfunction signedOffset(i: number, active: number, len: number, loop: boolean) {\n  const raw = i - active;\n  if (!loop || len <= 1) return raw;\n\n  // consider wrapped alternative\n  const alt = raw > 0 ? raw - len : raw + len;\n  return Math.abs(alt) < Math.abs(raw) ? alt : raw;\n}\n\n/** Memoized individual card - prevents re-renders when sibling cards change */\ntype StackCardProps<T extends CardStackItem> = {\n  item: T;\n  index: number;\n  isActive: boolean;\n  cardWidth: number;\n  cardHeight: number;\n  x: number;\n  y: number;\n  z: number;\n  lift: number;\n  rotateX: number;\n  rotateZ: number;\n  scale: number;\n  zIndex: number;\n  reduceMotion: boolean | null;\n  springTransition: { type: \"spring\"; stiffness: number; damping: number };\n  handleDragEnd: (\n    e: MouseEvent | TouchEvent | PointerEvent,\n    info: PanInfo,\n  ) => void;\n  onSelect: (index: number) => void;\n  renderCard?: (item: T, state: { active: boolean }) => React.ReactNode;\n};\n\nconst StackCard = React.memo(function StackCard<T extends CardStackItem>({\n  item,\n  index,\n  isActive,\n  cardWidth,\n  cardHeight,\n  x,\n  y,\n  z,\n  lift,\n  rotateX,\n  rotateZ,\n  scale,\n  zIndex,\n  reduceMotion,\n  springTransition,\n  handleDragEnd,\n  onSelect,\n  renderCard,\n}: StackCardProps<T>) {\n  const handleClick = React.useCallback(() => {\n    onSelect(index);\n  }, [onSelect, index]);\n\n  return (\n    <motion.div\n      className={cn(\n        \"absolute bottom-0 rounded-2xl border-4 border-black/10 dark:border-white/10 overflow-hidden shadow-xl\",\n        \"will-change-transform select-none\",\n        isActive ? \"cursor-grab active:cursor-grabbing\" : \"cursor-pointer\",\n      )}\n      style={{\n        width: cardWidth,\n        height: cardHeight,\n        zIndex,\n        transformStyle: \"preserve-3d\",\n      }}\n      initial={\n        reduceMotion\n          ? false\n          : {\n              opacity: 0,\n              y: y + 40,\n              x,\n              rotateZ,\n              rotateX,\n              scale,\n            }\n      }\n      animate={{\n        opacity: 1,\n        x,\n        y: y + lift,\n        rotateZ,\n        rotateX,\n        scale,\n      }}\n      transition={springTransition}\n      onClick={handleClick}\n      drag={isActive ? \"x\" : false}\n      dragConstraints={isActive ? DRAG_CONSTRAINTS : undefined}\n      dragElastic={isActive ? 0.18 : undefined}\n      onDragEnd={isActive ? handleDragEnd : undefined}\n    >\n      <div\n        className=\"h-full w-full\"\n        style={{\n          transform: `translateZ(${z}px)`,\n          transformStyle: \"preserve-3d\",\n        }}\n      >\n        {renderCard ? (\n          renderCard(item, { active: isActive })\n        ) : (\n          <DefaultFanCard item={item} active={isActive} />\n        )}\n      </div>\n    </motion.div>\n  );\n}) as <T extends CardStackItem>(props: StackCardProps<T>) => React.ReactElement;\n\n/** Memoized default card content */\nconst DefaultFanCard = React.memo(function DefaultFanCard({\n  item,\n}: {\n  item: CardStackItem;\n  active: boolean;\n}) {\n  return (\n    <div className=\"relative h-full w-full\">\n      {/* image */}\n      <div className=\"absolute inset-0\">\n        {item.imageSrc ? (\n          <img\n            src={item.imageSrc}\n            alt={item.title}\n            className=\"h-full w-full object-cover\"\n            draggable={false}\n            loading=\"eager\"\n          />\n        ) : (\n          <div className=\"flex h-full w-full items-center justify-center bg-secondary text-sm text-muted-foreground\">\n            No image\n          </div>\n        )}\n      </div>\n\n      {/* subtle gradient overlay at bottom for text readability */}\n      <div className=\"pointer-events-none absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent\" />\n\n      {/* content */}\n      <div className=\"relative z-10 flex h-full flex-col justify-end p-5\">\n        <div className=\"truncate text-lg font-semibold text-white\">\n          {item.title}\n        </div>\n        {item.description ? (\n          <div className=\"mt-1 line-clamp-2 text-sm text-white/80\">\n            {item.description}\n          </div>\n        ) : null}\n      </div>\n    </div>\n  );\n});\n\nexport function CardStack<T extends CardStackItem>({\n  items,\n  initialIndex = 0,\n  maxVisible = 7,\n\n  cardWidth = 520,\n  cardHeight = 320,\n\n  overlap = 0.48,\n  spreadDeg = 48,\n\n  perspectivePx = 1100,\n  depthPx = 140,\n  tiltXDeg = 12,\n\n  activeLiftPx = 22,\n  activeScale = 1.03,\n  inactiveScale = 0.94,\n\n  springStiffness = 280,\n  springDamping = 28,\n\n  loop = true,\n  autoAdvance = false,\n  intervalMs = 2800,\n  pauseOnHover = true,\n\n  showDots = true,\n  className,\n\n  onChangeIndex,\n  renderCard,\n}: CardStackProps<T>) {\n  const reduceMotion = useReducedMotion();\n  const len = items.length;\n\n  const [active, setActive] = React.useState(() =>\n    wrapIndex(initialIndex, len),\n  );\n  const [hovering, setHovering] = React.useState(false);\n\n  // keep active in bounds if items change\n  React.useEffect(() => {\n    setActive((a) => wrapIndex(a, len));\n  }, [len]);\n\n  React.useEffect(() => {\n    if (!len) return;\n    onChangeIndex?.(active, items[active]!);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [active]);\n\n  // Memoize computed geometry values - these only change when props change\n  const { maxOffset, cardSpacing, stepDeg } = React.useMemo(\n    () => ({\n      maxOffset: Math.max(0, Math.floor(maxVisible / 2)),\n      cardSpacing: Math.max(10, Math.round(cardWidth * (1 - overlap))),\n      stepDeg:\n        Math.floor(maxVisible / 2) > 0\n          ? spreadDeg / Math.floor(maxVisible / 2)\n          : 0,\n    }),\n    [maxVisible, cardWidth, overlap, spreadDeg],\n  );\n\n  // Memoize spring transition to prevent object recreation on every render\n  const springTransition = React.useMemo(\n    () => createSpringTransition(springStiffness, springDamping),\n    [springStiffness, springDamping],\n  );\n\n  const canGoPrev = loop || active > 0;\n  const canGoNext = loop || active < len - 1;\n\n  const prev = React.useCallback(() => {\n    if (!len) return;\n    setActive((a) => (loop || a > 0 ? wrapIndex(a - 1, len) : a));\n  }, [loop, len]);\n\n  const next = React.useCallback(() => {\n    if (!len) return;\n    setActive((a) => (loop || a < len - 1 ? wrapIndex(a + 1, len) : a));\n  }, [loop, len]);\n\n  // Memoized keyboard handler\n  const onKeyDown = React.useCallback(\n    (e: React.KeyboardEvent) => {\n      if (e.key === \"ArrowLeft\") prev();\n      if (e.key === \"ArrowRight\") next();\n    },\n    [prev, next],\n  );\n\n  // Memoized drag end handler - stable reference prevents motion.div re-renders\n  const handleDragEnd = React.useCallback(\n    (_e: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {\n      if (reduceMotion) return;\n      const travel = info.offset.x;\n      const v = info.velocity.x;\n      const threshold = Math.min(160, cardWidth * 0.22);\n\n      if (travel > threshold || v > 650) prev();\n      else if (travel < -threshold || v < -650) next();\n    },\n    [reduceMotion, cardWidth, prev, next],\n  );\n\n  // autoplay - removed `active` from deps to prevent effect restart on every card change\n  React.useEffect(() => {\n    if (!autoAdvance) return;\n    if (reduceMotion) return;\n    if (!len) return;\n    if (pauseOnHover && hovering) return;\n\n    const id = window.setInterval(\n      () => {\n        setActive((a) => (loop || a < len - 1 ? wrapIndex(a + 1, len) : a));\n      },\n      Math.max(700, intervalMs),\n    );\n\n    return () => window.clearInterval(id);\n  }, [\n    autoAdvance,\n    intervalMs,\n    hovering,\n    pauseOnHover,\n    reduceMotion,\n    len,\n    loop,\n  ]);\n\n  if (!len) return null;\n\n  const activeItem = items[active]!;\n\n  return (\n    <div\n      className={cn(\"w-full\", className)}\n      onMouseEnter={() => setHovering(true)}\n      onMouseLeave={() => setHovering(false)}\n    >\n      {/* Stage */}\n      <div\n        className=\"relative w-full\"\n        style={{ height: Math.max(380, cardHeight + 80) }}\n        tabIndex={0}\n        onKeyDown={onKeyDown}\n      >\n        {/* background wash / spotlight (unique feel) */}\n        <div\n          className=\"pointer-events-none absolute inset-x-0 top-6 mx-auto h-48 w-[70%] rounded-full bg-black/5 blur-3xl dark:bg-white/5\"\n          aria-hidden=\"true\"\n        />\n        <div\n          className=\"pointer-events-none absolute inset-x-0 bottom-0 mx-auto h-40 w-[76%] rounded-full bg-black/10 blur-3xl dark:bg-black/30\"\n          aria-hidden=\"true\"\n        />\n\n        <div\n          className=\"absolute inset-0 flex items-end justify-center\"\n          style={{\n            perspective: `${perspectivePx}px`,\n          }}\n        >\n          <AnimatePresence initial={false}>\n            {items.map((item, i) => {\n              const off = signedOffset(i, active, len, loop);\n              const abs = Math.abs(off);\n              const visible = abs <= maxOffset;\n\n              // hide far-away cards cleanly\n              if (!visible) return null;\n\n              // fan geometry\n              const rotateZ = off * stepDeg;\n              const x = off * cardSpacing;\n              const y = abs * 10; // subtle arc-down feel\n              const z = -abs * depthPx;\n\n              const isActive = off === 0;\n\n              const scale = isActive ? activeScale : inactiveScale;\n              const lift = isActive ? -activeLiftPx : 0;\n\n              const rotateX = isActive ? 0 : tiltXDeg;\n\n              const zIndex = 100 - abs;\n\n              return (\n                <StackCard\n                  key={item.id}\n                  item={item}\n                  index={i}\n                  isActive={isActive}\n                  cardWidth={cardWidth}\n                  cardHeight={cardHeight}\n                  x={x}\n                  y={y}\n                  z={z}\n                  lift={lift}\n                  rotateX={rotateX}\n                  rotateZ={rotateZ}\n                  scale={scale}\n                  zIndex={zIndex}\n                  reduceMotion={reduceMotion}\n                  springTransition={springTransition}\n                  handleDragEnd={handleDragEnd}\n                  onSelect={setActive}\n                  renderCard={renderCard}\n                />\n              );\n            })}\n          </AnimatePresence>\n        </div>\n      </div>\n\n      {/* Dots navigation centered at bottom */}\n      {showDots ? (\n        <div className=\"mt-6 flex items-center justify-center gap-3\">\n          <div className=\"flex items-center gap-2\">\n            {items.map((it, idx) => {\n              const on = idx === active;\n              return (\n                <button\n                  key={it.id}\n                  onClick={() => setActive(idx)}\n                  className={cn(\n                    \"h-2 w-2 rounded-full transition\",\n                    on\n                      ? \"bg-foreground\"\n                      : \"bg-foreground/30 hover:bg-foreground/50\",\n                  )}\n                  aria-label={`Go to ${it.title}`}\n                />\n              );\n            })}\n          </div>\n          {activeItem.href ? (\n            <Link\n              href={activeItem.href}\n              target=\"_blank\"\n              rel=\"noreferrer\"\n              className=\"text-muted-foreground hover:text-foreground transition\"\n              aria-label=\"Open link\"\n            >\n              <SquareArrowOutUpRight className=\"h-4 w-4\" />\n            </Link>\n          ) : null}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ruixen/card-stack.tsx"
    }
  ]
}