{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "card-stack",
  "type": "registry:component",
  "title": "Card Stack",
  "description": "A deck of cards you shuffle by dragging the top one: inertia, rotation and a spring back to the bottom. Also works with click and keyboard.",
  "author": "Marcelo Treviño Velázquez <https://furin.dev>",
  "dependencies": [],
  "categories": [
    "Cards"
  ],
  "docs": "Installed in components/furin/card-stack/. Docs, live controls and props table: https://furin.dev/components/card-stack/",
  "files": [
    {
      "path": "registry/card-stack/CardStack.tsx",
      "content": "\"use client\";\nimport { useCallback, useEffect, useMemo, useRef } from \"react\";\nimport type { CSSProperties, KeyboardEvent, PointerEvent } from \"react\";\nimport \"./CardStack.css\";\n\nexport interface CardStackItem {\n  /** Título grande de la tarjeta. */\n  title: string;\n  /** Texto de apoyo. */\n  caption?: string;\n  /** Color de arranque del degradado. */\n  from?: string;\n  /** Color de cierre del degradado. */\n  to?: string;\n}\n\nexport interface CardStackProps {\n  /** Tarjetas del mazo, de arriba hacia abajo. */\n  cards?: CardStackItem[];\n  /** Ancho de cada tarjeta, en px. */\n  width?: number;\n  /** Cuánto asoma cada tarjeta de abajo, en px. */\n  fan?: number;\n  /** Giro máximo de las tarjetas del mazo, en grados. */\n  messiness?: number;\n  /** Distancia de arrastre que baraja la tarjeta, en px. */\n  threshold?: number;\n  /** Se llama con el índice de la tarjeta que acaba de quedar arriba. */\n  onShuffle?: (topIndex: number) => void;\n  /** Clase extra para el contenedor. */\n  className?: string;\n  /** Estilos extra para el contenedor. */\n  style?: CSSProperties;\n}\n\nconst DEFAULT_CARDS: CardStackItem[] = [\n  { title: \"Sunrise\", caption: \"Cold light over the bay\", from: \"#ff8a5c\", to: \"#ff3d7f\" },\n  { title: \"Tide\", caption: \"Blues that never repeat\", from: \"#5b6cff\", to: \"#27c6ff\" },\n  { title: \"Jungle\", caption: \"Damp green, long shadows\", from: \"#22d3a0\", to: \"#0a6f63\" },\n  { title: \"Golden\", caption: \"The hour when everything glows\", from: \"#ffd166\", to: \"#ff8f3d\" },\n  { title: \"Night\", caption: \"Quiet, with neon in the distance\", from: \"#3a3f55\", to: \"#12141c\" },\n];\n\ninterface S { x: number; y: number; r: number; s: number; d: number; vx: number; vy: number; vr: number; vs: number; vd: number; phase: \"idle\" | \"drag\" | \"fly\" | \"back\"; t: number }\nconst TAU_DEG = 180 / Math.PI;\nvoid TAU_DEG;\n\n/** Mazo de tarjetas: arrastra la de arriba con inercia y giro; al soltarla se baraja y vuelve al fondo del mazo con resorte. */\nexport function CardStack({\n  cards = DEFAULT_CARDS,\n  width = 250,\n  fan = 18,\n  messiness = 4,\n  threshold = 90,\n  onShuffle,\n  className = \"\",\n  style,\n}: CardStackProps) {\n  const n = cards.length;\n  const rootRef = useRef<HTMLDivElement>(null);\n  const els = useRef<(HTMLDivElement | null)[]>([]);\n  const order = useRef<number[]>(Array.from({ length: n }, (_, i) => i));\n  const S = useRef<S[]>([]);\n  const raf = useRef(0);\n  const last = useRef(0);\n  const drag = useRef({ id: -1, i: -1, sx: 0, sy: 0, dx: 0, dy: 0, t0: 0, samples: [] as { t: number; x: number; y: number }[] });\n  const reduced = useRef(false);\n  const live = useRef<HTMLSpanElement>(null);\n  const cfg = useRef({ fan, messiness, threshold, onShuffle });\n  cfg.current = { fan, messiness, threshold, onShuffle };\n\n  // Giro fijo de cada tarjeta (determinista): alterna lados para que el mazo se vea hecho a mano.\n  const jitter = useMemo(() => cards.map((_, i) => (((i * 37) % 11) / 10 - 0.35) * (i % 2 ? 1 : -1)), [cards]);\n\n  const pose = useCallback((rank: number, i: number) => {\n    const { fan: f, messiness: m } = cfg.current;\n    const d = Math.min(rank, 4);\n    return { x: 0, y: -d * f, r: rank === 0 ? jitter[i] * m * 0.12 : jitter[i] * m, s: 1 - d * 0.04, d: Math.min(d * 0.13, 0.5) };\n  }, [jitter]);\n\n  const applyOrder = useCallback(() => {\n    order.current.forEach((i, rank) => {\n      const el = els.current[i];\n      if (!el) return;\n      el.style.zIndex = String(n - rank);\n      el.dataset.top = rank === 0 ? \"true\" : \"false\";\n      el.setAttribute(\"aria-hidden\", rank === 0 ? \"false\" : \"true\");\n      el.tabIndex = -1;\n    });\n    const top = cards[order.current[0]];\n    if (live.current && top) live.current.textContent = `${top.title}. Card ${order.current[0] + 1} of ${n}.`;\n  }, [cards, n]);\n\n  const paint = useCallback((i: number) => {\n    const el = els.current[i], c = S.current[i];\n    if (!el || !c) return;\n    el.style.transform = `translate3d(${c.x.toFixed(2)}px, ${c.y.toFixed(2)}px, 0) rotate(${c.r.toFixed(3)}deg) scale(${c.s.toFixed(4)})`;\n    el.style.setProperty(\"--furin-card-stack-dim\", Math.max(0, c.d).toFixed(3));\n    el.style.setProperty(\"--furin-card-stack-sx\", String(Math.max(-1, Math.min(1, c.x / (el.offsetWidth || 250)))));\n    el.style.setProperty(\"--furin-card-stack-lift\", c.phase === \"drag\" || c.phase === \"fly\" ? \"1\" : \"0\");\n  }, []);\n\n  const tick = useCallback((now: number) => {\n    raf.current = 0;\n    const dt = Math.min(0.033, (now - last.current) / 1000 || 0.016);\n    last.current = now;\n    let moving = false;\n    const W = rootRef.current?.offsetWidth || 250;\n    const dr = drag.current;\n    const topIdx = order.current[0];\n    // Cuánto se ha sacado la tarjeta de arriba: las de abajo \"se asoman\" un poco para anticipar el relevo.\n    const topC = S.current[topIdx];\n    const prog = topC && (topC.phase === \"drag\" || topC.phase === \"fly\") ? Math.min(1, Math.hypot(topC.x, topC.y) / (W * 0.7)) : 0;\n\n    for (let i = 0; i < n; i++) {\n      const c = S.current[i];\n      if (!c) continue;\n      const rank = order.current.indexOf(i);\n      if (c.phase === \"drag\") {\n        const vxs = dr.samples.length > 1 ? (dr.samples[dr.samples.length - 1].x - dr.samples[0].x) / Math.max(0.016, (dr.samples[dr.samples.length - 1].t - dr.samples[0].t) / 1000) : 0;\n        const tr = Math.max(-24, Math.min(24, dr.dx * 0.055 + vxs * 0.0035));\n        c.x = dr.dx; c.y = dr.dy;\n        c.vr += (180 * (tr - c.r) - 20 * c.vr) * dt; c.r += c.vr * dt;\n        c.vs += (260 * (1.045 - c.s) - 22 * c.vs) * dt; c.s += c.vs * dt;\n        c.d += (0 - c.d) * Math.min(1, dt * 12);\n        moving = true;\n      } else if (c.phase === \"fly\") {\n        c.t += dt;\n        c.x += c.vx * dt; c.y += c.vy * dt;\n        const f = Math.pow(0.9, dt * 60);\n        c.vx *= f; c.vy *= f;\n        c.r += c.vr * dt; c.vr *= Math.pow(0.95, dt * 60);\n        c.s += (1.0 - c.s) * Math.min(1, dt * 8);\n        if (Math.hypot(c.x, c.y) > W * 0.95 || c.t > 0.34 || Math.hypot(c.vx, c.vy) < 90) {\n          // Ya salió del mazo: pasa al fondo y regresa por debajo de las demás.\n          order.current = order.current.filter((k) => k !== i).concat(i);\n          c.phase = \"back\";\n          applyOrder();\n          cfg.current.onShuffle?.(order.current[0]);\n        }\n        moving = true;\n      } else {\n        const tg = pose(rank, i);\n        let { x: tx, y: ty, r: tr, s: ts, d: td } = tg;\n        if (rank > 0 && prog > 0) {\n          const up = pose(rank - 1, order.current[rank - 1]);\n          const p = prog * 0.55;\n          tx += (up.x - tx) * p; ty += (up.y - ty) * p; tr += (up.r - tr) * p; ts += (up.s - ts) * p; td += (up.d - td) * p;\n        }\n        const back = c.phase === \"back\";\n        const k = back ? 120 : 230, cc = back ? 15 : 23;\n        const sub = 2, h = dt / sub;\n        for (let q = 0; q < sub; q++) {\n          c.vx += (k * (tx - c.x) - cc * c.vx) * h; c.x += c.vx * h;\n          c.vy += (k * (ty - c.y) - cc * c.vy) * h; c.y += c.vy * h;\n          c.vr += (k * (tr - c.r) - cc * c.vr) * h; c.r += c.vr * h;\n          c.vs += (k * 1.3 * (ts - c.s) - cc * c.vs) * h; c.s += c.vs * h;\n        }\n        c.d += (td - c.d) * Math.min(1, dt * 10);\n        const err = Math.abs(tx - c.x) + Math.abs(ty - c.y) + Math.abs(tr - c.r) * 2 + Math.abs(ts - c.s) * 100;\n        const vel = Math.abs(c.vx) + Math.abs(c.vy) + Math.abs(c.vr) * 2;\n        if (err < 0.15 && vel < 1.2) { c.x = tx; c.y = ty; c.r = tr; c.s = ts; c.d = td; c.vx = c.vy = c.vr = c.vs = 0; if (c.phase === \"back\") c.phase = \"idle\"; }\n        else moving = true;\n      }\n      paint(i);\n    }\n    if (moving) raf.current = requestAnimationFrame(tick);\n  }, [n, pose, paint, applyOrder]);\n\n  const wake = useCallback(() => {\n    if (!raf.current) { last.current = performance.now(); raf.current = requestAnimationFrame(tick); }\n  }, [tick]);\n\n  useEffect(() => {\n    reduced.current = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    order.current = Array.from({ length: n }, (_, i) => i);\n    S.current = cards.map((_, i) => { const p = pose(i, i); return { ...p, vx: 0, vy: 0, vr: 0, vs: 0, vd: 0, phase: \"idle\", t: 0 } as S; });\n    applyOrder();\n    for (let i = 0; i < n; i++) paint(i);\n    return () => { cancelAnimationFrame(raf.current); raf.current = 0; };\n  }, [cards, n, pose, paint, applyOrder]);\n\n  // Si cambian fan o messiness con el mazo montado, reacomoda.\n  useEffect(() => { wake(); }, [fan, messiness, wake]);\n\n  const shuffle = useCallback((vx: number, vy: number) => {\n    const i = order.current[0];\n    const c = S.current[i];\n    if (!c || c.phase === \"fly\") return;\n    if (reduced.current) {\n      order.current = order.current.slice(1).concat(i);\n      c.phase = \"idle\"; applyOrder();\n      cfg.current.onShuffle?.(order.current[0]);\n      wake();\n      return;\n    }\n    c.phase = \"fly\"; c.t = 0;\n    c.vx = vx; c.vy = vy;\n    c.vr = vx * 0.03 + (vx >= 0 ? 38 : -38);\n    els.current[i]!.style.zIndex = String(n + 5);\n    wake();\n  }, [applyOrder, n, wake]);\n\n  const onDown = (e: PointerEvent<HTMLDivElement>, i: number) => {\n    if (order.current[0] !== i || S.current[i].phase === \"fly\") return;\n    const c = S.current[i];\n    const dr = drag.current;\n    dr.id = e.pointerId; dr.i = i; dr.sx = e.clientX - c.x; dr.sy = e.clientY - c.y; dr.dx = c.x; dr.dy = c.y; dr.t0 = performance.now();\n    dr.samples = [{ t: dr.t0, x: e.clientX, y: e.clientY }];\n    c.phase = \"drag\";\n    e.currentTarget.setPointerCapture(e.pointerId);\n    wake();\n  };\n  const onMove = (e: PointerEvent<HTMLDivElement>) => {\n    const dr = drag.current;\n    if (dr.id !== e.pointerId) return;\n    dr.dx = e.clientX - dr.sx; dr.dy = e.clientY - dr.sy;\n    const now = performance.now();\n    dr.samples.push({ t: now, x: e.clientX, y: e.clientY });\n    while (dr.samples.length > 2 && now - dr.samples[0].t > 90) dr.samples.shift();\n  };\n  const onUp = (e: PointerEvent<HTMLDivElement>) => {\n    const dr = drag.current;\n    if (dr.id !== e.pointerId) return;\n    dr.id = -1;\n    const c = S.current[dr.i];\n    if (!c) return;\n    const sm = dr.samples;\n    let vx = 0, vy = 0;\n    if (sm.length > 1) {\n      const dt = Math.max(0.016, (sm[sm.length - 1].t - sm[0].t) / 1000);\n      vx = (sm[sm.length - 1].x - sm[0].x) / dt; vy = (sm[sm.length - 1].y - sm[0].y) / dt;\n    }\n    const dist = Math.hypot(dr.dx, dr.dy), speed = Math.hypot(vx, vy);\n    const tap = dist < 6 && performance.now() - dr.t0 < 350;\n    const W = rootRef.current?.offsetWidth || 250;\n    const thr = Math.max(50, Math.min(cfg.current.threshold, W * 0.5));\n    if (tap) { c.phase = \"idle\"; shuffle(1000, -160); }\n    else if (dist > thr || (speed > 700 && dist > 24)) {\n      // La dirección sale de la velocidad del gesto; si fue lento, del desplazamiento.\n      const bx = speed > 400 ? vx : dr.dx, by = speed > 400 ? vy : dr.dy;\n      const len = Math.hypot(bx, by) || 1;\n      const sp = Math.max(speed, 1150);\n      shuffle((bx / len) * sp, (by / len) * sp);\n    } else { c.phase = \"idle\"; c.vx = vx * 0.1; c.vy = vy * 0.1; }\n    wake();\n  };\n\n  const onKey = (e: KeyboardEvent<HTMLDivElement>) => {\n    if (e.key === \"Enter\" || e.key === \" \" || e.key === \"ArrowRight\") { e.preventDefault(); shuffle(1000, -160); }\n    else if (e.key === \"ArrowLeft\") { e.preventDefault(); shuffle(-1000, -160); }\n  };\n\n  const vars = { \"--furin-card-stack-w\": `${width}px` } as CSSProperties;\n\n  return (\n    <div\n      ref={rootRef}\n      className={`furin-card-stack ${className}`}\n      style={{ ...vars, ...style }}\n      role=\"group\"\n      aria-roledescription=\"card deck\"\n      aria-label=\"Card deck. Drag the top card, or press Enter, to shuffle.\"\n      tabIndex={0}\n      onKeyDown={onKey}\n    >\n      {cards.map((c, i) => {\n        const p = pose(i, i);\n        return (\n          <div\n            key={i}\n            ref={(el) => { els.current[i] = el; }}\n            className=\"furin-card-stack__card\"\n            data-top={i === 0 ? \"true\" : \"false\"}\n            aria-hidden={i === 0 ? \"false\" : \"true\"}\n            style={{\n              zIndex: n - i,\n              transform: `translate3d(0, ${p.y}px, 0) rotate(${p.r}deg) scale(${p.s})`,\n              \"--furin-card-stack-from\": c.from ?? \"#5b6cff\",\n              \"--furin-card-stack-to\": c.to ?? \"#27c6ff\",\n              \"--furin-card-stack-dim\": p.d,\n            } as CSSProperties}\n            onPointerDown={(e) => onDown(e, i)}\n            onPointerMove={onMove}\n            onPointerUp={onUp}\n            onPointerCancel={onUp}\n          >\n            <span className=\"furin-card-stack__count\">{String(i + 1).padStart(2, \"0\")} / {String(n).padStart(2, \"0\")}</span>\n            <span className=\"furin-card-stack__dot\" />\n            <div className=\"furin-card-stack__text\">\n              <strong>{c.title}</strong>\n              {c.caption ? <span>{c.caption}</span> : null}\n            </div>\n            <i className=\"furin-card-stack__sheen\" />\n          </div>\n        );\n      })}\n      <span ref={live} className=\"furin-card-stack__live\" aria-live=\"polite\" />\n    </div>\n  );\n}\n\nexport default CardStack;\n",
      "type": "registry:component",
      "target": "components/furin/card-stack/CardStack.tsx"
    },
    {
      "path": "registry/card-stack/CardStack.css",
      "content": ".furin-card-stack {\n  position: relative;\n  width: var(--furin-card-stack-w);\n  max-width: calc(100% - 48px);\n  aspect-ratio: 3 / 4;\n  margin: 28px 0 0;\n  outline: none;\n  font-family: Geist, -apple-system, BlinkMacSystemFont, \"SF Pro Display\", system-ui, sans-serif;\n}\n.furin-card-stack:focus-visible { outline: 2px solid rgb(255 255 255 / 0.55); outline-offset: 22px; border-radius: 28px; }\n.furin-card-stack__card {\n  position: absolute;\n  inset: 0;\n  container-type: inline-size;\n  border-radius: 26px;\n  color: #fff;\n  cursor: grab;\n  touch-action: none;\n  user-select: none;\n  -webkit-user-select: none;\n  background:\n    radial-gradient(70% 50% at 82% 14%, rgb(255 255 255 / 0.34), transparent 62%),\n    radial-gradient(90% 60% at 10% 112%, rgb(0 0 0 / 0.28), transparent 60%),\n    linear-gradient(155deg, var(--furin-card-stack-from), var(--furin-card-stack-to));\n  box-shadow:\n    inset 0 1px 0 rgb(255 255 255 / 0.4),\n    inset 0 0 0 1px rgb(255 255 255 / 0.1),\n    0 2px 6px rgb(0 0 0 / 0.3),\n    0 26px 40px -18px rgb(0 0 0 / 0.65);\n}\n.furin-card-stack__card[data-top=\"false\"] { cursor: default; }\n.furin-card-stack__card:active[data-top=\"true\"] { cursor: grabbing; }\n/* Grano de papel: ruido fino sobre el color. */\n.furin-card-stack__card::before {\n  content: \"\";\n  position: absolute;\n  inset: 0;\n  border-radius: inherit;\n  opacity: 0.5;\n  mix-blend-mode: overlay;\n  pointer-events: none;\n  background: url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' seed='4'/><feColorMatrix values='0 0 0 0 1  0 0 0 0 1  0 0 0 0 1  0 0 0 0.9 -0.3'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>\") 0 0 / 160px;\n}\n/* Las tarjetas de abajo se oscurecen un poco: profundidad sin transparencias. */\n.furin-card-stack__card::after {\n  content: \"\";\n  position: absolute;\n  inset: 0;\n  border-radius: inherit;\n  background: #05060a;\n  opacity: var(--furin-card-stack-dim, 0);\n  pointer-events: none;\n}\n.furin-card-stack__count {\n  position: absolute;\n  left: 8cqw;\n  top: 8cqw;\n  font: 600 3.7cqw/1 \"JetBrains Mono\", ui-monospace, Menlo, monospace;\n  letter-spacing: 0.1em;\n  opacity: 0.8;\n}\n.furin-card-stack__dot {\n  position: absolute;\n  right: 8cqw;\n  top: 7.6cqw;\n  width: 5cqw;\n  height: 5cqw;\n  border-radius: 50%;\n  background: rgb(255 255 255 / 0.9);\n  box-shadow: 0 0 0 1.4cqw rgb(255 255 255 / 0.22);\n}\n.furin-card-stack__text {\n  position: absolute;\n  left: 8cqw;\n  right: 8cqw;\n  bottom: 8cqw;\n  display: grid;\n  gap: 2.4cqw;\n}\n.furin-card-stack__text strong { font: 700 12.5cqw/1 Geist, system-ui, sans-serif; letter-spacing: -0.035em; text-shadow: 0 0.4cqw 0.8cqw rgb(0 0 0 / 0.14); }\n.furin-card-stack__text span { font: 500 4.5cqw/1.35 Geist, system-ui, sans-serif; opacity: 0.85; }\n/* Reflejo que se desliza con el arrastre. */\n.furin-card-stack__sheen {\n  position: absolute;\n  inset: 0;\n  border-radius: inherit;\n  pointer-events: none;\n  opacity: calc(0.35 + var(--furin-card-stack-lift, 0) * 0.65);\n  background: linear-gradient(112deg, transparent 32%, rgb(255 255 255 / 0.3) 50%, transparent 68%) calc(50% - var(--furin-card-stack-sx, 0) * 120%) 0 / 260% 100%;\n  mix-blend-mode: soft-light;\n}\n.furin-card-stack__live { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n",
      "type": "registry:file",
      "target": "components/furin/card-stack/CardStack.css"
    }
  ]
}
