{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "image-card-fan", "type": "registry:ui", "title": "Image Card Fan", "description": "A fanned hand of large image cards. Click one and it lifts out of the hand while its title and description settle in on either side — one card out at a time.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/image-card-fan.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface FanCardItem {\n /** Stable, unique identifier. */\n id: string;\n /** Image URL rendered as the card face. */\n src: string;\n /** Shown beside the fan while this card is active, and its accessible name. */\n title: string;\n /** Shown on the opposite side while this card is active. */\n description?: string;\n}\n\nexport interface ImageCardFanProps {\n /** Cards in the hand, in fan order. */\n cards: readonly FanCardItem[];\n /** Active card id. Pass to control the selection yourself. */\n activeId?: string;\n /** Card width in pixels. Height follows a 5:7 ratio. Shrinks to fit narrow containers. */\n cardWidth?: number;\n className?: string;\n /** Initially active card. Defaults to the first card. */\n defaultActiveId?: string;\n /** Fires when a different card is picked. */\n onSelect?: (card: FanCardItem) => void;\n}\n\n/** Flick speed, in px/s, that selects a card regardless of how far it travelled. */\nconst SELECT_VELOCITY = -600;\n\n/** Cards waiting in the hand sit behind the active one, so they ride smaller. */\nconst HAND_SCALE = 0.72;\n\n/** Spread each card into a symmetric arc around the middle of the hand. */\nfunction fanTransform(index: number, count: number, spacing: number) {\n const offset = index - (count - 1) / 2;\n // The arc keeps a constant total sweep, so a bigger hand fans tighter.\n const stepDeg = count > 1 ? Math.min(9, 46 / (count - 1)) : 0;\n const rotate = offset * stepDeg;\n\n return {\n rotate,\n x: offset * spacing,\n y: Math.abs(rotate) * 1.9,\n };\n}\n\nexport function ImageCardFan({\n cards,\n activeId: controlledActiveId,\n cardWidth = 224,\n className,\n defaultActiveId,\n onSelect,\n}: ImageCardFanProps) {\n const shouldReduceMotion = useReducedMotion();\n const fanRef = React.useRef(null);\n // Set while a pointer drag is in flight so the trailing click is ignored.\n const draggingRef = React.useRef(false);\n const [fanWidth, setFanWidth] = React.useState(null);\n const [internalActiveId, setInternalActiveId] = React.useState(\n defaultActiveId ?? cards[0]?.id,\n );\n const [hoveredId, setHoveredId] = React.useState(null);\n // Dragging a card claims the vertical touch axis, which on a phone means\n // swiping over the fan scrolls nothing. Pointer devices only.\n const [canDrag, setCanDrag] = React.useState(false);\n\n // Fall back to the first card whenever the requested id is not in the hand:\n // a swapped `cards` array, a stale controlled id, a bad `defaultActiveId`.\n // Without this the fan has no card lifted and no side text — an empty state\n // that is otherwise unreachable.\n const requestedId = controlledActiveId ?? internalActiveId;\n const activeId = cards.some((card) => card.id === requestedId)\n ? requestedId\n : cards[0]?.id;\n const activeIndex = cards.findIndex((card) => card.id === activeId);\n const activeCard = cards[activeIndex];\n\n React.useEffect(() => {\n setCanDrag(!window.matchMedia(\"(pointer: coarse)\").matches);\n }, []);\n\n React.useEffect(() => {\n const node = fanRef.current;\n\n if (!node) {\n return;\n }\n\n const observer = new ResizeObserver(([entry]) => {\n setFanWidth(entry.contentRect.width);\n });\n\n observer.observe(node);\n\n return () => observer.disconnect();\n }, []);\n\n // Cards shrink rather than overflow once the column gets narrow.\n const width =\n fanWidth === null ? cardWidth : Math.min(cardWidth, fanWidth * 0.55);\n\n // Every travel distance scales off the card, so the fan holds together at\n // any width instead of only at the default.\n const height = width * 1.4;\n // Enough clearance under the hand for the outer cards to droop along the arc\n // without hanging out of the box — previews clip anything that does.\n const baseBottom = height * 0.09;\n const hoverLift = height * 0.34;\n const activeLift = height * 0.62;\n const selectDistance = -height * 0.5;\n\n const handCards = cards.filter((card) => card.id !== activeId);\n const hoveredIndex = hoveredId\n ? handCards.findIndex((card) => card.id === hoveredId)\n : -1;\n\n // Clamp the horizontal spread so the outermost cards (including their hover\n // scale) never clip against the column edges.\n const maxOffset = (handCards.length - 1) / 2;\n // 0.45 covers half a hand-sized card plus the extra width a 9° tilt adds.\n const halfAvailable =\n fanWidth === null ? Number.POSITIVE_INFINITY : fanWidth / 2 - width * 0.45;\n const fanSpacing =\n maxOffset > 0\n ? Math.min(\n width * (handCards.length > 5 ? 0.43 : 0.52),\n Math.max(width * 0.23, halfAvailable / maxOffset),\n )\n : 0;\n\n const cardTransition = shouldReduceMotion\n ? { duration: 0.16, ease: \"easeOut\" as const }\n : {\n damping: 30,\n mass: 0.9,\n stiffness: 340,\n type: \"spring\" as const,\n };\n\n const textTransition = { duration: shouldReduceMotion ? 0 : 0.32 };\n const textOffset = shouldReduceMotion ? 0 : 10;\n\n function clearHover(id: string) {\n setHoveredId((current) => (current === id ? null : current));\n }\n\n function selectCard(card: FanCardItem) {\n if (card.id === activeId) {\n return;\n }\n\n clearHover(card.id);\n\n if (controlledActiveId === undefined) {\n setInternalActiveId(card.id);\n }\n\n onSelect?.(card);\n }\n\n return (\n \n
\n {activeCard ? (\n \n

\n {String(activeIndex + 1).padStart(2, \"0\")} /{\" \"}\n {String(cards.length).padStart(2, \"0\")}\n

\n

\n {activeCard.title}\n

\n \n ) : null}\n
\n\n \n {cards.map((card) => {\n const isActive = card.id === activeId;\n const handIndex = handCards.findIndex(\n (candidate) => candidate.id === card.id,\n );\n\n let target: { rotate: number; scale: number; x: number; y: number };\n let zIndex: number;\n\n if (isActive) {\n target = { rotate: 0, scale: 1, x: 0, y: -activeLift };\n zIndex = 100;\n } else {\n const fan = fanTransform(handIndex, handCards.length, fanSpacing);\n const isHovered = card.id === hoveredId;\n // Neighbours step aside, and the push falls off with distance.\n const neighborShift =\n hoveredIndex !== -1 && !isHovered\n ? (Math.sign(handIndex - hoveredIndex) * width * 0.14) /\n Math.max(1, Math.abs(handIndex - hoveredIndex))\n : 0;\n\n target = {\n rotate: isHovered ? fan.rotate * 0.55 : fan.rotate,\n scale: isHovered ? HAND_SCALE * 1.08 : HAND_SCALE,\n x: fan.x + neighborShift,\n // Lift to a constant height so hovered cards fully clear the\n // bottom edge no matter how far they droop along the arc.\n y: isHovered ? -hoverLift : fan.y,\n };\n zIndex = isHovered ? 60 : 10 + handIndex;\n }\n\n return (\n \n clearHover(card.id)}\n // Enter and Space reach this natively; a drag never does,\n // because the pointer sequence sets the flag first.\n onClick={() => {\n if (draggingRef.current) {\n draggingRef.current = false;\n return;\n }\n\n selectCard(card);\n }}\n onDragEnd={(_event, info) => {\n if (\n info.offset.y < selectDistance ||\n info.velocity.y < SELECT_VELOCITY\n ) {\n selectCard(card);\n }\n }}\n onDragStart={() => {\n draggingRef.current = true;\n }}\n onFocus={() => {\n if (!isActive) {\n setHoveredId(card.id);\n }\n }}\n onHoverEnd={() => clearHover(card.id)}\n onHoverStart={() => {\n if (!isActive) {\n setHoveredId(card.id);\n }\n }}\n onPointerDown={() => {\n draggingRef.current = false;\n }}\n type=\"button\"\n >\n {/* Decorative: the button label and the side text carry the name. */}\n \n \n \n );\n })}\n \n\n \n {activeCard?.description ? (\n \n {activeCard.description}\n \n ) : null}\n \n \n );\n}\n", "type": "registry:ui", "target": "components/ruixen/image-card-fan.tsx" } ] }