{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"swipe-card-stack","type":"registry:component","title":"Swipe Card Stack","description":"A Tinder-style deck on hand-written springs: the top card tilts with your drag, LIKE and NOPE stamps fade in with a wash of colour, the cards behind rise as you commit, and a flick throws it with your velocity. Buttons, arrow keys and undo, plus a finished end state.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/swipe-card-stack.tsx","content":"\"use client\";\n\n/*\n * SwipeCardStack: a deck you throw cards off, on hand-written springs.\n *\n * Drag the top card and it tilts with its own x (a little more when you hold it\n * high), a LIKE or NOPE stamp fades in with a wash of colour, and the cards\n * underneath rise toward the front as you commit. Let go past the threshold, or\n * flick it hard enough that it would clear the threshold, and it is thrown off\n * the stack carrying your velocity. Buttons, the arrow keys and Backspace do the\n * same jobs without a pointer. Undo flies the last card back; when the deck runs\n * out a finished end state offers to start over.\n */\n\nimport {\n  useEffect,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n  type RefObject,\n} from \"react\";\n\nexport type SwipeCard = {\n  id: string | number;\n  title: string;\n  subtitle?: string;\n  meta?: string;\n  tag?: string;\n  /** Hue (0-360) of the generated gradient artwork. */\n  hue?: number;\n};\n\nexport type SwipeDirection = \"left\" | \"right\";\n\nexport type SwipeCardStackProps = {\n  /** Distance in px a card must travel before letting go throws it. */\n  threshold?: number;\n  /** How many cards are rendered at once. The rest wait behind them. */\n  stackDepth?: number;\n  cards?: SwipeCard[];\n  /** Deal the deck again forever (default). Set false for a finite deck with an end state. */\n  loop?: boolean;\n  likeLabel?: string;\n  nopeLabel?: string;\n  onSwipe?: (card: SwipeCard, direction: SwipeDirection) => void;\n  onUndo?: (card: SwipeCard) => void;\n  /** Called once the last card has been thrown (never when `loop` is on). */\n  onEnd?: () => void;\n  /** Extra classes for the root. The default width is w-64. */\n  className?: string;\n};\n\nconst DEMO_CARDS: SwipeCard[] = [\n  { id: 1, title: \"Alder Cove\", subtitle: \"Pacific Northwest\", meta: \"3 nights from $420\", tag: \"Coast\", hue: 205 },\n  { id: 2, title: \"Kestrel Ridge\", subtitle: \"Dolomites, Italy\", meta: \"5 nights from $690\", tag: \"Alpine\", hue: 268 },\n  { id: 3, title: \"Saffron Quarter\", subtitle: \"Marrakesh\", meta: \"4 nights from $540\", tag: \"City\", hue: 24 },\n  { id: 4, title: \"Lumen Atoll\", subtitle: \"Maldives\", meta: \"6 nights from $1,180\", tag: \"Island\", hue: 168 },\n  { id: 5, title: \"Tidewater House\", subtitle: \"Cornwall\", meta: \"2 nights from $310\", tag: \"Retreat\", hue: 332 },\n  { id: 6, title: \"Obsidian Flats\", subtitle: \"Iceland\", meta: \"7 nights from $960\", tag: \"Wild\", hue: 152 },\n];\n\nconst LIKE = \"#34d399\";\nconst NOPE = \"#fb7185\";\nconst GRAIN =\n  \"url(\\\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='140' height='140'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>\\\")\";\n\n/* ---- Engine: one rAF loop that springs every mounted card ---------------- */\n\ntype Dir = 1 | -1;\n\ninterface Rec {\n  el: HTMLElement;\n  like: HTMLElement | null;\n  nope: HTMLElement | null;\n  likeWash: HTMLElement | null;\n  nopeWash: HTMLElement | null;\n  veil: HTMLElement | null;\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  /** Effective depth in the stack: 0 is the front. Springs toward the real depth. */\n  d: number;\n  vd: number;\n  /** Resting y of a thrown card (a little lift). */\n  lift: number;\n  out: 0 | Dir;\n  drag: boolean;\n  hidden: boolean;\n  /** Where on the card (0 top, 1 bottom) it was grabbed. */\n  gy: number;\n  px: number;\n  py: number;\n  x0: number;\n  y0: number;\n  sc: number;\n  pid: number;\n  samples: { t: number; x: number }[];\n}\n\ninterface Eng {\n  recs: Map<number, Rec>;\n  /** Direction each card was last thrown, kept so an undo can fly it back in. */\n  dirs: Map<number, Dir>;\n  log: Dir[];\n  idx: number;\n  n: number;\n  loop: boolean;\n  thr: number;\n  depth: number;\n  W: number;\n  hint: -1 | 0 | 1;\n  reduce: boolean;\n  ready: boolean;\n  deck: HTMLElement | null;\n  raf: number;\n  last: number;\n  wake: (() => void) | null;\n}\n\nconst clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));\nconst FAR_K = 2.2;\n\nfunction makeEng(n: number): Eng {\n  return {\n    recs: new Map(),\n    dirs: new Map(),\n    log: [],\n    idx: 0,\n    n,\n    loop: false,\n    thr: 120,\n    depth: 3,\n    W: 256,\n    hint: 0,\n    reduce: false,\n    ready: false,\n    deck: null,\n    raf: 0,\n    last: 0,\n    wake: null,\n  };\n}\n\n/** Advance every card by dt seconds and write the result to the DOM. Returns true while anything is still moving. */\nfunction step(e: Eng, dt: number): boolean {\n  const W = e.W;\n  const FAR = W * FAR_K;\n  const thr = e.thr;\n  let active = false;\n  const top = e.recs.get(e.idx);\n  const p = top ? clamp(Math.abs(top.x) / thr, 0, 1) : 0;\n  const sub = dt > 0 ? 2 : 0;\n  const h = dt / 2;\n  e.recs.forEach((r, i) => {\n    const depth = i - e.idx;\n    if (r.hidden && depth < 0) return;\n    if (r.hidden) {\n      r.hidden = false;\n      r.el.style.visibility = \"visible\";\n    }\n    let tx = 0;\n    let ty = 0;\n    let td = 0;\n    let k = 170;\n    let c = 22;\n    if (depth < 0) {\n      tx = r.out * FAR;\n      ty = r.lift;\n      k = 48;\n      c = 11.5;\n    } else {\n      if (depth === 0 && !r.drag) tx = e.hint * 14;\n      td = depth === 0 ? 0 : Math.max(0, depth - p);\n    }\n    if (e.reduce && !r.drag) {\n      r.x = depth < 0 ? r.out * W * 0.2 : tx;\n      r.y = depth < 0 ? 0 : ty;\n      r.d = td;\n      r.vx = r.vy = r.vd = 0;\n    } else {\n      for (let s = 0; s < sub; s++) {\n        if (!r.drag) {\n          r.vx += (-k * (r.x - tx) - c * r.vx) * h;\n          r.x += r.vx * h;\n          r.vy += (-k * (r.y - ty) - c * r.vy) * h;\n          r.y += r.vy * h;\n        }\n        r.vd += (-170 * (r.d - td) - 24 * r.vd) * h;\n        r.d += r.vd * h;\n      }\n      const rest =\n        !r.drag &&\n        Math.abs(r.x - tx) < 0.15 &&\n        Math.abs(r.vx) < 3 &&\n        Math.abs(r.y - ty) < 0.15 &&\n        Math.abs(r.vy) < 3 &&\n        Math.abs(r.d - td) < 0.002 &&\n        Math.abs(r.vd) < 0.03;\n      if (rest) {\n        r.x = tx;\n        r.y = ty;\n        r.d = td;\n        r.vx = r.vy = r.vd = 0;\n      } else active = true;\n    }\n    const rot = clamp((r.x / W) * 18 * (1 + (0.5 - r.gy) * 0.9), -34, 34);\n    const sc = 1 - 0.06 * r.d;\n    const st = r.el.style;\n    st.transform =\n      \"translate3d(\" + r.x.toFixed(2) + \"px,\" + (r.y - 26 * r.d).toFixed(2) + \"px,0) rotate(\" + rot.toFixed(3) + \"deg) scale(\" + sc.toFixed(4) + \")\";\n    const gone = depth < 0 && e.reduce;\n    st.transition = e.reduce ? \"opacity 200ms linear\" : \"none\";\n    st.opacity = gone ? \"0\" : String(clamp(e.depth - r.d, 0, 1));\n    if (r.veil) r.veil.style.opacity = String(clamp(r.d * 0.16, 0, 0.7));\n    const lv = depth <= 0 && !gone ? clamp((r.x - 0.12 * thr) / (0.7 * thr), 0, 1) : 0;\n    const nv = depth <= 0 && !gone ? clamp((-r.x - 0.12 * thr) / (0.7 * thr), 0, 1) : 0;\n    if (r.like) {\n      r.like.style.opacity = String(lv);\n      r.like.style.transform = \"rotate(-12deg) scale(\" + (0.82 + 0.18 * lv).toFixed(3) + \")\";\n    }\n    if (r.nope) {\n      r.nope.style.opacity = String(nv);\n      r.nope.style.transform = \"rotate(12deg) scale(\" + (0.82 + 0.18 * nv).toFixed(3) + \")\";\n    }\n    if (r.likeWash) r.likeWash.style.opacity = String(lv * 0.85);\n    if (r.nopeWash) r.nopeWash.style.opacity = String(nv * 0.85);\n    if (depth < 0 && Math.abs(r.x) > W * 1.5 && !r.drag) {\n      r.hidden = true;\n      st.visibility = \"hidden\";\n    }\n  });\n  return active;\n}\n\nfunction register(e: Eng, i: number, el: HTMLElement): Rec {\n  const q = (s: string) => el.querySelector<HTMLElement>('[data-ssc=\"' + s + '\"]');\n  const depth = i - e.idx;\n  const rec: Rec = {\n    el,\n    like: q(\"like\"),\n    nope: q(\"nope\"),\n    likeWash: q(\"like-wash\"),\n    nopeWash: q(\"nope-wash\"),\n    veil: q(\"veil\"),\n    x: 0,\n    y: 0,\n    vx: 0,\n    vy: 0,\n    d: e.ready && depth > 0 ? depth + 1 : Math.max(0, depth),\n    vd: 0,\n    lift: -26,\n    out: 0,\n    drag: false,\n    hidden: false,\n    gy: 0.5,\n    px: 0,\n    py: 0,\n    x0: 0,\n    y0: 0,\n    sc: 1,\n    pid: -1,\n    samples: [],\n  };\n  const dir = e.dirs.get(i);\n  if (depth < 0) {\n    rec.out = dir ?? 1;\n    rec.x = rec.out * e.W * FAR_K;\n    rec.y = rec.lift;\n  } else if (depth === 0 && e.ready && dir) {\n    rec.x = dir * e.W * FAR_K;\n    rec.y = -26;\n  }\n  e.recs.set(i, rec);\n  step(e, 0);\n  e.wake?.();\n  return rec;\n}\n\n/* ---- Hooks ---------------------------------------------------------------- */\n\nfunction subscribeMotion(cb: () => void) {\n  const m = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  m.addEventListener(\"change\", cb);\n  return () => m.removeEventListener(\"change\", cb);\n}\n\n/** True when the visitor asked for reduced motion. False on the server. */\nfunction useReducedMotion(): boolean {\n  return useSyncExternalStore(\n    subscribeMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* ---- Artwork --------------------------------------------------------------- */\n\nfunction artFor(hue: number): string {\n  const h = ((Math.round(hue) % 360) + 360) % 360;\n  return [\n    \"radial-gradient(120% 78% at 18% -4%, hsl(\" + h + \" 92% 64% / 0.95), transparent 58%)\",\n    \"radial-gradient(90% 70% at 96% 34%, hsl(\" + ((h + 52) % 360) + \" 88% 58% / 0.8), transparent 62%)\",\n    \"radial-gradient(110% 80% at 40% 104%, hsl(\" + ((h + 330) % 360) + \" 80% 30%), transparent 72%)\",\n    \"linear-gradient(162deg, hsl(\" + h + \" 58% 15%), hsl(\" + ((h + 28) % 360) + \" 52% 7%))\",\n  ].join(\",\");\n}\n\n/* ---- The card ----------------------------------------------------------- */\n\nfunction DeckCard({\n  i,\n  card,\n  engRef,\n  index,\n  position,\n  total,\n  likeLabel,\n  nopeLabel,\n  onToss,\n}: {\n  i: number;\n  card: SwipeCard;\n  engRef: RefObject<Eng | null>;\n  index: number;\n  position: number;\n  total: number;\n  likeLabel: string;\n  nopeLabel: string;\n  onToss: (i: number, dir: Dir, v: number) => void;\n}) {\n  const elRef = useRef<HTMLDivElement>(null);\n  const isTop = i === index;\n\n  useEffect(() => {\n    const e = engRef.current;\n    const el = elRef.current;\n    if (!e || !el) return;\n    register(e, i, el);\n    return () => {\n      e.recs.delete(i);\n    };\n  }, [i, engRef]);\n\n  function down(ev: ReactPointerEvent<HTMLDivElement>) {\n    const e = engRef.current;\n    if (!e || i !== e.idx) return;\n    if (ev.pointerType === \"mouse\" && ev.button !== 0) return;\n    const rec = e.recs.get(i);\n    if (!rec) return;\n    const deck = e.deck;\n    const rect = rec.el.getBoundingClientRect();\n    rec.sc = deck ? deck.offsetWidth / Math.max(1, deck.getBoundingClientRect().width) : 1;\n    rec.drag = true;\n    rec.px = ev.clientX;\n    rec.py = ev.clientY;\n    rec.x0 = rec.x;\n    rec.y0 = rec.y;\n    rec.vx = rec.vy = 0;\n    rec.gy = clamp((ev.clientY - rect.top) / Math.max(1, rect.height), 0.2, 0.8);\n    rec.pid = ev.pointerId;\n    rec.samples = [{ t: ev.timeStamp, x: rec.x }];\n    try {\n      rec.el.setPointerCapture(ev.pointerId);\n    } catch {\n      /* capture is a nicety */\n    }\n    e.wake?.();\n  }\n\n  function move(ev: ReactPointerEvent<HTMLDivElement>) {\n    const e = engRef.current;\n    const rec = e?.recs.get(i);\n    if (!e || !rec || !rec.drag || ev.pointerId !== rec.pid) return;\n    rec.x = rec.x0 + (ev.clientX - rec.px) * rec.sc;\n    rec.y = clamp(rec.y0 + (ev.clientY - rec.py) * rec.sc * 0.35, -70, 70);\n    rec.samples.push({ t: ev.timeStamp, x: rec.x });\n    while (rec.samples.length > 2 && ev.timeStamp - (rec.samples[0]?.t ?? 0) > 90) rec.samples.shift();\n    e.wake?.();\n  }\n\n  function end(ev: ReactPointerEvent<HTMLDivElement>, cancelled: boolean) {\n    const e = engRef.current;\n    const rec = e?.recs.get(i);\n    if (!e || !rec || !rec.drag || ev.pointerId !== rec.pid) return;\n    rec.drag = false;\n    try {\n      rec.el.releasePointerCapture(ev.pointerId);\n    } catch {\n      /* already released */\n    }\n    const a = rec.samples[0];\n    const b = rec.samples[rec.samples.length - 1];\n    let v = 0;\n    if (a && b && b.t > a.t) v = clamp(((b.x - a.x) / (b.t - a.t)) * 1000, -3200, 3200);\n    const proj = rec.x + v * 0.16;\n    if (!cancelled && Math.abs(proj) > e.thr) onToss(i, proj > 0 ? 1 : -1, v);\n    else rec.vx = clamp(v, -1400, 1400);\n    e.wake?.();\n  }\n\n  const hue = Number.isFinite(card.hue) ? (card.hue as number) : (i * 57 + 200) % 360;\n  const label = [card.title, card.subtitle, card.meta].filter(Boolean).join(\", \");\n\n  return (\n    <div\n      ref={elRef}\n      role={isTop ? \"group\" : undefined}\n      aria-roledescription={isTop ? \"card\" : undefined}\n      aria-label={isTop ? \"Card \" + position + \" of \" + total + \": \" + label : undefined}\n      aria-hidden={isTop ? undefined : true}\n      onPointerDown={down}\n      onPointerMove={move}\n      onPointerUp={(ev) => end(ev, false)}\n      onPointerCancel={(ev) => end(ev, true)}\n      className=\"absolute inset-0 select-none overflow-hidden rounded-[28px] will-change-transform\"\n      style={{\n        zIndex: i < index ? 60 + i : 40 - (i - index),\n        touchAction: \"pan-y\",\n        cursor: isTop ? \"grab\" : \"default\",\n        background: \"#0b0c0f\",\n        boxShadow: \"inset 0 0 0 1px rgba(255,255,255,0.1), 0 28px 50px -18px rgba(0,0,0,0.8), 0 6px 14px -6px rgba(0,0,0,0.6)\",\n      }}\n    >\n      <div aria-hidden className=\"absolute inset-0\" style={{ background: artFor(hue) }} />\n      <div\n        aria-hidden\n        className=\"absolute -right-10 top-8 h-44 w-44 rounded-full\"\n        style={{ border: \"1px solid rgba(255,255,255,0.22)\", boxShadow: \"0 0 0 22px rgba(255,255,255,0.04), 0 0 0 46px rgba(255,255,255,0.025)\" }}\n      />\n      <div aria-hidden className=\"absolute inset-0 opacity-[0.07] mix-blend-overlay\" style={{ backgroundImage: GRAIN }} />\n      <div\n        aria-hidden\n        className=\"absolute inset-x-0 bottom-0 h-[62%]\"\n        style={{ background: \"linear-gradient(to top, rgba(5,6,8,0.94) 8%, rgba(5,6,8,0.55) 55%, rgba(5,6,8,0))\" }}\n      />\n      <div className=\"absolute inset-x-0 bottom-0 flex flex-col gap-1 p-5\">\n        {card.tag ? (\n          <span\n            className=\"mb-1.5 w-fit rounded-full px-2.5 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-white/85 backdrop-blur-md\"\n            style={{ background: \"rgba(255,255,255,0.12)\", border: \"1px solid rgba(255,255,255,0.16)\" }}\n          >\n            {card.tag}\n          </span>\n        ) : null}\n        <span className=\"text-[22px] font-semibold leading-[1.1] tracking-tight text-white\">{card.title}</span>\n        {card.subtitle ? <span className=\"text-[13px] text-white/65\">{card.subtitle}</span> : null}\n        {card.meta ? (\n          <span className=\"mt-2.5 pt-2.5 text-[11px] uppercase tracking-[0.14em] text-white/45\" style={{ borderTop: \"1px solid rgba(255,255,255,0.12)\" }}>\n            {card.meta}\n          </span>\n        ) : null}\n      </div>\n      <div\n        aria-hidden\n        data-ssc=\"like-wash\"\n        className=\"pointer-events-none absolute inset-0 opacity-0\"\n        style={{ background: \"linear-gradient(100deg, rgba(52,211,153,0.5), rgba(52,211,153,0) 62%)\" }}\n      />\n      <div\n        aria-hidden\n        data-ssc=\"nope-wash\"\n        className=\"pointer-events-none absolute inset-0 opacity-0\"\n        style={{ background: \"linear-gradient(260deg, rgba(251,113,133,0.5), rgba(251,113,133,0) 62%)\" }}\n      />\n      <div aria-hidden data-ssc=\"veil\" className=\"pointer-events-none absolute inset-0 opacity-0\" style={{ background: \"#050608\" }} />\n      <span\n        aria-hidden\n        data-ssc=\"like\"\n        className=\"pointer-events-none absolute left-5 top-6 rounded-lg px-2.5 py-0.5 text-[22px] font-extrabold uppercase tracking-[0.1em] opacity-0\"\n        style={{ color: LIKE, border: \"3px solid \" + LIKE, textShadow: \"0 0 18px rgba(52,211,153,0.6)\", boxShadow: \"0 0 24px rgba(52,211,153,0.25)\" }}\n      >\n        {likeLabel}\n      </span>\n      <span\n        aria-hidden\n        data-ssc=\"nope\"\n        className=\"pointer-events-none absolute right-5 top-6 rounded-lg px-2.5 py-0.5 text-[22px] font-extrabold uppercase tracking-[0.1em] opacity-0\"\n        style={{ color: NOPE, border: \"3px solid \" + NOPE, textShadow: \"0 0 18px rgba(251,113,133,0.6)\", boxShadow: \"0 0 24px rgba(251,113,133,0.25)\" }}\n      >\n        {nopeLabel}\n      </span>\n    </div>\n  );\n}\n\n/* ---- Round buttons -------------------------------------------------------- */\n\nfunction RoundButton({\n  label,\n  color,\n  size,\n  disabled,\n  onClick,\n  onHint,\n  children,\n}: {\n  label: string;\n  color: string;\n  size: number;\n  disabled: boolean;\n  onClick: () => void;\n  onHint?: (on: boolean) => void;\n  children: ReactNode;\n}) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      disabled={disabled}\n      onClick={onClick}\n      onPointerEnter={(e) => e.pointerType === \"mouse\" && onHint?.(true)}\n      onPointerLeave={() => onHint?.(false)}\n      onFocus={(e) => e.currentTarget.matches(\":focus-visible\") && onHint?.(true)}\n      onBlur={() => onHint?.(false)}\n      className=\"grid shrink-0 place-items-center rounded-full bg-[#14161b] transition-[transform,background-color,box-shadow,opacity] duration-150 hover:bg-[#1e2128] active:scale-90 disabled:pointer-events-none disabled:opacity-30 motion-reduce:transition-none focus-visible:!rounded-full focus-visible:!outline-2 focus-visible:!outline-offset-2 focus-visible:!outline-[#7c8cff]\"\n      style={{\n        width: size,\n        height: size,\n        color,\n        border: \"1px solid rgba(255,255,255,0.1)\",\n        boxShadow: \"0 10px 24px -10px rgba(0,0,0,0.8), inset 0 1px 0 rgba(255,255,255,0.06)\",\n      }}\n    >\n      {children}\n    </button>\n  );\n}\n\nconst ICON = { width: 22, height: 22, viewBox: \"0 0 24 24\", fill: \"none\", stroke: \"currentColor\", strokeWidth: 2.2, strokeLinecap: \"round\", strokeLinejoin: \"round\", \"aria-hidden\": true } as const;\n\n/* ---- The component -------------------------------------------------------- */\n\nexport function SwipeCardStack({\n  threshold = 120,\n  stackDepth = 3,\n  cards = DEMO_CARDS,\n  loop = true,\n  likeLabel = \"LIKE\",\n  nopeLabel = \"NOPE\",\n  onSwipe,\n  onUndo,\n  onEnd,\n  className = \"\",\n}: SwipeCardStackProps) {\n  const reduce = useReducedMotion();\n  const engRef = useRef<Eng | null>(null);\n  if (engRef.current === null) engRef.current = makeEng(cards.length);\n  const deckRef = useRef<HTMLDivElement>(null);\n  const [view, setView] = useState({ index: 0, liked: 0, passed: 0, msg: \"\" });\n\n  const n = cards.length;\n  const depthN = clamp(Math.round(stackDepth) || 1, 1, 6);\n  const canLoop = loop && n > 0;\n\n  useEffect(() => {\n    const e = engRef.current;\n    if (!e) return;\n    e.thr = Math.max(20, threshold);\n    e.depth = depthN;\n    e.loop = canLoop;\n    e.n = n;\n    e.reduce = reduce;\n    e.wake?.();\n  }, [threshold, depthN, canLoop, n, reduce]);\n\n  useEffect(() => {\n    const e = engRef.current;\n    const deck = deckRef.current;\n    if (!e || !deck) return;\n    e.deck = deck;\n    e.ready = true;\n    e.W = deck.offsetWidth || 256;\n    const tick = (t: number) => {\n      e.raf = 0;\n      const dt = e.last ? Math.min(0.034, (t - e.last) / 1000) : 0.016;\n      e.last = t;\n      if (step(e, dt)) e.raf = requestAnimationFrame(tick);\n      else e.last = 0;\n    };\n    e.wake = () => {\n      if (!e.raf) e.raf = requestAnimationFrame(tick);\n    };\n    const ro = new ResizeObserver(() => {\n      e.W = deck.offsetWidth || e.W;\n      e.wake?.();\n    });\n    ro.observe(deck);\n    e.wake();\n    return () => {\n      ro.disconnect();\n      if (e.raf) cancelAnimationFrame(e.raf);\n      e.raf = 0;\n      e.last = 0;\n      e.wake = null;\n      e.ready = false;\n    };\n  }, []);\n\n  function cardAt(i: number): SwipeCard | undefined {\n    return n === 0 ? undefined : cards[((i % n) + n) % n];\n  }\n\n  function sync(msg: string) {\n    const e = engRef.current;\n    if (!e) return;\n    let liked = 0;\n    for (const d of e.log) if (d === 1) liked++;\n    setView({ index: e.idx, liked, passed: e.log.length - liked, msg });\n  }\n\n  function toss(i: number, dir: Dir, v: number) {\n    const e = engRef.current;\n    if (!e || i !== e.idx || (!canLoop && i >= n)) return;\n    const card = cardAt(i);\n    const rec = e.recs.get(i);\n    if (rec) {\n      rec.out = dir;\n      rec.drag = false;\n      rec.vx = dir * Math.max(Math.abs(v), 700);\n      rec.lift = clamp(rec.y, -80, 80) - 26;\n    }\n    e.dirs.set(i, dir);\n    e.log.push(dir);\n    e.idx = i + 1;\n    e.wake?.();\n    const left = canLoop ? -1 : n - e.idx;\n    const t = card?.title ?? \"Card\";\n    sync((dir === 1 ? \"Liked \" : \"Passed on \") + t + (left >= 0 ? \". \" + left + (left === 1 ? \" card left.\" : \" cards left.\") : \".\"));\n    if (card) onSwipe?.(card, dir === 1 ? \"right\" : \"left\");\n    if (!canLoop && e.idx >= n) onEnd?.();\n  }\n\n  function swipe(dir: Dir) {\n    const e = engRef.current;\n    if (e) toss(e.idx, dir, 0);\n  }\n\n  function undo() {\n    const e = engRef.current;\n    if (!e || e.idx <= 0) return;\n    e.log.pop();\n    e.idx -= 1;\n    const rec = e.recs.get(e.idx);\n    if (rec) {\n      rec.out = 0;\n      rec.lift = -26;\n    }\n    e.wake?.();\n    const card = cardAt(e.idx);\n    sync(\"Undid \" + (card?.title ?? \"last card\") + \".\");\n    if (card) onUndo?.(card);\n  }\n\n  function restart() {\n    const e = engRef.current;\n    if (!e) return;\n    e.log = [];\n    e.dirs.clear();\n    e.idx = 0;\n    e.recs.forEach((r) => {\n      r.out = 0;\n      r.lift = -26;\n    });\n    e.wake?.();\n    sync(\"Deck restarted.\");\n  }\n\n  function hint(d: -1 | 1, on: boolean) {\n    const e = engRef.current;\n    if (!e) return;\n    e.hint = on ? d : e.hint === d ? 0 : e.hint;\n    e.wake?.();\n  }\n\n  function onKey(ev: ReactKeyboardEvent<HTMLDivElement>) {\n    if (ev.target !== ev.currentTarget || ev.altKey) return;\n    if (ev.key === \"ArrowRight\") swipe(1);\n    else if (ev.key === \"ArrowLeft\") swipe(-1);\n    else if (ev.key === \"Backspace\" || ev.key === \"ArrowUp\" || ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === \"z\")) undo();\n    else return;\n    ev.preventDefault();\n  }\n\n  const ended = !canLoop && view.index >= n;\n  const lo = Math.max(0, view.index - 3);\n  const hi = canLoop ? view.index + depthN : Math.min(n, view.index + depthN);\n  const slots: number[] = [];\n  for (let i = lo; i < hi; i++) slots.push(i);\n  const topCard = cardAt(view.index);\n  const endRef = useRef<HTMLDivElement>(null);\n  useEffect(() => {\n    endRef.current?.toggleAttribute(\"inert\", !ended);\n  }, [ended]);\n\n  return (\n    <div className={\"relative w-64 max-w-full select-none pt-9 \" + className}>\n      <div\n        ref={deckRef}\n        tabIndex={0}\n        role=\"group\"\n        aria-roledescription=\"card stack\"\n        aria-label={\n          ended\n            ? \"Card stack, all \" + n + \" cards reviewed.\"\n            : \"Card stack, card \" + Math.min(view.index + 1, n) + \" of \" + n + \". Left and right arrow keys pass or like, Backspace undoes.\"\n        }\n        aria-keyshortcuts=\"ArrowLeft ArrowRight Backspace\"\n        onKeyDown={onKey}\n        className=\"relative aspect-[4/5] w-full rounded-[28px] outline-none focus-visible:!rounded-[28px] focus-visible:!outline-2 focus-visible:!outline-offset-[6px] focus-visible:!outline-[#7c8cff]\"\n      >\n        {slots.map((i) => {\n          const card = cardAt(i);\n          if (!card) return null;\n          return (\n            <DeckCard\n              key={i}\n              i={i}\n              card={card}\n              engRef={engRef}\n              index={view.index}\n              position={(i % Math.max(1, n)) + 1}\n              total={n}\n              likeLabel={likeLabel}\n              nopeLabel={nopeLabel}\n              onToss={toss}\n            />\n          );\n        })}\n        <div\n          ref={endRef}\n          aria-hidden={!ended}\n          className=\"absolute inset-0 z-[70] flex flex-col items-center justify-center gap-3 rounded-[28px] px-6 text-center transition-[opacity,transform] duration-500 motion-reduce:transition-none\"\n          style={{\n            opacity: ended ? 1 : 0,\n            transform: ended ? \"scale(1)\" : \"scale(0.94)\",\n            pointerEvents: ended ? \"auto\" : \"none\",\n            background: \"radial-gradient(100% 70% at 50% 0%, rgba(255,255,255,0.07), rgba(255,255,255,0) 70%), #0b0c0f\",\n            boxShadow: \"inset 0 0 0 1px rgba(255,255,255,0.1), 0 28px 50px -18px rgba(0,0,0,0.8)\",\n            transitionTimingFunction: \"cubic-bezier(0.22, 1, 0.36, 1)\",\n          }}\n        >\n          <svg width=\"64\" height=\"64\" viewBox=\"0 0 64 64\" fill=\"none\" aria-hidden>\n            <circle cx=\"32\" cy=\"32\" r=\"30\" stroke=\"rgba(255,255,255,0.14)\" />\n            <circle cx=\"32\" cy=\"32\" r=\"21\" stroke=\"rgba(255,255,255,0.22)\" strokeDasharray=\"3 5\" />\n            <path d=\"M23 33.5l7 7 12-14\" stroke={LIKE} strokeWidth=\"3\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n          </svg>\n          <p className=\"text-[20px] font-semibold tracking-tight text-white\">All caught up</p>\n          <p className=\"text-[13px] leading-snug text-white/55\">\n            {view.liked} liked, {view.passed} passed.\n            <br />\n            That was the whole deck.\n          </p>\n          <button\n            type=\"button\"\n            onClick={restart}\n            className=\"mt-1 min-h-10 rounded-full bg-white px-5 text-[13px] font-medium text-black transition-[transform,background-color] duration-150 hover:bg-white/90 active:scale-95 motion-reduce:transition-none focus-visible:!rounded-full focus-visible:!outline-2 focus-visible:!outline-offset-2 focus-visible:!outline-[#7c8cff]\"\n          >\n            Start over\n          </button>\n        </div>\n      </div>\n\n      <div className=\"mt-9 flex items-center justify-center gap-5\">\n        <RoundButton\n          label={\"Pass on \" + (topCard?.title ?? \"card\")}\n          color={NOPE}\n          size={56}\n          disabled={ended}\n          onClick={() => swipe(-1)}\n          onHint={(on) => hint(-1, on)}\n        >\n          <svg {...ICON}>\n            <path d=\"M6 6l12 12M18 6L6 18\" />\n          </svg>\n        </RoundButton>\n        <RoundButton label=\"Undo last swipe\" color=\"rgba(255,255,255,0.7)\" size={44} disabled={view.index === 0} onClick={undo}>\n          <svg {...ICON} width={18} height={18}>\n            <path d=\"M9 14L4 9l5-5\" />\n            <path d=\"M4 9h10a6 6 0 010 12h-3\" />\n          </svg>\n        </RoundButton>\n        <RoundButton\n          label={\"Like \" + (topCard?.title ?? \"card\")}\n          color={LIKE}\n          size={56}\n          disabled={ended}\n          onClick={() => swipe(1)}\n          onHint={(on) => hint(1, on)}\n        >\n          <svg {...ICON}>\n            <path d=\"M12 20s-7-4.4-7-10a4 4 0 017-2.6A4 4 0 0119 10c0 5.6-7 10-7 10z\" />\n          </svg>\n        </RoundButton>\n      </div>\n      <div aria-live=\"polite\" role=\"status\" className=\"sr-only\">\n        {view.msg}\n      </div>\n    </div>\n  );\n}\n","type":"registry:component","target":"components/viberdy/swipe-card-stack.tsx"}],"meta":{"tier":"free","tags":["drag","swipe","stack","gesture","cards","tinder","spring"],"href":"https://viberdy.dev/library/swipe-card-stack","aiPrompt":"Create a React 19 + Tailwind v4 + TypeScript \"use client\" component named SwipeCardStack in one file. No dependencies (no framer-motion, no gesture library), inline SVG icons. It paints its own surfaces (cards #0b0c0f, 1px inset hairline rgba(255,255,255,0.1) via box-shadow, radius 28px).\n\nProps (all optional): threshold=120 (px a card must travel before release throws it), stackDepth=3 (cards rendered at once, clamp 1-6), cards (default 6 demo destination cards {id,title,subtitle,meta,tag,hue}), loop=true (deal forever; false ends the deck with the end state), likeLabel=\"LIKE\", nopeLabel=\"NOPE\", onSwipe(card, \"left\"|\"right\"), onUndo(card), onEnd(), className (root default w-64 max-w-full).\n\nLayout: a deck box with aspect-ratio 4/5 holding absolutely positioned cards, then 36px below a row of three round buttons (Pass 56px rose #fb7185 X icon, Undo 44px neutral counter-clockwise arrow, Like 56px emerald #34d399 heart), gap 20px, plus an sr-only aria-live status line. Card artwork is generated from a hue h: four stacked backgrounds - radial hsl(h 92% 64% / .95) at 18% -4%, radial hsl(h+52 88% 58% / .8) at 96% 34%, radial hsl(h-30 80% 30%) at 40% 104%, and a 162deg linear hsl(h 58% 15%) to hsl(h+28 52% 7%); a thin ring with two soft halos top right, grain at 7% overlay, and a bottom scrim (to top, rgba(5,6,8,.94) to transparent over 62% height). Text sits at the bottom with 20px padding: a glass tag pill (10px uppercase, backdrop blur), title 22px/600 white, subtitle 13px white/65, a hairline then meta 11px uppercase white/45.\n\nPhysics (one requestAnimationFrame loop for every mounted card, semi-implicit Euler, 2 substeps, dt clamped to 34ms, sleeps when everything is at rest): each card has x, y and an effective depth d, each spring-driven. Settling: stiffness 170, damping 22. Throw: target x = +-2.2 * deck width, stiffness 48, damping 11.5, initial vx = dir * max(|release velocity|, 700) px/s, resting y lifts 26px. Depth: stiffness 170, damping 24. Card transform = translate3d(x, y - 26*d, 0) rotate(r) scale(1 - 0.06*d), where r = clamp(x/width * 18 * (1 + (0.5 - gripY) * 0.9), +-34) degrees (gripY is where on the card it was grabbed, 0.2-0.8). Opacity = clamp(stackDepth - d, 0, 1); a black veil at d*0.16 (max .7) darkens deeper cards. The card at depth k targets d = k - p, where p = min(1, |topCard.x| / threshold), so the cards behind rise as you drag. A newly mounted card enters at d + 1 and rises. Thrown cards are kept mounted (last 3) so undo flies them back; they are hidden once |x| > 1.5 * width.\n\nDrag: pointer events on the top card only (touch-action pan-y so vertical scroll still works, setPointerCapture), map movement to layout pixels with deck.offsetWidth / deck.getBoundingClientRect().width, y follows at 0.35 clamped to +-70. Keep the last ~90ms of x samples. On release project x + v*0.16 s; if its magnitude exceeds threshold, throw in that direction, otherwise spring home carrying the velocity (clamped 1400). pointercancel never throws.\n\nStamps: LIKE (top-left, rotated -12deg, 3px border and text #34d399 with glow) and NOPE (top-right, +12deg, #fb7185); opacity = clamp((|x| - 0.12*threshold) / (0.7*threshold)) for their side, scale 0.82 to 1; plus a full-card side wash (100deg gradient of the colour at .5 alpha fading to transparent by 62%) at 0.85 of that opacity.\n\nControls: buttons throw the top card (v=0 so the 700px/s minimum applies); hovering (mouse) or keyboard-focusing Pass/Like leans the top card 14px toward that side. Keyboard on the focusable deck (tabIndex 0, role group, aria-roledescription \"card stack\"): ArrowLeft passes, ArrowRight likes, Backspace / ArrowUp / Ctrl-or-Cmd+Z undoes. Undo pops the last throw and springs that card back to center. Keep a log of directions for liked/passed counts.\n\nEnd state (only when loop is false and every card is thrown): an overlay card fills the deck, scale .94 to 1 and fade over 500ms cubic-bezier(.22,1,.36,1): ringed check glyph, \"All caught up\" 20px/600, \"N liked, M passed. That was the whole deck.\", and a white \"Start over\" pill (min-height 40px) that deals the deck again. Inert while hidden (toggleAttribute in an effect). onEnd fires when the last card is thrown.\n\nAccessibility: only the top card is exposed (role group, roledescription card, label \"Card 2 of 6: title, subtitle, meta\"); others aria-hidden. Buttons have labels naming the card (\"Pass on Alder Cove\"). A polite live region announces every swipe, undo and restart with cards left. Focus rings are written as important utilities (focus-visible:!outline-2 focus-visible:!outline-offset-2 focus-visible:!outline-[#7c8cff]) and border colours are inline, because host pages may have unlayered global rules. Buttons are 44-56px.\n\nReduced motion (matchMedia via useSyncExternalStore): no springs - settled positions apply instantly, a swiped card fades out over 200ms, dragging still follows the pointer. Fallbacks: nothing needs WebGL; with no JS the cards stack unpositioned.","license":"https://viberdy.dev/license"}}