{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"card-dealer","type":"registry:block","title":"Card Dealer","description":"A card table with a face-down deck, a fanned hand and a discard pile. Cards arc across the table between them and flip in flight, hop over each other when the hand is sorted, and lift on hover. Data-driven by each card's zone, with custom faces and backs, keyboard-playable hand cards and reduced-motion support.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/card-dealer.tsx","type":"registry:component","target":"@components/motion/card-dealer.tsx","content":"\"use client\";\n// beui.dev/components/blocks/card-dealer\n\nimport {\n  type AnimationPlaybackControls,\n  arc,\n  motion,\n  type Transition,\n  useAnimate,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  type ReactNode,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\nexport type CardDealerZone = \"deck\" | \"hand\" | \"pile\";\n\nexport interface CardDealerCard {\n  id: string;\n  zone: CardDealerZone;\n  /** Accessible name for a card in the hand. */\n  label: string;\n}\n\nexport interface CardDealerProps<T extends CardDealerCard> {\n  /** Every card on the table. Order within each zone is the stacking and fan order. */\n  cards: T[];\n  /** The card's face. */\n  renderFace: (card: T) => ReactNode;\n  /** The card's back, shown while it sits in the deck. */\n  renderBack?: (card: T) => ReactNode;\n  /** Makes hand cards buttons. Called with the card's id. */\n  onCardSelect?: (id: string) => void;\n  /** Card size in px. Default 84 by 120. */\n  cardWidth?: number;\n  cardHeight?: number;\n  /** Most horizontal space between neighbouring hand cards, in px. Default 46. */\n  spread?: number;\n  /** Rotation between neighbouring hand cards, in degrees. Default 5. */\n  angle?: number;\n  /** Accessible name for the table. Default \"Card table\". */\n  \"aria-label\"?: string;\n  className?: string;\n}\n\n// A card crossing the table arcs high and lands with a small settle; a card\n// changing slots inside the hand only hops. Both are tuned here rather than\n// borrowed from lib/ease because the arc length is the whole effect.\nconst FLIGHT: Transition = { type: \"spring\", visualDuration: 0.55, bounce: 0.18 };\nconst HOP: Transition = { type: \"spring\", visualDuration: 0.4, bounce: 0.2 };\n// arc() bends relative to the distance travelled. A negative strength puts\n// horizontal moves above the line in both directions (automatic direction\n// would bend them below).\nconst FLIGHT_STRENGTH = -0.35;\nconst HOP_STRENGTH = -0.3;\n// Cards in a multi-card move leave one after another, not as a single slab.\n// A big move shares a fixed window, so dealing the whole deck still flows.\nconst STAGGER = 0.045;\nconst MAX_STAGGER_WINDOW = 0.6;\nconst EDGE = 16;\nconst HOVER_LIFT = 18;\nconst HOVER_PUSH = 14;\n// The fan's outermost tilt and drop, whatever the hand size. Small hands sit\n// well inside both; a whole deck spread into the hand becomes one shallow\n// ribbon instead of cards tipping past vertical.\nconst MAX_FAN_ANGLE = 40;\nconst MAX_FAN_DROP = 26;\nconst FAN_DROP = 2.2;\n\ntype Pose = { x: number; y: number; rotate: number; faceDown: boolean; z: number };\n\n/** Stable per-card tilt for the pile, so a card keeps its angle once it lands. */\nfunction pileTilt(id: string) {\n  let hash = 0;\n  for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) | 0;\n  return ((Math.abs(hash) % 17) - 8) * 1.1;\n}\n\n/**\n * A card table: a face-down deck, a fanned hand and a discard pile. Every card\n * stays mounted and moving one between zones is just a change of `zone`, so it\n * arcs across the table to its new place, flipping face up or down in flight.\n * Reordering the hand makes cards hop over each other into their new slots.\n */\nexport function CardDealer<T extends CardDealerCard>({\n  cards,\n  renderFace,\n  renderBack,\n  onCardSelect,\n  cardWidth = 84,\n  cardHeight = 120,\n  spread = 46,\n  angle = 5,\n  \"aria-label\": ariaLabel = \"Card table\",\n  className,\n}: CardDealerProps<T>) {\n  const reduce = useReducedMotion();\n  const canHover = useHoverCapable();\n  const stageRef = useRef<HTMLElement>(null);\n  const [size, setSize] = useState<{ width: number; height: number } | null>(null);\n  const [hovered, setHovered] = useState<string | null>(null);\n  // Where each card was last rendered, read during render to tell a flight\n  // from a hop from a card that stayed put. Written after every commit.\n  const previous = useRef(new Map<string, { zone: CardDealerZone; slot: number }>());\n  const resized = useRef(false);\n\n  useLayoutEffect(() => {\n    const stage = stageRef.current;\n    if (!stage) return;\n    const measure = () => {\n      resized.current = true;\n      setSize({ width: stage.clientWidth, height: stage.clientHeight });\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(stage);\n    return () => observer.disconnect();\n  }, []);\n\n  const deck = cards.filter((card) => card.zone === \"deck\");\n  const hand = cards.filter((card) => card.zone === \"hand\");\n  const pile = cards.filter((card) => card.zone === \"pile\");\n\n  useLayoutEffect(() => {\n    const next = new Map<string, { zone: CardDealerZone; slot: number }>();\n    for (const list of [deck, hand, pile])\n      list.forEach((card, slot) => {\n        next.set(card.id, { zone: card.zone, slot });\n      });\n    previous.current = next;\n    resized.current = false;\n  });\n\n  const poseFor = (card: T, slot: number): Pose => {\n    const width = size?.width ?? 0;\n    const height = size?.height ?? 0;\n    if (card.zone === \"deck\") {\n      // A thin visible edge for the first few cards, then the stack stops growing.\n      const depth = Math.min(slot, 6) * 0.6;\n      return { x: EDGE - depth, y: EDGE - depth, rotate: 0, faceDown: true, z: slot };\n    }\n    if (card.zone === \"pile\") {\n      return {\n        x: width - cardWidth - EDGE,\n        y: EDGE,\n        rotate: pileTilt(card.id),\n        faceDown: false,\n        z: 400 + slot,\n      };\n    }\n    const count = hand.length;\n    const offset = slot - (count - 1) / 2;\n    const edge = Math.max((count - 1) / 2, 1);\n    const reach = offset / edge;\n    const tilt = Math.min(angle * edge, MAX_FAN_ANGLE);\n    const drop = Math.min(FAN_DROP * edge * edge, MAX_FAN_DROP);\n    const room = width - cardWidth - EDGE * 2;\n    const step = count > 1 ? Math.min(spread, room / (count - 1)) : 0;\n    const hoveredSlot = hovered ? hand.findIndex((item) => item.id === hovered) : -1;\n    const push =\n      hoveredSlot < 0 || slot === hoveredSlot\n        ? 0\n        : Math.sign(slot - hoveredSlot) * HOVER_PUSH;\n    return {\n      x: width / 2 - cardWidth / 2 + offset * step + push,\n      // Outer cards sit lower so the fan reads as held, not laid flat.\n      y:\n        height -\n        cardHeight -\n        EDGE +\n        reach * reach * drop -\n        (slot === hoveredSlot ? HOVER_LIFT : 0),\n      rotate: reach * tilt,\n      faceDown: false,\n      z: slot === hoveredSlot ? 300 : 100 + slot,\n    };\n  };\n\n  // Moving cards leave in stacking order, so a deal or a collect fans out.\n  let moving = 0;\n  const movers = cards.filter((card) => {\n    const was = previous.current.get(card.id);\n    return was !== undefined && was.zone !== card.zone;\n  }).length;\n  const stagger = movers > 1 ? Math.min(STAGGER, MAX_STAGGER_WINDOW / (movers - 1)) : 0;\n  const transitionFor = (card: T, slot: number): Transition => {\n    if (reduce || resized.current) return { duration: 0 };\n    const was = previous.current.get(card.id);\n    if (!was) return { duration: 0 };\n    if (was.zone !== card.zone) {\n      const delay = moving * stagger;\n      moving += 1;\n      return { ...FLIGHT, delay, path: arc({ strength: FLIGHT_STRENGTH }) };\n    }\n    if (card.zone === \"hand\" && was.slot !== slot) {\n      return { ...HOP, path: arc({ strength: HOP_STRENGTH }) };\n    }\n    return HOP;\n  };\n\n  const renderCard = (card: T, slot: number) => {\n    const pose = poseFor(card, slot);\n    const interactive = card.zone === \"hand\" && onCardSelect !== undefined;\n    const faces = (\n      <motion.span\n        aria-hidden=\"true\"\n        className=\"relative block size-full [transform-style:preserve-3d]\"\n        initial={false}\n        animate={{ rotateY: pose.faceDown ? 180 : 0 }}\n        transition={reduce ? { duration: 0 } : FLIGHT}\n      >\n        <span className=\"absolute inset-0 overflow-hidden rounded-xl [backface-visibility:hidden]\">\n          {renderFace(card)}\n        </span>\n        <span className=\"absolute inset-0 overflow-hidden rounded-xl [backface-visibility:hidden] [transform:rotateY(180deg)]\">\n          {renderBack ? renderBack(card) : <DefaultBack />}\n        </span>\n      </motion.span>\n    );\n\n    return (\n      <CardFrame\n        key={card.id}\n        pose={pose}\n        transition={transitionFor(card, slot)}\n        width={cardWidth}\n        height={cardHeight}\n      >\n        {/* The faces never change element, so a card flips in flight instead\n            of remounting face up; the button lies over them only in the hand. */}\n        <div className=\"size-full [perspective:800px]\">{faces}</div>\n        {interactive ? (\n          <button\n            type=\"button\"\n            aria-label={card.label}\n            onClick={() => onCardSelect?.(card.id)}\n            onPointerEnter={(event) => {\n              if (canHover && event.pointerType === \"mouse\") setHovered(card.id);\n            }}\n            onPointerLeave={() => setHovered((id) => (id === card.id ? null : id))}\n            onFocus={() => setHovered(card.id)}\n            onBlur={() => setHovered((id) => (id === card.id ? null : id))}\n            className=\"absolute inset-0 cursor-pointer rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n          />\n        ) : null}\n      </CardFrame>\n    );\n  };\n\n  return (\n    <section\n      ref={stageRef}\n      aria-label={ariaLabel}\n      className={cn(\"relative h-80 w-full touch-manipulation select-none\", className)}\n    >\n      {/* One flat list, so a card changing zones keeps its element and flies\n          instead of remounting; zone by zone with the hand in fan order, so\n          Tab follows the hand. */}\n      {size\n        ? [deck, hand, pile].flatMap((zone) =>\n            zone.map((card, slot) => renderCard(card, slot)),\n          )\n        : null}\n    </section>\n  );\n}\n\n/**\n * Moves one card. Imperative so each new move can stop the last one first:\n * a declarative arc path keeps running when its target changes, and a long\n * flight finishing after a quick hop would write its stale end point back.\n */\nfunction CardFrame({\n  pose,\n  transition,\n  width,\n  height,\n  children,\n}: {\n  pose: Pose;\n  transition: Transition;\n  width: number;\n  height: number;\n  children: ReactNode;\n}) {\n  const [frame, animateFrame] = useAnimate<HTMLDivElement>();\n  const running = useRef<AnimationPlaybackControls | null>(null);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: a move starts when the pose changes; the transition belongs to that same render and must not restart a move on its own.\n  useLayoutEffect(() => {\n    if (!frame.current) return;\n    running.current?.stop();\n    running.current = animateFrame(\n      frame.current,\n      { x: pose.x, y: pose.y, rotate: pose.rotate },\n      transition,\n    );\n  }, [pose.x, pose.y, pose.rotate]);\n\n  useLayoutEffect(() => () => running.current?.stop(), []);\n\n  return (\n    <div\n      ref={frame}\n      className=\"absolute top-0 left-0\"\n      style={{ width, height, zIndex: pose.z }}\n    >\n      {children}\n    </div>\n  );\n}\n\nfunction DefaultBack() {\n  return (\n    <span className=\"block size-full rounded-xl border border-border bg-[repeating-linear-gradient(45deg,var(--muted)_0_6px,var(--background)_6px_12px)]\" />\n  );\n}\n"},{"path":"lib/hooks/use-hover-capable.ts","type":"registry:hook","target":"@lib/hooks/use-hover-capable.ts","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}