{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "card-stack",
  "title": "Card stack",
  "description": "A fanned stack of payment or account cards: drag, flick, tap or key the front card away and the next comes forward, with its balance under the fan and dots to pick a card.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "@manniche/chart-kit"
  ],
  "files": [
    {
      "path": "registry/manniche/card-stack/card-stack.tsx",
      "content": "// A fanned stack of payment or account cards. The front card's figure sits under the fan in the display number.\n// Drag or flick the front card sideways, tap it, press the arrow keys or use the buttons, and it flies off and\n// tucks in at the back while the next card comes forward. The dots under the fan pick a card directly.\n// The fan is sized in CSS from the container's width, so nothing jumps on load and it fits from 280 px up.\n// Screen readers: the stack is a carousel whose only visible slide is the front card (\"1 of 4: Everyday, Debit,\n// ending 4821, €2,091.20\"); the cards behind it are hidden and inert. Every switch made with a key, a button or a\n// swipe is read once through a polite live region. The dots are a radio group.\n// Reduced motion: no fan-in, no flight and no rolling digits; the next card is simply in front.\nimport { ChevronLeft, ChevronRight } from 'lucide-react'\nimport { useEffect, useId, useRef, useState, type CSSProperties, type HTMLAttributes, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react'\nimport { BigNumber } from '@/registry/manniche/chart-kit/chart-kit'\nimport { EASE_CSS, formatValue, stepIndex, type ValueFormat } from '@/registry/manniche/chart-kit/chart-utils'\nimport { useAnnounce, useChartFrame } from '@/registry/manniche/chart-kit/use-chart'\nimport { cn } from '@/lib/utils'\n\nexport type StackCard = {\n  /** Unique and stable; it is the card's value for `value` and `onValueChange`. */\n  id: string\n  /** The name on the card, such as \"Everyday\". */\n  name: string\n  /** A second word in the corner, such as \"Debit\" or \"Virtual\". */\n  kind?: string\n  /** The last digits of the number, shown as \"•••• 4821\". */\n  ending?: string\n  /** The figure shown under the fan while this card is in front, such as its balance. */\n  amount: number\n  /** The card's fill. Any CSS colour; defaults run ink, then --chart-1, --chart-3, --chart-2, --chart-5, --chart-4. */\n  color?: string\n  /** The text colour on the card. Leave it out and it is picked from the fill for contrast. */\n  textColor?: string\n}\n\nexport type CardStackLabels = {\n  /** The small line over the figure. Default \"Everyday · Debit\". */\n  readout?: (card: StackCard) => string\n  previous?: string\n  next?: string\n  /** The name of the dot picker. */\n  picker?: string\n  /** Read after the stack's name. */\n  hint?: string\n  /** The front slide's name, which screen readers say when they reach it. */\n  slide?: (card: StackCard, position: number, count: number, amount: string) => string\n  /** What is read when a card comes forward. */\n  announce?: (card: StackCard, amount: string) => string\n  /** One dot's name. */\n  dot?: (card: StackCard) => string\n  /** Shown in place of the dots when they do not fit. Default \"2 of 7\". */\n  counter?: (position: number, count: number) => string\n}\n\nexport type CardStackProps = Omit<HTMLAttributes<HTMLDivElement>, 'defaultValue' | 'onChange'> & {\n  /** The cards, front first. */\n  data: StackCard[]\n  /** The accessible name of the stack, such as \"Your cards\". */\n  label: string\n  /** The figure's format. Default two decimals; pass a currency for money. */\n  format?: ValueFormat\n  /** The id of the card in front (controlled). */\n  value?: string\n  /** The card in front at first (uncontrolled). Default the first card. */\n  defaultValue?: string\n  /** Called with the new front card's id. */\n  onValueChange?: (id: string) => void\n  /** The widest a card gets, in px. Default 330, or 268 inside a compact tile. */\n  cardWidth?: number\n  /** Draw your own card face. The shell (fill, corners, shadow, motion) stays. */\n  renderCard?: (card: StackCard, state: { front: boolean; index: number }) => ReactNode\n  /** Hide the figure under the fan. */\n  hideAmount?: boolean\n  labels?: CardStackLabels\n}\n\nconst DEFAULT_LABELS: Required<CardStackLabels> = {\n  readout: (c) => (c.kind ? `${c.name} · ${c.kind}` : c.name),\n  previous: 'Previous card',\n  next: 'Next card',\n  picker: 'Choose a card',\n  hint: 'Use the arrow keys, the buttons or a swipe to switch cards.',\n  slide: (c, i, n, amount) => `${i} of ${n}: ${[c.name, c.kind, c.ending && `ending ${c.ending}`, amount].filter(Boolean).join(', ')}`,\n  announce: (c, amount) => `${c.name}${c.ending ? `, ending ${c.ending}` : ''}, ${amount}`,\n  dot: (c) => (c.ending ? `${c.name}, ending ${c.ending}` : c.name),\n  counter: (i, n) => `${i} of ${n}`,\n}\n\nconst FILLS = ['var(--foreground)', 'var(--chart-1)', 'var(--chart-3)', 'var(--chart-2)', 'var(--chart-5)', 'var(--chart-4)']\n\n// The fan: each card behind the front one is moved, turned and shrunk a step more, about a point low on its left.\nconst STEP = { x: 16, y: -9, turn: 5.5, shrink: 0.035 }\nconst MAX_BEHIND = 3\nconst ORIGIN = { x: 0.22, y: 1.2 }\nconst RATIO = 1.586 // ISO/IEC 7810 ID-1\nconst slot = (k: number) =>\n  `translate(${k * STEP.x}px, ${k * STEP.y}px) rotate(${k * STEP.turn}deg) scale(${1 - k * STEP.shrink})`\n\n// How far the fan reaches past the front card, as a·width + b px, so CSS can reserve the room. Right uses the\n// deepest card; the lift above the front card's top edge is the largest of all depths, written as CSS max().\nfunction fanCorners(k: number) {\n  const h = 1 / RATIO\n  const s = 1 - k * STEP.shrink\n  const t = (k * STEP.turn * Math.PI) / 180\n  const ox = ORIGIN.x\n  const oy = ORIGIN.y * h\n  let right = -Infinity\n  let top = Infinity\n  for (const [px, py] of [\n    [0, 0],\n    [1, 0],\n    [0, h],\n    [1, h],\n  ]) {\n    const rx = (px - ox) * s\n    const ry = (py - oy) * s\n    right = Math.max(right, ox + rx * Math.cos(t) - ry * Math.sin(t) - 1)\n    top = Math.min(top, oy + rx * Math.sin(t) + ry * Math.cos(t))\n  }\n  return { right, top }\n}\nfunction fanRoom(behind: number) {\n  const right = fanCorners(behind).right\n  const lifts = Array.from({ length: behind }, (_, i) => {\n    const k = i + 1\n    return `calc(${-k * STEP.y}px - var(--w) * ${fanCorners(k).top.toFixed(4)})`\n  })\n  return {\n    marginRight: `calc(var(--w) * ${right.toFixed(4)} + ${behind * STEP.x}px)`,\n    marginTop: lifts.length ? `max(0px, ${lifts.join(', ')})` : '0px',\n    width: `min(var(--cw), calc((100cqw - ${behind * STEP.x}px) / ${(1 + right).toFixed(4)}))`,\n  }\n}\n\nconst FLY_MS = 220\nconst SETTLE_MS = 300\nconst TAP_PX = 4\n\ntype Drag = { id: string; pointer: number; x0: number; dx: number; samples: { t: number; x: number }[] }\n\nexport function CardStack({\n  data,\n  label,\n  format = { decimals: 2 },\n  value,\n  defaultValue,\n  onValueChange,\n  cardWidth,\n  renderCard,\n  hideAmount = false,\n  labels: labelsProp,\n  className,\n  style,\n  ...rest\n}: CardStackProps) {\n  const labels = { ...DEFAULT_LABELS, ...labelsProp }\n  const { ref: frameRef, width: frameWidth, drawn, reduced } = useChartFrame<HTMLDivElement>()\n  const { say, region } = useAnnounce()\n  const hintId = useId()\n  const [inner, setInner] = useState(defaultValue ?? data[0]?.id)\n  const current = value ?? inner\n  const n = data.length\n  const front = Math.max(0, data.findIndex((c) => c.id === current))\n\n  const [drag, setDrag] = useState<Drag | null>(null)\n  const [fly, setFly] = useState<{ id: string; dir: number; ms: number } | null>(null)\n  const [enter, setEnter] = useState<string | null>(null)\n  // The fan opens once, card by card, the first time the stack is seen; after that cards move without a delay.\n  const [opened, setOpened] = useState(false)\n  const flyTimer = useRef(0)\n\n  useEffect(() => () => window.clearTimeout(flyTimer.current), [])\n  useEffect(() => {\n    if (!drawn || opened) return\n    const t = window.setTimeout(() => setOpened(true), 600)\n    return () => window.clearTimeout(t)\n  }, [drawn, opened])\n  // A card coming back from the left is placed there for one frame without a transition, then glides in.\n  useEffect(() => {\n    if (!enter) return\n    let b = 0\n    const a = requestAnimationFrame(() => {\n      b = requestAnimationFrame(() => setEnter(null))\n    })\n    return () => {\n      cancelAnimationFrame(a)\n      cancelAnimationFrame(b)\n    }\n  }, [enter])\n\n  if (!n) return null\n\n  const select = (i: number, speak: boolean) => {\n    const card = data[((i % n) + n) % n]\n    if (card.id === data[front].id) return\n    if (value === undefined) setInner(card.id)\n    onValueChange?.(card.id)\n    if (speak) say(labels.announce(card, formatValue(card.amount, format)))\n  }\n\n  // The front card flies out (in the swipe's direction) and the next one comes forward at once.\n  const next = (dir = 1, ms = FLY_MS) => {\n    if (n < 2) return\n    if (!reduced) {\n      window.clearTimeout(flyTimer.current)\n      setFly({ id: data[front].id, dir, ms })\n      flyTimer.current = window.setTimeout(() => setFly(null), ms)\n    }\n    select(front + 1, true)\n  }\n  // The back card comes in from the left, over the top, and lands in front.\n  const previous = () => {\n    if (n < 2) return\n    const i = (front - 1 + n) % n\n    if (!reduced) setEnter(data[i].id)\n    select(i, true)\n  }\n\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    if (e.target !== e.currentTarget) return\n    if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next()\n    else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') previous()\n    else if (e.key === 'Home' || e.key === 'End') select(e.key === 'Home' ? 0 : n - 1, true)\n    else return\n    e.preventDefault()\n  }\n\n  // Dragging -----------------------------------------------------------------------------------------------------\n  const onPointerDown = (e: PointerEvent<HTMLDivElement>, id: string) => {\n    if (id !== data[front].id || (e.pointerType === 'mouse' && e.button !== 0)) return\n    e.currentTarget.setPointerCapture(e.pointerId)\n    setDrag({ id, pointer: e.pointerId, x0: e.clientX, dx: 0, samples: [{ t: e.timeStamp, x: e.clientX }] })\n  }\n  const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {\n    if (!drag || e.pointerId !== drag.pointer) return\n    const samples = [...drag.samples, { t: e.timeStamp, x: e.clientX }].filter((s) => e.timeStamp - s.t < 100)\n    setDrag({ ...drag, dx: e.clientX - drag.x0, samples })\n  }\n  const onPointerUp = (e: PointerEvent<HTMLDivElement>) => {\n    if (!drag || e.pointerId !== drag.pointer) return\n    const { dx, samples } = drag\n    setDrag(null)\n    const first = samples[0]\n    const last = samples[samples.length - 1]\n    // px per ms over the last tenth of a second, so a short flick counts as well as a long drag\n    const v = last.t > first.t ? (last.x - first.x) / (last.t - first.t) : 0\n    const width = e.currentTarget.offsetWidth\n    const far = Math.abs(dx) > Math.min(90, width * 0.28)\n    const flick = Math.abs(dx) > 16 && Math.abs(v) > 0.45 && Math.sign(v) === Math.sign(dx)\n    if (far || flick) {\n      // Keep the throw's speed: an ease-out-quint starts at about 4.3 times its average speed, so the card\n      // leaves the finger at the speed it was thrown and slows to a stop off to the side, with no spring back.\n      const left = width * 1.15 - Math.abs(dx)\n      const ms = Math.round(Math.max(160, Math.min(SETTLE_MS, (4.3 * left) / Math.max(Math.abs(v), 0.6))))\n      next(Math.sign(dx), ms)\n    } else if (Math.abs(dx) < TAP_PX) next(1)\n  }\n  const onPointerCancel = (e: PointerEvent<HTMLDivElement>) => {\n    if (drag && e.pointerId === drag.pointer) setDrag(null)\n  }\n\n  // Layout -------------------------------------------------------------------------------------------------------\n  const behind = Math.min(n - 1, MAX_BEHIND)\n  const room = fanRoom(behind)\n  const dotsFit = frameWidth === 0 || n * 44 + 2 * 44 + 2 * 8 <= frameWidth\n  const frontCard = data[front]\n\n  return (\n    <div\n      ref={frameRef}\n      className={cn(\n        '@container grid w-full min-w-0 justify-items-center gap-[18px] group-data-[density=compact]/tile:gap-3',\n        cardWidth === undefined && '[--cw:330px] group-data-[density=compact]/tile:[--cw:268px]',\n        className,\n      )}\n      style={{ ...(cardWidth !== undefined ? ({ '--cw': `${cardWidth}px` } as CSSProperties) : null), ...style }}\n      {...rest}\n    >\n      <div\n        tabIndex={0}\n        role=\"group\"\n        aria-roledescription=\"carousel\"\n        aria-label={label}\n        aria-describedby={hintId}\n        onKeyDown={onKeyDown}\n        className=\"relative aspect-[1.586] touch-pan-y rounded-[20px] outline-offset-[6px] select-none [-webkit-touch-callout:none]\"\n        style={{ '--w': room.width, width: 'var(--w)', marginRight: room.marginRight, marginTop: room.marginTop } as CSSProperties}\n      >\n        <span id={hintId} className=\"sr-only\">\n          {labels.hint}\n        </span>\n        {data.map((card, i) => {\n          const k = (i - front + n) % n\n          const isFront = k === 0\n          const fill = card.color ?? FILLS[i % FILLS.length]\n          const ink = card.color === undefined && i % FILLS.length === 0\n          const settle = `transform ${SETTLE_MS}ms ${EASE_CSS}, opacity 200ms ${EASE_CSS}`\n          let transform = slot(Math.min(k, MAX_BEHIND))\n          let transition = reduced ? 'none' : settle\n          let delay = opened || reduced ? 0 : Math.min(k, MAX_BEHIND) * 60\n          let opacity = k > MAX_BEHIND ? 0 : 1\n          let z = 20 - Math.min(k, 19)\n          if (!drawn && !reduced) transform = slot(0)\n          if (drag?.id === card.id) {\n            transform = `translateX(${drag.dx}px) rotate(${drag.dx * 0.05}deg)`\n            transition = 'none'\n            z = 30\n          } else if (fly?.id === card.id) {\n            transform = `translate(${fly.dir * 115}%, -4%) rotate(${fly.dir * 16}deg)`\n            transition = `transform ${fly.ms}ms ${EASE_CSS}`\n            delay = 0\n            opacity = 1\n            z = 30\n          } else if (enter === card.id) {\n            transform = 'translate(-115%, -4%) rotate(-16deg)'\n            transition = 'none'\n            z = 30\n          }\n          return (\n            <div\n              key={card.id}\n              role={isFront ? 'group' : undefined}\n              aria-roledescription={isFront ? 'slide' : undefined}\n              aria-label={isFront ? labels.slide(card, i + 1, n, formatValue(card.amount, format)) : undefined}\n              aria-hidden={isFront ? undefined : true}\n              inert={!isFront}\n              data-front={isFront || undefined}\n              onPointerDown={(e) => onPointerDown(e, card.id)}\n              onPointerMove={onPointerMove}\n              onPointerUp={onPointerUp}\n              onPointerCancel={onPointerCancel}\n              onLostPointerCapture={onPointerCancel}\n              onDragStart={(e) => e.preventDefault()}\n              className={cn(\n                'absolute inset-0 flex flex-col justify-between overflow-hidden rounded-[18px] px-5 py-[18px]',\n                'shadow-[0_1px_1px_rgba(0,0,0,0.08),0_14px_28px_-14px_rgba(0,0,0,0.45)]',\n                'group-data-[density=compact]/tile:px-4 group-data-[density=compact]/tile:py-3.5',\n                isFront && (drag ? 'cursor-grabbing' : 'cursor-grab'),\n                !card.textColor && !ink && 'cs-auto',\n              )}\n              style={\n                {\n                  '--fill': fill,\n                  background: fill,\n                  color: card.textColor ?? (ink ? 'var(--card)' : 'oklch(0.985 0.002 90)'),\n                  transform,\n                  transformOrigin: `${ORIGIN.x * 100}% ${ORIGIN.y * 100}%`,\n                  transition,\n                  transitionDelay: delay ? `${delay}ms` : undefined,\n                  opacity,\n                  zIndex: z,\n                } as CSSProperties\n              }\n            >\n              {renderCard ? renderCard(card, { front: isFront, index: i }) : <CardFace card={card} />}\n            </div>\n          )\n        })}\n      </div>\n      <style>{AUTO_CSS}</style>\n\n      {!hideAmount && (\n        <div aria-hidden className=\"grid min-w-0 justify-items-center gap-1 text-center\">\n          <span className=\"max-w-full truncate text-[13.5px] leading-[1.3] font-medium text-muted-foreground\">\n            {labels.readout(frontCard)}\n          </span>\n          <BigNumber value={frontCard.amount} format={format} size=\"lg\" className=\"group-data-[density=compact]/tile:text-[30px]\" />\n        </div>\n      )}\n\n      {n > 1 && (\n        <div className=\"flex items-center gap-2\">\n          <IconButton label={labels.previous} onClick={previous}>\n            <ChevronLeft className=\"size-[18px]\" strokeWidth={2} />\n          </IconButton>\n          {dotsFit ? (\n            <div role=\"radiogroup\" aria-label={labels.picker} className=\"flex\">\n              {data.map((card, i) => (\n                <Dot\n                  key={card.id}\n                  label={labels.dot(card)}\n                  on={i === front}\n                  onPick={() => select(i, false)}\n                  onKey={(e) => {\n                    const key = e.key === 'ArrowDown' ? 'ArrowRight' : e.key === 'ArrowUp' ? 'ArrowLeft' : e.key\n                    const to = stepIndex(key, i, n, { loop: true })\n                    if (to === null) return\n                    e.preventDefault()\n                    select(to, false)\n                    ;(e.currentTarget.parentElement?.children[to] as HTMLElement | undefined)?.focus()\n                  }}\n                />\n              ))}\n            </div>\n          ) : (\n            <span className=\"min-w-14 text-center font-mono text-[12px] text-muted-foreground tabular-nums\">\n              {labels.counter(front + 1, n)}\n            </span>\n          )}\n          <IconButton label={labels.next} onClick={() => next(1)}>\n            <ChevronRight className=\"size-[18px]\" strokeWidth={2} />\n          </IconButton>\n        </div>\n      )}\n      {region}\n    </div>\n  )\n}\n\n// Text on a card fill: where the browser can do colour maths, the fill is pulled out of the murky middle band and\n// the text goes near-white on a dark fill and near-black on a light one, so the name and number keep 4.5:1.\nconst SWITCH = 'clamp(0, (l - 0.66) * 1000, 1)'\nconst AUTO_CSS = `@supports (color: oklch(from red l c h)) {\n  .cs-auto {\n    background: oklch(from var(--fill) calc((1 - ${SWITCH}) * min(l, 0.55) + ${SWITCH} * max(l, 0.77)) c h) !important;\n    color: oklch(from var(--fill) calc((1 - ${SWITCH}) * 0.985 + ${SWITCH} * 0.2) 0.01 90) !important;\n  }\n}`\n\nfunction CardFace({ card }: { card: StackCard }) {\n  return (\n    <>\n      <span\n        aria-hidden\n        className=\"pointer-events-none absolute -right-[60px] -bottom-[90px] size-60 rounded-full opacity-[0.13] shadow-[inset_0_0_0_1px_currentColor] after:absolute after:inset-7 after:rounded-full after:shadow-[inset_0_0_0_1px_currentColor] after:content-['']\"\n      />\n      <span aria-hidden className=\"relative flex items-center justify-between gap-2\">\n        <span className=\"truncate text-[15px] leading-[1.2] font-semibold tracking-[-0.01em]\">{card.name}</span>\n        <svg viewBox=\"0 0 20 20\" className=\"size-5 flex-none fill-none stroke-current stroke-[1.8] opacity-90 [stroke-linecap:round]\">\n          <path d=\"M6 6.5a5 5 0 0 1 0 7M9.5 4.5a8 8 0 0 1 0 11M13 2.5a11 11 0 0 1 0 15\" />\n        </svg>\n      </span>\n      <svg aria-hidden viewBox=\"0 0 38 28\" className=\"relative h-7 w-[38px]\">\n        <rect x=\".75\" y=\".75\" width=\"36.5\" height=\"26.5\" rx=\"6\" fill=\"currentColor\" fillOpacity=\".16\" stroke=\"currentColor\" strokeOpacity=\".45\" strokeWidth=\"1.2\" />\n        <path d=\"M1 10h11M1 18h11M26 10h11M26 18h11M12 1v26M26 1v26M12 14h14\" stroke=\"currentColor\" strokeOpacity=\".45\" strokeWidth=\"1.2\" fill=\"none\" />\n      </svg>\n      <span aria-hidden className=\"relative flex items-center justify-between gap-2\">\n        {card.ending ? (\n          <span className=\"font-mono text-[14px] leading-none font-medium tracking-[0.06em] whitespace-nowrap\">•••• {card.ending}</span>\n        ) : (\n          <span />\n        )}\n        {card.kind && <span className=\"truncate text-[12px] leading-none font-medium opacity-[0.78]\">{card.kind}</span>}\n      </span>\n    </>\n  )\n}\n\nfunction IconButton({ label, onClick, children }: { label: string; onClick: () => void; children: ReactNode }) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      onClick={onClick}\n      className=\"grid size-11 flex-none cursor-pointer place-items-center rounded-full bg-muted text-foreground transition-transform duration-150 ease-out-quint active:scale-[0.96] motion-reduce:transition-none\"\n    >\n      {children}\n    </button>\n  )\n}\n\nfunction Dot({ label, on, onPick, onKey }: { label: string; on: boolean; onPick: () => void; onKey: (e: KeyboardEvent<HTMLButtonElement>) => void }) {\n  return (\n    <button\n      type=\"button\"\n      role=\"radio\"\n      aria-checked={on}\n      aria-label={label}\n      tabIndex={on ? 0 : -1}\n      onClick={onPick}\n      onKeyDown={onKey}\n      className=\"group/dot grid size-11 cursor-pointer place-items-center rounded-full focus-visible:outline-offset-[-4px]\"\n    >\n      <i\n        aria-hidden\n        className={cn(\n          'block size-[7px] rounded-full bg-foreground transition-[opacity,transform] duration-200 ease-out-quint motion-reduce:transition-none',\n          on ? 'scale-[1.15] opacity-100' : 'opacity-[0.22] group-hover/dot:opacity-50',\n        )}\n      />\n    </button>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "surfaces"
  ],
  "type": "registry:component"
}