{ "$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) {\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 = {\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 = {\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({\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) {\n const handleClick = React.useCallback(() => {\n onSelect(index);\n }, [onSelect, index]);\n\n return (\n \n \n {renderCard ? (\n renderCard(item, { active: isActive })\n ) : (\n \n )}\n \n \n );\n}) as (props: StackCardProps) => 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
\n {/* image */}\n
\n {item.imageSrc ? (\n \n ) : (\n
\n No image\n
\n )}\n
\n\n {/* subtle gradient overlay at bottom for text readability */}\n
\n\n {/* content */}\n
\n
\n {item.title}\n
\n {item.description ? (\n
\n {item.description}\n
\n ) : null}\n
\n
\n );\n});\n\nexport function CardStack({\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) {\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 setHovering(true)}\n onMouseLeave={() => setHovering(false)}\n >\n {/* Stage */}\n \n {/* background wash / spotlight (unique feel) */}\n \n \n\n \n \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 \n );\n })}\n \n
\n \n\n {/* Dots navigation centered at bottom */}\n {showDots ? (\n
\n
\n {items.map((it, idx) => {\n const on = idx === active;\n return (\n 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
\n {activeItem.href ? (\n \n \n \n ) : null}\n
\n ) : null}\n \n );\n}\n", "type": "registry:ui", "target": "components/ruixen/card-stack.tsx" } ] }