Card Stack

beta

A pile of cards you step through as a receding deck, or that fans out into an arc on hover and focus.

Installation

Terminal
pnpm dlx @dowel-ui/cli add card-stack

npm 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

PropTypeDefault
children (required)

One card per child.

ReactNode
defaultIndex

deck only: the card on top at first (uncontrolled).

number
getCardLabel

deck only: names each card for assistive technology. Defaults to "2 of 5".

(index: number, count: number) => string
index

deck only: the card on top (controlled).

number
onIndexChange

deck only: called when the reader moves to another card.

(index: number) => void
open

fan only: forces the fan open or shut. Leave unset to follow hover and focus.

boolean
scatter

fan only: how far the pile is from squared-up, 0–100.

number
spread

fan only: the width of the fan, 0–100. It is an arc the cards share, not a gap.

number
variant"deck" | "fan""deck"

Plus every attribute of <div>.

Quality

10/10 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testedpasses
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokenspasses
  • className mergedpasses
  • Visible focuspasses
  • No fixed widthspasses

Source

This is exactly what dowel add card-stack writes into your project, with imports rewritten to your own path alias.

ui/card-stack.tsx
"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 };
ui/card-stack-layout.ts
// 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,
  };
}