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