Card Stack
betaA pile of cards you step through as a receding deck, or that fans out into an arc on hover and focus.
Installation
pnpm dlx @dowel-ui/cli add card-stacknpm packages installed: class-variance-authority.
Accessibility
The deck is a carousel region (aria-roledescription="carousel") of labelled slides; only the top card is exposed — the others are aria-hidden and inert. Indicator dots are buttons with a roving tabindex and aria-current; arrow keys (mirrored in right-to-left layouts), Home and End step the deck and move focus with it, and a polite status region announces the new card. Wheel and swipe are conveniences on top of that, and the wheel releases the page at either end. The fan is a labelled list that opens on focus as well as hover; its motion is decoration and settles instantly under reduced motion.
Props
CardStack
| Prop | Type | Default |
|---|---|---|
children (required)One card per child. | ReactNode | — |
defaultIndex
| number | — |
getCardLabel
| (index: number, count: number) => string | — |
index
| number | — |
onIndexChange
| (index: number) => void | — |
open
| boolean | — |
scatter
| number | — |
spread
| number | — |
variant | "deck" | "fan" | "deck" |
Plus every attribute of <div>.
Quality
10/10 checks, measured from the source and its tests
- Tested — passes
- axe assertion — passes
- Keyboard tested — passes
- Storybook examples — passes
- Accessibility documented — passes
- Semantic tokens only — passes
- Motion from tokens — passes
- className merged — passes
- Visible focus — passes
- No fixed widths — passes
Source
This is exactly what dowel add card-stack writes into your project, with imports rewritten to your own path alias.
"use client";
// Ported from SmoothUI Scrollable Card Stack (MIT, © 2024 Eduardo Calvo) and bencho Card stack (MIT, © 2026 Lorenzo Cabra). See THIRD_PARTY_NOTICES.md.
import { cva, type VariantProps } from "class-variance-authority";
import {
Children,
useEffect,
useRef,
useState,
type ComponentPropsWithRef,
type KeyboardEvent,
type ReactNode,
type TouchEvent,
} from "react";
import { focusRing } from "@/lib/styles";
import { cn } from "@/lib/utils";
import { deckCardStyle, fanCardStyle } from "./card-stack-layout";
/*
* One pile of cards, two ways of going through it (ADR 0014, families not
* copies):
*
* - `deck` (SmoothUI Scrollable Card Stack): one card on top, the rest
* receding behind it. Wheel, swipe, arrow keys or the indicator dots step
* through them one at a time. It is a carousel, and says so.
* - `fan` (bencho Card stack): a loose, seeded pile that fans out into an arc
* on hover or focus; the card under the pointer lifts out of the fan.
*
* Both are CSS transitions on inline transforms. The source springs were
* driven by discrete steps and hover, never by a gesture's velocity, so
* `ease-overshoot` carries the bounce and the motion scale stops it.
*/
const WHEEL_THRESHOLD = 20;
const WHEEL_INTERVAL = 300;
const SWIPE_THRESHOLD = 60;
const cardStackVariants = cva("relative flex flex-col items-center", {
variants: {
variant: {
deck: "gap-4",
fan: "rtl:[--card-stack-flip:-1]",
},
},
defaultVariants: {
variant: "deck",
},
});
const cardShell = cn(
"col-start-1 row-start-1",
"transition-[transform,opacity,filter,scale] duration-[var(--duration-slow)] ease-[var(--ease-overshoot)]",
);
export interface CardStackProps
extends ComponentPropsWithRef<"div">, VariantProps<typeof cardStackVariants> {
/** One card per child. */
children: ReactNode;
/** `deck` only: the card on top (controlled). */
index?: number;
/** `deck` only: the card on top at first (uncontrolled). */
defaultIndex?: number;
/** `deck` only: called when the reader moves to another card. */
onIndexChange?: (index: number) => void;
/** `deck` only: names each card for assistive technology. Defaults to "2 of 5". */
getCardLabel?: (index: number, count: number) => string;
/** `fan` only: forces the fan open or shut. Leave unset to follow hover and focus. */
open?: boolean;
/** `fan` only: the width of the fan, 0–100. It is an arc the cards share, not a gap. */
spread?: number;
/** `fan` only: how far the pile is from squared-up, 0–100. */
scatter?: number;
}
const defaultLabel = (index: number, count: number) =>
`${String(index + 1)} of ${String(count)}`;
type Shared = Omit<
CardStackProps,
| "variant"
| "index"
| "defaultIndex"
| "onIndexChange"
| "getCardLabel"
| "open"
| "spread"
| "scatter"
>;
type DeckProps = Shared &
Pick<CardStackProps, "index" | "defaultIndex" | "onIndexChange" | "getCardLabel">;
type FanProps = Shared & Pick<CardStackProps, "open" | "spread" | "scatter">;
/** A pile of cards: a stepped deck, or a pile that fans out. */
export function CardStack({
variant = "deck",
index,
defaultIndex,
onIndexChange,
getCardLabel,
open,
spread,
scatter,
...props
}: CardStackProps) {
return variant === "fan" ? (
<Fan open={open} spread={spread} scatter={scatter} {...props} />
) : (
<Deck
index={index}
defaultIndex={defaultIndex}
onIndexChange={onIndexChange}
getCardLabel={getCardLabel}
{...props}
/>
);
}
function isRtl(element: Element) {
return element.closest("[dir]")?.getAttribute("dir") === "rtl";
}
function Deck({
className,
children,
index,
defaultIndex = 0,
onIndexChange,
getCardLabel = defaultLabel,
...props
}: DeckProps) {
const cards = Children.toArray(children);
const count = cards.length;
const [uncontrolled, setUncontrolled] = useState(defaultIndex);
const current = Math.min(Math.max(index ?? uncontrolled, 0), Math.max(count - 1, 0));
const [announcement, setAnnouncement] = useState("");
const stage = useRef<HTMLDivElement>(null);
const dots = useRef<(HTMLButtonElement | null)[]>([]);
const lastWheel = useRef(Number.NEGATIVE_INFINITY);
const touchStart = useRef<number | null>(null);
function go(target: number, focusDot = false) {
const next = Math.min(Math.max(target, 0), count - 1);
if (next === current) return false;
if (index === undefined) setUncontrolled(next);
onIndexChange?.(next);
setAnnouncement(`Card ${getCardLabel(next, count)}`);
if (focusDot) dots.current[next]?.focus();
return true;
}
// The wheel listener must be non-passive to keep the page from scrolling
// while it steps the deck — but only while there is a card to step to, so
// the page scrolls on as normal once either end is reached.
const step = useRef(go);
const currentRef = useRef(current);
useEffect(() => {
step.current = go;
currentRef.current = current;
});
useEffect(() => {
const element = stage.current;
if (!element) return;
const onWheel = (event: WheelEvent) => {
if (Math.abs(event.deltaY) < WHEEL_THRESHOLD) return;
const now = event.timeStamp;
if (now - lastWheel.current < WHEEL_INTERVAL) {
event.preventDefault();
return;
}
if (step.current(currentRef.current + Math.sign(event.deltaY))) {
lastWheel.current = now;
event.preventDefault();
}
};
element.addEventListener("wheel", onWheel, { passive: false });
return () => element.removeEventListener("wheel", onWheel);
}, []);
// The dots are the keyboard's way in, and behave like a tab list: one tab
// stop, arrows to move, Home and End to jump.
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
const forward = isRtl(event.currentTarget) ? "ArrowLeft" : "ArrowRight";
const backward = forward === "ArrowLeft" ? "ArrowRight" : "ArrowLeft";
const targets: Record<string, number> = {
ArrowDown: current + 1,
[forward]: current + 1,
ArrowUp: current - 1,
[backward]: current - 1,
Home: 0,
End: count - 1,
};
const target = targets[event.key];
if (target === undefined) return;
event.preventDefault();
go(target, true);
}
function handleTouchMove(event: TouchEvent<HTMLDivElement>) {
const start = touchStart.current;
const touch = event.touches[0];
if (start === null || !touch) return;
const delta = start - touch.clientY;
if (Math.abs(delta) < SWIPE_THRESHOLD) return;
touchStart.current = null;
go(current + Math.sign(delta));
}
return (
<div
role="region"
aria-roledescription="carousel"
aria-label={props["aria-labelledby"] ? undefined : "Cards"}
data-slot="card-stack"
data-variant="deck"
className={cn(cardStackVariants({ variant: "deck" }), className)}
{...props}
>
<div
ref={stage}
data-slot="card-stack-stage"
className="grid touch-none place-items-center pt-24 [perspective:1000px]"
onTouchStart={(event) => {
touchStart.current = event.touches[0]?.clientY ?? null;
}}
onTouchMove={handleTouchMove}
onTouchEnd={() => {
touchStart.current = null;
}}
>
{cards.map((card, i) => {
const active = i === current;
return (
<div
key={i}
role="group"
aria-roledescription="slide"
aria-label={getCardLabel(i, count)}
aria-hidden={active ? undefined : true}
inert={!active}
data-slot="card-stack-card"
data-active={active}
className={cn(cardShell, "data-[active=true]:hover:scale-[1.02]")}
style={deckCardStyle(i, current, count)}
>
{card}
</div>
);
})}
</div>
<div role="group" aria-label="Choose a card" className="flex items-center gap-2">
{cards.map((_, i) => (
<button
key={i}
ref={(element) => {
dots.current[i] = element;
}}
type="button"
tabIndex={i === current ? 0 : -1}
aria-label={`Card ${getCardLabel(i, count)}`}
aria-current={i === current ? "true" : undefined}
data-slot="card-stack-indicator"
className={cn(
"relative size-2 rounded-full bg-border-strong after:absolute after:-inset-2",
"transition-[background-color,scale] duration-[var(--duration-fast)] ease-[var(--ease-out-quint)]",
"hover:bg-muted-foreground aria-current:scale-125 aria-current:bg-primary",
focusRing,
)}
onClick={() => go(i)}
onKeyDown={handleKeyDown}
/>
))}
</div>
<span role="status" aria-live="polite" className="sr-only">
{announcement}
</span>
</div>
);
}
function Fan({
className,
children,
open,
spread = 55,
scatter = 40,
onPointerEnter,
onPointerLeave,
onFocus,
onBlur,
...props
}: FanProps) {
const cards = Children.toArray(children);
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const [lifted, setLifted] = useState<number | null>(null);
const fanned = open ?? (hovered || focused);
return (
<div
role="list"
aria-label={props["aria-labelledby"] ? undefined : "Cards"}
data-slot="card-stack"
data-variant="fan"
data-state={fanned ? "open" : "closed"}
className={cn(
cardStackVariants({ variant: "fan" }),
"grid place-items-center",
className,
)}
onPointerEnter={(event) => {
onPointerEnter?.(event);
setHovered(true);
}}
onPointerLeave={(event) => {
onPointerLeave?.(event);
setHovered(false);
setLifted(null);
}}
onFocus={(event) => {
onFocus?.(event);
setFocused(true);
}}
onBlur={(event) => {
onBlur?.(event);
if (!event.currentTarget.contains(event.relatedTarget)) {
setFocused(false);
setLifted(null);
}
}}
{...props}
>
{cards.map((card, i) => {
const up = fanned && lifted === i;
return (
<div
key={i}
role="listitem"
data-slot="card-stack-card"
className={cardShell}
style={fanCardStyle(i, cards.length, { fanned, spread, scatter })}
onPointerEnter={() => setLifted(i)}
onFocus={() => setLifted(i)}
>
<div
data-slot="card-stack-face"
data-lifted={up || undefined}
className="transition-[translate] duration-[var(--duration-slow)] ease-[var(--ease-out-quint)] data-lifted:-translate-y-4"
>
{card}
</div>
</div>
);
})}
</div>
);
}
export { cardStackVariants };
// Ported from SmoothUI Scrollable Card Stack (MIT, © 2024 Eduardo Calvo) and bencho Card stack (MIT, © 2026 Lorenzo Cabra). See THIRD_PARTY_NOTICES.md.
import type { CSSProperties } from "react";
/*
* Where each card sits. Pure functions of index and state, so the component
* only chooses which one applies, and the geometry can be read (and tested)
* on its own.
*/
/* Deck: SmoothUI's numbers. Each card behind the top one is 8% smaller and
* 30px higher, up to three deep; cards already passed fade and blur away. */
const DECK_SCALE_STEP = 0.08;
const DECK_RISE = -30;
const DECK_MAX_RISE = DECK_RISE * 3;
export function deckCardStyle(index: number, current: number, count: number): CSSProperties {
const offset = index - current;
const passed = offset < 0;
const scale = Math.min(Math.max(1 - offset * DECK_SCALE_STEP, DECK_SCALE_STEP), 2);
const rise = Math.max(offset * DECK_RISE, DECK_MAX_RISE);
return {
transform: `translateY(${String(rise)}px) scale(${String(scale)})`,
opacity: passed ? 0 : 1,
filter: passed ? "blur(2px)" : "none",
zIndex: count - index,
};
}
/*
* Fan: bencho's idea, our numbers. Every card turns about one pivot far below
* the pile, so the sideways travel, the drop of the outer cards and their tilt
* are one rotation rather than three values that never quite agree.
*
* The pile is seeded, not random — the same on every render and every copy —
* so it looks set down by hand rather than squared up by a computer. These
* seeds are our own; each column is -1..1 and multiplied by `scatter`.
*/
const SEEDS = [
{ rot: -0.55, dx: 0.35, dy: -0.3, beat: 0.3, tune: -0.2 },
{ rot: 0.7, dx: -0.45, dy: 0.2, beat: -0.4, tune: 0.35 },
{ rot: -0.2, dx: 0.6, dy: 0.45, beat: 0.6, tune: -0.45 },
{ rot: 0.4, dx: -0.2, dy: -0.5, beat: -0.1, tune: 0.15 },
{ rot: -0.8, dx: -0.6, dy: 0.1, beat: 0.45, tune: 0.5 },
{ rot: 0.25, dx: 0.15, dy: -0.15, beat: -0.55, tune: -0.3 },
] as const;
/** Degrees a scattered card sits off square, at full scatter. */
const REST_TILT = 7;
/** Pixels a scattered card sits off centre, at full scatter. */
const REST_DRIFT = 6;
/** Pixels each card rises above the one beneath it in the closed pile. */
const REST_RISE = 3;
/** How much smaller each card beneath the top one is in the closed pile. */
const REST_SHRINK = 0.025;
/** Milliseconds between one card starting to move and the next. */
const STAGGER = 35;
const DURATION = 420;
export interface FanState {
fanned: boolean;
/** 0–100. */
spread: number;
/** 0–100. */
scatter: number;
}
export function fanCardStyle(index: number, count: number, state: FanState): CSSProperties {
const seed = SEEDS[index % SEEDS.length] ?? SEEDS[0];
const scatter = Math.min(Math.max(state.scatter, 0), 100) / 100;
const spread = Math.min(Math.max(state.spread, 0), 100) / 100;
const depth = count - 1 - index;
const fromCentre = index - (count - 1) / 2;
// The arc is never zero: a stack that does not open reads as broken.
const arc = 10 + spread * 30;
const step = count > 1 ? arc / (count - 1) : 0;
const x = state.fanned ? 0 : seed.dx * scatter * REST_DRIFT;
const y = state.fanned ? 0 : -depth * REST_RISE + seed.dy * scatter * REST_DRIFT;
const angle = state.fanned
? fromCentre * step + seed.rot * scatter * 0.3 * step
: seed.rot * scatter * REST_TILT;
const scale = state.fanned ? 1 : 1 - depth * REST_SHRINK;
// Opening starts from the top card; closing from the bottom one. The beat
// keeps six cards on one easing from all landing on the same frame.
const order = state.fanned ? depth : index;
const delay = Math.max(0, STAGGER * (order + seed.beat * scatter * 0.6));
const duration = DURATION * (1 + seed.tune * scatter * 0.45);
return {
transform:
`translate(calc(var(--card-stack-flip, 1) * ${x.toFixed(2)}px), ${y.toFixed(2)}px) ` +
`rotate(calc(var(--card-stack-flip, 1) * ${angle.toFixed(3)}deg)) scale(${scale.toFixed(3)})`,
transformOrigin: "50% calc(50% + 12.5rem)",
transitionDelay: `calc(${delay.toFixed(0)}ms * var(--motion-scale, 1))`,
transitionDuration: `calc(${duration.toFixed(0)}ms * var(--motion-scale, 1))`,
zIndex: index,
};
}