{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "carousel-card-riffle",
  "title": "Carousel Card Riffle",
  "description": "A card-stack navigator that shows its actual card edges as a scrubbable stripe of thin lines — dragging it riffles cards past with quick flip-past kicks, and the same stripe doubles as the pagination readout.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/carousel-card-riffle/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// RiffleEdge — a card-stack navigator whose pagination indicator IS the scrub\n// control. The stack shows its actual card edges as a stripe of thin lines\n// (one 1px --border line per card, the current one recolored --foreground),\n// running down the side of the top card like the exposed page-edge of a\n// book. Dragging that stripe riffles cards past: each step change kicks the\n// top card with a 220ms rotateY(5deg) + translateX(11px) flip-past (deter-\n// ministic per-index vertical jitter, so no two steps land identically),\n// settling on a smooth ease-out with no overshoot — release settles the\n// arrival with the same kick. The two decorative depth layers behind the\n// top card (its permanently-visible stacked-thickness read) kick too, on\n// the same commit, at a subtler amplitude (9px/3deg then 5px/2deg, reduced\n// with depth): an opposite-leaning translate+rotate that eases back to\n// their resting offset a beat after the top card, each layer lagging the\n// one in front of it — so a step reads as the whole deck visibly\n// reshuffling and restacking, cleanly, not a subtle 3px nudge and not a\n// glitchy overshoot. Scrub\n// velocity governs which: slow drags step discretely (a kick per card), fast\n// drags blur the top card through a CSS filter transition instead of\n// chattering through kicks — the exposed edge itself carries both roles at\n// once, never a separate dot-row or progress bar.\n//\n// Direct DOM writes (transform/filter) on committed index changes only — no\n// per-frame rAF loop. Motion is entirely CSS-transition-driven: a one-off\n// double-write (snap to a kicked pose, then rAF back to identity/rest so the\n// transition eases the return) for the flip and the two back-layer nudges,\n// and a filter transition ramped by the caller for blur. The back layers'\n// resting offset is expressed in rem (matching their Tailwind translate\n// utility at rest) with the kick's extra nudge layered on top via calc(), so\n// the shuffle scales with root font size the same way the static pose\n// already did. `prefers-reduced-motion` drops all of it: no rotateY, no\n// perspective, no back-layer nudge, no blur ever — steps crossfade via\n// opacity instead.\n//\n// The stripe's *drawn* thickness is deliberately compressed (down to a\n// 1.5px-per-card pitch) so a small deck visibly reads thinner than a large\n// one — but the drag range is NOT tied to that compressed thickness: it's\n// a `travel` distance matched to the card's own measured height, a\n// full-height rail beside the card rather than a sliver a couple of\n// cards wide. Wheel/trackpad scroll over the stripe steps one card per\n// tick too (accumulated deltaY, cooldown-gated so a single fling can't\n// blow through several kicks at once) — dragging, wheeling and the\n// keyboard all land on the same commit+kick path.\n//\n// A11y: root is role=\"group\" (aria-label + a nested aria-live=\"polite\" span\n// announcing \"Card N of total\" on every committed change); the stripe itself\n// is role=\"slider\" (vertical, aria-valuemax = count-1) and owns the keyboard\n// — ArrowLeft/Right and PageUp/PageDown step one card, Home/End jump to the\n// ends. Differs from gallery-coverflow-caustic (lateral drag-through of large cover\n// art, momentum + chromatic aberration, browsing) and from drill-down-spines\n// (levels compress into read-at-rest spines you click to pop back to):\n// carousel-card-riffle has exactly one visible card, and the \"how many / where am I\"\n// readout is the scrubbable thickness of the stack itself, not a row of\n// static dots or a shelf of resting spines. DOM/CSS only, no canvas.\n// ---------------------------------------------------------------------------\n\nconst NATURAL_PITCH = 3; // px: 1px line + 2px gap, the \"natural\" edge rhythm\nconst MIN_PITCH = 1.5;\nconst STRIPE_PAD = 16; // px hit-area padding above/below the drawn lines\nconst KICK_MS = 220;\n// phase 1 of every kick: the card visibly travels OUT to the kicked pose\n// before settling back — without this the pose was snapped on instantly and\n// the viewer only ever saw the settle half, so no shuffle read at all\nconst KICK_OUT_MS = 130;\nconst KICK_OUT_EASE = \"cubic-bezier(0.25, 0.46, 0.45, 0.94)\"; // plain ease-out, no overshoot\nconst CROSSFADE_MS = 120;\nconst BLUR_TRANSITION_MS = 140;\nconst MAX_BLUR = 5;\nconst FAST_VELOCITY = 14; // idx/s — at or above this, blur-through instead of a kick\nconst VELOCITY_FOR_MAX_BLUR = 46; // idx/s mapped to MAX_BLUR\nconst KICK_ROTATE_DEG = 7;\nconst KICK_TRANSLATE_X = 15;\n// clean settle: smooth ease-out with no bounce/overshoot past identity/rest,\n// so the return half of every kick reads as a deliberate riffle rather than\n// a springy glitch — applied to the top card's flip-past and both back\n// layers' restack alike\nconst SETTLE_EASE = \"cubic-bezier(0.22, 1, 0.36, 1)\";\nconst PERSPECTIVE_PX = 720;\n// decorative back-layer rest offsets, in rem — matches the original\n// translate-x-1.5/translate-y-1.5 and translate-x-3/translate-y-3 Tailwind\n// utilities exactly (1 spacing unit = 0.25rem), so switching them to\n// JS-driven inline transforms doesn't change their resting appearance.\nconst BACK1_REST_REM = 0.375;\nconst BACK2_REST_REM = 0.75;\n// shuffle nudge applied on top of the rest offset during a kick — smaller\n// than the top card's own kick, and smaller again for the second layer, so\n// depth reads as reduced amplitude the further back a layer sits. Scaled\n// down proportionally with the top card's own kick so the whole deck still\n// visibly cascades on every step, without reading as a broken animation.\nconst BACK1_SHIFT_PX = 9;\nconst BACK2_SHIFT_PX = 5;\nconst BACK1_ROTATE_DEG = 3;\nconst BACK2_ROTATE_DEG = 2;\nconst BACK_STAGGER_MS = 90; // each back layer settles this much later than the one in front\nconst WHEEL_STEP_PX = 36; // deltaY accumulated before a wheel/trackpad tick steps a card\nconst WHEEL_COOLDOWN_MS = 360; // >= KICK_OUT_MS + KICK_MS, so each step's out-and-back reads before the next fires\n\nfunction clamp(v: number, lo: number, hi: number) {\n  return Math.min(hi, Math.max(lo, v));\n}\n\n// deterministic per-index jitter (px), so each card's flip-past reads as a\n// distinct sheet of paper rather than a repeating mechanical tick\nfunction jitterFor(i: number) {\n  const s = Math.sin(i * 12.9898) * 43758.5453;\n  const frac = s - Math.floor(s);\n  return (frac - 0.5) * 4; // -2..2px\n}\n\nexport interface RiffleEdgeItem {\n  /** stable id, also the React key */\n  id: string;\n  /** small mono eyebrow above the title (category, step kind, sender…) */\n  eyebrow?: string;\n  title: string;\n  description?: string;\n}\n\nexport interface RiffleEdgeProps {\n  /** the cards, in order */\n  items: RiffleEdgeItem[];\n  /** controlled current index; omit for uncontrolled */\n  index?: number;\n  /** uncontrolled initial index. Default 0. */\n  defaultIndex?: number;\n  /** called with the new index after a swipe/click/keyboard change */\n  onIndexChange?: (index: number) => void;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n  /** accessible name for the enclosing group */\n  \"aria-label\"?: string;\n}\n\nexport function RiffleEdge({\n  items,\n  index,\n  defaultIndex = 0,\n  onIndexChange,\n  className = \"\",\n  \"aria-label\": ariaLabel = \"Card stack\",\n}: RiffleEdgeProps) {\n  const count = Math.max(1, items.length);\n  const clampIndex = (v: number) => clamp(Math.round(v), 0, count - 1);\n\n  const isControlled = index !== undefined;\n  const [internal, setInternal] = useState(() => clampIndex(defaultIndex));\n  const current = isControlled ? clampIndex(index as number) : internal;\n\n  // mirrors `current` for synchronous reads inside pointer/keyboard handlers,\n  // where React state hasn't re-rendered yet between rapid successive events\n  const posRef = useRef(current);\n  useEffect(() => {\n    posRef.current = current;\n  }, [current]);\n\n  const [cardHeight, setCardHeight] = useState(0);\n  const [reducedMotion, setReducedMotion] = useState(false);\n\n  const stripeRef = useRef<HTMLDivElement>(null);\n  const cardRef = useRef<HTMLDivElement>(null);\n  const back1Ref = useRef<HTMLDivElement>(null);\n  const back2Ref = useRef<HTMLDivElement>(null);\n  const liveRef = useRef<HTMLSpanElement>(null);\n\n  const dragRef = useRef<{\n    pointerId: number;\n    lastRaw: number;\n    lastT: number;\n    vel: number;\n    lastDir: number;\n  } | null>(null);\n  const wheelRef = useRef({ accum: 0, lastT: 0 });\n  const settleTimerRef = useRef<number | null>(null);\n  useEffect(\n    () => () => {\n      if (settleTimerRef.current !== null) window.clearTimeout(settleTimerRef.current);\n    },\n    []\n  );\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    setReducedMotion(mq.matches);\n    const onChange = () => setReducedMotion(mq.matches);\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n\n  // the natural pitch shrinks only if the deck's own thickness would\n  // otherwise outgrow the top card's rendered height — measured on the\n  // card, not the stripe (the stripe's size is DERIVED from the pitch, so\n  // measuring itself would be circular)\n  useEffect(() => {\n    const el = cardRef.current;\n    if (!el) return;\n    setCardHeight(el.getBoundingClientRect().height);\n    const ro = new ResizeObserver((entries) => {\n      const h = entries[0]?.contentRect.height;\n      if (h) setCardHeight(h);\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  // aria-live announce on every committed change (not on raw drag position)\n  useEffect(() => {\n    if (liveRef.current) liveRef.current.textContent = `Card ${current + 1} of ${count}`;\n  }, [current, count]);\n\n  const commit = (v: number) => {\n    if (!isControlled) setInternal(v);\n    posRef.current = v;\n    onIndexChange?.(v);\n  };\n\n  const kick = (dir: number, targetIndex: number) => {\n    const card = cardRef.current;\n    if (!card) return;\n    if (reducedMotion) {\n      card.style.transition = \"none\";\n      card.style.opacity = \"0.4\";\n      requestAnimationFrame(() => {\n        card.style.transition = `opacity ${CROSSFADE_MS}ms ease-out`;\n        card.style.opacity = \"1\";\n      });\n      return;\n    }\n    const j = jitterFor(targetIndex);\n    const j1 = jitterFor(targetIndex + 1) * 0.5;\n    const j2 = jitterFor(targetIndex + 2) * 0.3;\n    const back1 = back1Ref.current;\n    const back2 = back2Ref.current;\n    if (settleTimerRef.current !== null) window.clearTimeout(settleTimerRef.current);\n\n    // phase 1: animate OUT to the kicked pose — the visible half of the\n    // shuffle. The deck's decorative depth layers travel with the top card,\n    // a smaller opposite-leaning nudge per layer (reduced amplitude with\n    // depth), so a step reads as the whole stack restacking rather than\n    // only the top card swapping.\n    card.style.transition = `transform ${KICK_OUT_MS}ms ${KICK_OUT_EASE}`;\n    card.style.transform = `perspective(${PERSPECTIVE_PX}px) rotateY(${dir * KICK_ROTATE_DEG}deg) translateX(${(-dir * KICK_TRANSLATE_X).toFixed(2)}px) translateY(${j.toFixed(2)}px)`;\n    if (back1) {\n      back1.style.transition = `transform ${KICK_OUT_MS}ms ${KICK_OUT_EASE}`;\n      back1.style.transform = `translate(calc(${BACK1_REST_REM}rem + ${(dir * BACK1_SHIFT_PX).toFixed(2)}px), calc(${BACK1_REST_REM}rem + ${j1.toFixed(2)}px)) rotate(${(dir * BACK1_ROTATE_DEG).toFixed(2)}deg)`;\n    }\n    if (back2) {\n      back2.style.transition = `transform ${KICK_OUT_MS}ms ${KICK_OUT_EASE}`;\n      back2.style.transform = `translate(calc(${BACK2_REST_REM}rem + ${(dir * BACK2_SHIFT_PX).toFixed(2)}px), calc(${BACK2_REST_REM}rem + ${j2.toFixed(2)}px)) rotate(${(dir * BACK2_ROTATE_DEG).toFixed(2)}deg)`;\n    }\n\n    // phase 2: once the out-travel lands, settle everything back to rest —\n    // each back layer a beat after the layer in front of it, no overshoot\n    settleTimerRef.current = window.setTimeout(() => {\n      settleTimerRef.current = null;\n      card.style.transition = `transform ${KICK_MS}ms ${SETTLE_EASE}`;\n      card.style.transform = `perspective(${PERSPECTIVE_PX}px) rotateY(0deg) translateX(0px) translateY(0px)`;\n      if (back1) {\n        back1.style.transition = `transform ${KICK_MS + BACK_STAGGER_MS}ms ${SETTLE_EASE}`;\n        back1.style.transform = `translate(${BACK1_REST_REM}rem, ${BACK1_REST_REM}rem) rotate(0deg)`;\n      }\n      if (back2) {\n        back2.style.transition = `transform ${KICK_MS + BACK_STAGGER_MS * 2}ms ${SETTLE_EASE}`;\n        back2.style.transform = `translate(${BACK2_REST_REM}rem, ${BACK2_REST_REM}rem) rotate(0deg)`;\n      }\n    }, KICK_OUT_MS);\n  };\n\n  const setBlur = (velAbs: number) => {\n    const card = cardRef.current;\n    if (!card || reducedMotion) return;\n    const t = clamp((velAbs - FAST_VELOCITY) / (VELOCITY_FOR_MAX_BLUR - FAST_VELOCITY), 0, 1);\n    const px = t * MAX_BLUR;\n    card.style.transition = `filter ${BLUR_TRANSITION_MS}ms ease-out`;\n    card.style.filter = px > 0.05 ? `blur(${px.toFixed(2)}px)` : \"none\";\n  };\n\n  // applies a raw (fractional) scrub position: rounds to the nearest card,\n  // commits it if changed, and picks kick (slow) vs blur-through (fast)\n  const applyRaw = (raw: number, velAbs: number) => {\n    const rounded = clampIndex(raw);\n    if (rounded !== posRef.current) {\n      const dir = Math.sign(rounded - posRef.current) || 1;\n      commit(rounded);\n      if (reducedMotion || velAbs < FAST_VELOCITY) kick(dir, rounded);\n    }\n    setBlur(velAbs);\n  };\n\n  const pitch =\n    cardHeight > 0 ? clamp(cardHeight / count, MIN_PITCH, NATURAL_PITCH) : NATURAL_PITCH;\n  const runHeight = count > 1 ? (count - 1) * pitch + 2 : 2;\n\n  // `runHeight` is deliberately compressed (down to a 1.5px pitch) so the\n  // drawn line-stack reads as the deck's actual physical thickness. Mapping\n  // pointer position over THAT span, though, made the drag range as small as\n  // ~20px for a handful of cards — a couple of stray pixels would skip\n  // several cards, and the stripe itself rendered as a sliver next to a much\n  // taller card. `travel` is the interactive range: it matches the card's\n  // own measured height (a full-height rail beside it, like a scrollbar),\n  // independent of how compact the visual pitch is. The thin lines still\n  // draw at `runHeight`/`pitch` (centered inside the taller stripe via the\n  // stripe's own `items-center`) — only the hit box and the drag mapping grow.\n  const travel = cardHeight > 0 ? Math.max(runHeight, cardHeight - STRIPE_PAD * 2) : runHeight;\n\n  // maps clientY across `travel` (inset by STRIPE_PAD on each side, which is\n  // now genuinely just headroom past the ends, not the whole usable range)\n  const rawFromClientY = (clientY: number) => {\n    const stripe = stripeRef.current;\n    if (!stripe) return posRef.current;\n    const rect = stripe.getBoundingClientRect();\n    const frac = clamp((clientY - rect.top - STRIPE_PAD) / Math.max(1, travel), 0, 1);\n    return frac * (count - 1);\n  };\n\n  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {\n    const stripe = stripeRef.current;\n    if (!stripe) return;\n    e.preventDefault();\n    try {\n      stripe.setPointerCapture(e.pointerId);\n    } catch {\n      /* capture unsupported */\n    }\n    stripe.focus({ preventScroll: true });\n    const raw = rawFromClientY(e.clientY);\n    dragRef.current = {\n      pointerId: e.pointerId,\n      lastRaw: raw,\n      lastT: performance.now(),\n      vel: 0,\n      lastDir: 1,\n    };\n    applyRaw(raw, 0);\n  };\n\n  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    const drag = dragRef.current;\n    if (!drag || e.pointerId !== drag.pointerId) return;\n    const raw = rawFromClientY(e.clientY);\n    const now = performance.now();\n    const dt = Math.max(4, now - drag.lastT) / 1000;\n    const instVel = (raw - drag.lastRaw) / dt;\n    drag.vel = drag.vel * 0.5 + instVel * 0.5;\n    if (instVel !== 0) drag.lastDir = Math.sign(instVel);\n    drag.lastRaw = raw;\n    drag.lastT = now;\n    applyRaw(raw, Math.abs(drag.vel));\n  };\n\n  const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {\n    const drag = dragRef.current;\n    if (!drag || e.pointerId !== drag.pointerId) return;\n    try {\n      stripeRef.current?.releasePointerCapture(e.pointerId);\n    } catch {\n      /* already released */\n    }\n    const wasFast = Math.abs(drag.vel) >= FAST_VELOCITY;\n    const finalRounded = clampIndex(drag.lastRaw);\n    const lastDir = drag.lastDir || 1;\n    dragRef.current = null;\n    setBlur(0); // release settles: blur eases to zero either way\n    if (finalRounded !== posRef.current) {\n      commit(finalRounded);\n      kick(lastDir, finalRounded);\n    } else if (wasFast && !reducedMotion) {\n      // arrived on this card via a fast blur-through pass that never itself\n      // kicked — mark the arrival so a release always reads as a settle\n      kick(lastDir, finalRounded);\n    }\n  };\n\n  const onPointerCancel = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (dragRef.current?.pointerId !== e.pointerId) return;\n    dragRef.current = null;\n    setBlur(0);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    const cur = posRef.current;\n    let next: number | null = null;\n    switch (e.key) {\n      case \"ArrowRight\":\n      case \"PageDown\":\n        next = cur + 1;\n        break;\n      case \"ArrowLeft\":\n      case \"PageUp\":\n        next = cur - 1;\n        break;\n      case \"Home\":\n        next = 0;\n        break;\n      case \"End\":\n        next = count - 1;\n        break;\n      default:\n        return;\n    }\n    e.preventDefault();\n    const clamped = clampIndex(next);\n    if (clamped !== cur) {\n      const dir = Math.sign(clamped - cur) || 1;\n      commit(clamped);\n      kick(dir, clamped);\n    }\n  };\n\n  // wheel/trackpad: accumulate deltaY and step one card per WHEEL_STEP worth\n  // of scroll, gated by a cooldown so a fast trackpad fling doesn't blow\n  // through several cards' kicks in one continuous gesture — mirrors the\n  // discrete feel of a slow drag or a keyboard step, never a blur-through.\n  // Attached natively (not React's onWheel) with { passive: false }: a\n  // passive listener would make preventDefault() a silent no-op (the page\n  // scrolls out from under the stripe) and can warn in the console, which\n  // the verify gate treats as a failure.\n  useEffect(() => {\n    const stripe = stripeRef.current;\n    if (!stripe) return;\n    const handler = (e: WheelEvent) => {\n      e.preventDefault();\n      const w = wheelRef.current;\n      w.accum += e.deltaY;\n      const now = performance.now();\n      if (now - w.lastT < WHEEL_COOLDOWN_MS) return;\n      if (Math.abs(w.accum) < WHEEL_STEP_PX) return;\n      const dir = Math.sign(w.accum);\n      w.accum = 0;\n      w.lastT = now;\n      const cur = posRef.current;\n      const clamped = clampIndex(cur + dir);\n      if (clamped !== cur) {\n        commit(clamped);\n        kick(dir, clamped);\n      }\n    };\n    stripe.addEventListener(\"wheel\", handler, { passive: false });\n    return () => stripe.removeEventListener(\"wheel\", handler);\n    // re-bound every render (cheap: one addEventListener/removeEventListener\n    // pair) rather than a narrow deps array — `handler` closes over `commit`/\n    // `kick`, which themselves close over props like `onIndexChange`, so a\n    // stale closure here would silently call an old callback after a parent\n    // re-render, exactly the class of bug a trimmed deps array would hide.\n  });\n\n  const item = items[current] ?? items[0];\n\n  return (\n    <div className={`w-full ${className}`} role=\"group\" aria-label={ariaLabel}>\n      <div className=\"flex items-center gap-3\">\n        <div className=\"relative min-h-[200px] flex-1\" style={{ perspective: `${PERSPECTIVE_PX}px` }}>\n          {/* decorative stack depth — the physical thickness read, behind the\n              top card. Rest transform set inline (rem, matching the original\n              translate-x-1.5/translate-y-1.5 and translate-x-3/translate-y-3\n              utilities) rather than via Tailwind classes, so `kick` can nudge\n              and ease it back with the same imperative double-write it uses\n              on the top card. */}\n          <div\n            ref={back1Ref}\n            aria-hidden\n            className=\"absolute inset-0 rounded-md border border-border bg-surface opacity-60\"\n            style={{ transform: `translate(${BACK1_REST_REM}rem, ${BACK1_REST_REM}rem)`, willChange: \"transform\" }}\n          />\n          <div\n            ref={back2Ref}\n            aria-hidden\n            className=\"absolute inset-0 rounded-md border border-border bg-surface opacity-35\"\n            style={{ transform: `translate(${BACK2_REST_REM}rem, ${BACK2_REST_REM}rem)`, willChange: \"transform\" }}\n          />\n          {item ? (\n            <div\n              ref={cardRef}\n              className=\"relative flex h-full flex-col gap-2 rounded-md border border-border bg-surface p-5 transition-colors duration-200 hover:border-foreground/35\"\n              style={{ willChange: \"transform, filter\" }}\n            >\n              {item.eyebrow && (\n                <p className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-ns-muted\">\n                  {item.eyebrow}\n                </p>\n              )}\n              <h3 className=\"text-base font-semibold tracking-tight text-foreground\">\n                {item.title}\n              </h3>\n              {item.description && (\n                <p className=\"text-sm leading-relaxed text-ns-muted\">{item.description}</p>\n              )}\n              <p aria-hidden className=\"mt-auto font-mono text-[10px] tracking-wide text-ns-muted\">\n                {String(current + 1).padStart(2, \"0\")} / {String(count).padStart(2, \"0\")}\n              </p>\n            </div>\n          ) : (\n            <div className=\"relative flex h-full items-center justify-center rounded-md border border-border bg-surface p-5 text-sm text-ns-muted\">\n              No cards\n            </div>\n          )}\n        </div>\n\n        <div\n          ref={stripeRef}\n          role=\"slider\"\n          tabIndex={0}\n          aria-label=\"Scrub cards\"\n          aria-orientation=\"vertical\"\n          aria-valuemin={0}\n          aria-valuemax={count - 1}\n          aria-valuenow={current}\n          aria-valuetext={`Card ${current + 1} of ${count}`}\n          onPointerDown={onPointerDown}\n          onPointerMove={onPointerMove}\n          onPointerUp={endDrag}\n          onPointerCancel={onPointerCancel}\n          onKeyDown={onKeyDown}\n          style={{ height: travel + STRIPE_PAD * 2 }}\n          className=\"group relative flex w-8 shrink-0 cursor-row-resize touch-none select-none flex-col items-center justify-center rounded-sm outline-none transition-colors duration-200 hover:bg-border/10 focus-visible:ring-2 focus-visible:ring-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n        >\n          <div className=\"relative\" style={{ height: runHeight, width: 14 }}>\n            {items.map((it, i) => {\n              const active = i === current;\n              return (\n                <div\n                  key={it.id}\n                  aria-hidden\n                  data-active={active ? \"true\" : \"false\"}\n                  className={\n                    active\n                      ? \"absolute left-0 w-full rounded-full bg-foreground transition-colors duration-200\"\n                      : \"absolute left-0 w-full rounded-full bg-border transition-colors duration-200 group-hover:bg-ns-muted\"\n                  }\n                  style={{ top: i * pitch, height: active ? 2 : 1 }}\n                />\n              );\n            })}\n          </div>\n        </div>\n      </div>\n      <span ref={liveRef} aria-live=\"polite\" className=\"sr-only\" />\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/carousel-card-riffle.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "stack",
      "cards",
      "carousel",
      "stepper",
      "pagination",
      "scrub",
      "slider",
      "onboarding",
      "queue"
    ],
    "instruction": "A card-stack navigator for stepping through a deck (onboarding steps, image sets, stacked notifications, a review queue) where the pagination indicator and the scrub control are the same element: an edge stripe running down the right side of the single visible top card, rendered as one 1px --border line per item spaced by a natural 3px pitch (1px line + 2px gap) that compresses down to a 1.5px floor when the deck is taller than the stripe's measured height — so the stripe's own length is literally the deck's physical thickness, and a 4-card deck reads visibly thinner than a 40-card one. The line at the current index is recolored --foreground (2px tall vs 1px for the rest) and transition-colors over 200ms, so scrubbing through cards is seen as that highlight relocating rather than a separate progress bar. RENDERING: only the top card is ever mounted — no off-stage card DOM — behind it two decorative translate-offset border/surface rectangles (opacity 60%/35%, aria-hidden) read as stacked depth. Those two depth layers shuffle along with every committed step at a subtler, proportionally-scaled amplitude: each kicks a smaller, opposite-leaning translate+rotate than the top card's own flip (reduced amplitude with depth, second layer smaller than the first) and eases back to its resting offset a beat after the layer in front of it, so a step reads as the whole deck visibly cascading and restacking, cleanly, not just the top card swapping out. INTERACTION: the stripe is role=\"slider\" (vertical, aria-valuemin 0, aria-valuemax count-1, aria-valuenow the current index, aria-valuetext \"Card N of total\"), pointer-draggable — clientY mapped linearly to a raw fractional index across the stripe's own measured height, rounded to the nearest card on every pointermove. Crossing to a new card kicks the top card: a synchronous double transform write (snap to perspective(720px) rotateY(5deg) translateX(∓11px) translateY(deterministic per-index jitter, ±2px via a sine hash so no two cards' kicks look identical), then next-frame transition back to identity over 220ms on a smooth ease-out cubic-bezier(0.22,1,0.36,1) with no overshoot) — a clean, deliberate flip-past, not a subtle nudge and not a bouncy glitch. Velocity gates which behavior plays: below 14 idx/s the kick fires per card (discrete stepping); at or above it, kicks are suppressed and a CSS filter blur (0–5px, ramped by velocity, capped at 46 idx/s) plays on the top card instead via a 140ms filter transition — fast scrubs blur the deck past, slow scrubs step it card by card. Release always settles: blur eases back to zero, and if the pointer let go mid-fast-pass without a kick ever having marked the final card, one settle kick fires so every release reads as an arrival, not a value glitching into place. A tap on the stripe (no drag) jumps straight to that position. The stripe's hit area and its drag-to-index mapping are sized to the card's own measured height (a full-height rail beside it), independent of how compressed the drawn line-pitch gets for a large deck — so precision doesn't collapse as more cards are added. Keyboard, once the stripe is focused: ArrowLeft/PageUp step back one card, ArrowRight/PageDown step forward one, Home/End jump to the first/last — every keyboard step plays the same kick as a slow drag step. Mouse wheel or trackpad scroll over the stripe also steps one card per tick (deltaY accumulated, cooldown-gated so a fast fling can't chain through several kicks at once). REDUCED MOTION: no perspective, no rotateY, no back-layer shuffle, no blur, ever — a card change instead snaps the top card's opacity to 0.4 and eases it back to 1 over 120ms, so the deck is always readable as a state change without vestibular motion. A11Y: the whole thing sits in one role=\"group\" (aria-label describing the deck) with a visually-hidden aria-live=\"polite\" span that announces \"Card N of total\" on every committed index change, independent of the stripe's own aria-valuetext. Differs from gallery-coverflow-caustic, which browses large cover-art cards laterally with drag momentum and a chromatic-aberration flourish on the focused card at rest in a wide gallery layout — carousel-card-riffle has exactly one visible card at a time and no momentum/coverflow geometry at all. Differs from drill-down-spines, whose collapsed levels are permanently visible, individually clickable, read-at-rest spines standing for navigation *history* you can jump back into at any depth — carousel-card-riffle's edge lines are not independently interactive targets and carry no history semantics, they're a single continuous scrub surface over a linear, ephemeral position in a flat deck. Zero dependencies, DOM+CSS only, no canvas — every color from --background/--foreground/--ns-muted/--border/--surface/--ns-accent tokens."
  },
  "type": "registry:ui"
}