{
  "$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<HTMLDivElement>(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<number | null>(null);\n  const [internalActiveId, setInternalActiveId] = React.useState(\n    defaultActiveId ?? cards[0]?.id,\n  );\n  const [hoveredId, setHoveredId] = React.useState<string | null>(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    <div\n      className={cn(\n        \"flex w-full flex-col items-center gap-6 sm:flex-row sm:items-center sm:gap-6 lg:gap-10\",\n        className,\n      )}\n    >\n      <div className=\"w-full shrink-0 text-center sm:w-40 sm:text-right md:w-48 lg:w-56\">\n        {activeCard ? (\n          <motion.div\n            animate={{ opacity: 1, y: 0 }}\n            initial={{ opacity: 0, y: textOffset }}\n            key={activeCard.id}\n            transition={textTransition}\n          >\n            <p className=\"font-mono text-muted-foreground text-xs tabular-nums\">\n              {String(activeIndex + 1).padStart(2, \"0\")} /{\" \"}\n              {String(cards.length).padStart(2, \"0\")}\n            </p>\n            <h3 className=\"mt-2 text-balance font-semibold text-2xl text-foreground leading-tight tracking-tight lg:text-3xl\">\n              {activeCard.title}\n            </h3>\n          </motion.div>\n        ) : null}\n      </div>\n\n      <div\n        className=\"relative w-full min-w-0 flex-1\"\n        ref={fanRef}\n        style={{ minHeight: height * 1.78 }}\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            <motion.div\n              animate={target}\n              className=\"absolute left-1/2\"\n              initial={false}\n              key={card.id}\n              style={{ bottom: baseBottom, marginLeft: -width / 2, zIndex }}\n              transition={cardTransition}\n            >\n              <motion.button\n                aria-label={card.title}\n                aria-pressed={isActive}\n                className={cn(\n                  \"block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n                  isActive\n                    ? \"cursor-default\"\n                    : \"cursor-grab active:cursor-grabbing\",\n                )}\n                drag={canDrag && !isActive ? \"y\" : false}\n                dragConstraints={{ bottom: 0, top: -height * 1.65 }}\n                dragElastic={0.12}\n                dragSnapToOrigin\n                onBlur={() => 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                <img\n                  alt=\"\"\n                  className=\"aspect-[5/7] select-none rounded-xl border border-border bg-muted object-cover shadow-lg\"\n                  draggable={false}\n                  src={card.src}\n                  style={{ width }}\n                />\n              </motion.button>\n            </motion.div>\n          );\n        })}\n      </div>\n\n      <div\n        aria-live=\"polite\"\n        className=\"w-full shrink-0 text-center sm:w-40 sm:text-left md:w-48 lg:w-56\"\n      >\n        {activeCard?.description ? (\n          <motion.p\n            animate={{ opacity: 1, y: 0 }}\n            className=\"text-pretty text-muted-foreground text-sm leading-relaxed\"\n            initial={{ opacity: 0, y: textOffset }}\n            key={activeCard.id}\n            transition={textTransition}\n          >\n            {activeCard.description}\n          </motion.p>\n        ) : null}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ruixen/image-card-fan.tsx"
    }
  ]
}