Card deck

A tactile stack that fans out, follows your hand, and deals the next card with a measured flick.

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/card-deck.json

Overview

A small collection you can hold. The cards sit in a shallow stack; hovering or focusing the front card gently fans the ones behind it. Drag sideways and the front card follows your hand with a slight rotation while the next comes forward. A committed drag or quick flick deals it away. Release early and a damped spring brings it home. The same collection works with arrow buttons and keyboard controls, and its cards can contain your own content.

States

rest
Up to four cards are visible, with 12px spacing and a 5.5% size difference between layers.
fan
Hover or keyboard focus opens the stack to 17px spacing and a slight alternating rotation.
drag
The front card follows the pointer without React updates on each frame. Every card has its own compositor layer, and the cards behind are moved directly as the next one rises in proportion to the drag, so nothing repaints mid-gesture.
deal
Cross 27% of the card’s width (at most 100px), or flick intentionally, to deal a card. The outgoing card continues from the release position.
return
An incomplete or cancelled drag returns along a sampled damped spring over 520ms, using the release velocity.

Usage

usage.tsx
import { CardDeck } from "@/components/card-deck";<CardDeck  items={[{ id: "capture", title: "Catch the little things." }, { id: "connect", title: "Follow the thread." }]}  getLabel={(card) => card.title}  renderCard={(card) => <div className="p-6"><h3>{card.title}</h3></div>}/>

Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.

Works with

A controlled collection

Render your own card contents; the deck owns the stack and gestures. The collection wraps in both directions.

example.tsx
"use client";import { useState } from "react";import { CardDeck } from "@/components/card-deck";const cards = [  { id: "capture", title: "Capture", body: "Save a thought before it disappears." },  { id: "connect", title: "Connect", body: "Find the thread between your ideas." },];export function Collection() {  const [value, setValue] = useState("capture");  return (    <CardDeck items={cards} value={value} onValueChange={setValue} height={300}      getLabel={(card) => card.title}      renderCard={(card, { index }) => (        <article className="flex h-full flex-col p-6">          <span className="text-xs text-muted-foreground">0{index + 1}</span>          <h3 className="mt-auto text-2xl">{card.title}</h3>          <p className="mt-3 text-sm text-muted-foreground">{card.body}</p>        </article>      )}    />  );}

Props

PropTypeDescription
itemsT[] where T extends { id: string }The collection in reading order. IDs must be unique.
renderCard(item: T, state: { active: boolean; index: number }) => ReactNodeYour card contents. Only the front card is interactive; rear cards and outgoing copies are inert.
getLabel(item: T) => stringdefault item.idAccessible name for each card.
value / defaultValuestringControlled selected ID or initial selected ID. Defaults to the first card.
onValueChange(id: string) => voidReceives the next selected ID after a gesture or button action.
heightnumberdefault 340Card height in CSS pixels, at least 120. Leave space for your longest content.
labelstringdefault Card collectionAccessible label for the collection region.
classNamestringAdditional classes on the root. Standard div attributes are forwarded.

Accessibility and motion

  • Left and Right on the front card browse the collection. Previous and Next buttons offer equivalent pointer and touch controls.
  • Only the front card is focusable. Back cards and the departing copy are inert and hidden from assistive technology.
  • Links, buttons, form inputs and elements marked data-deck-no-drag keep their own interactions without starting a card drag.
  • Vertical touch scrolling is preserved. Pointer cancellation returns the card, and a pause before release does not count as a flick.
  • Reduced motion removes dragging transforms, fanning and animated exits. Gestures still select cards, with immediate state changes.
  • The collection wraps. Empty collections render no controls; one-card collections disable browsing.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs Number roll.

Source

components/card-deck.tsx
'use client';import * as React from 'react';import { ArrowLeft, ArrowRight } from 'lucide-react';import { EASE_OUT, EASE_SETTLE, INTERACTION_FOCUS, useInteractionReducedMotion } from './ui-interaction-motion';import { useCardDeckGesture, type DeckThrow } from './use-card-deck-gesture';import { NumberRoll } from './number-roll';export interface CardDeckProps<T extends { id: string }> extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children' | 'onChange'> {  items: T[];  renderCard: (item: T, state: { active: boolean; index: number }) => React.ReactNode;  getLabel?: (item: T) => string;  value?: string;  defaultValue?: string;  onValueChange?: (id: string) => void;  label?: string;  /** Fixed card height keeps the stack stable while content changes. */  height?: number;}function DepartingCard({ motion, reduced, children }: { motion: DeckThrow; reduced: boolean; children: React.ReactNode }) {  const ref = React.useRef<HTMLDivElement>(null);  React.useLayoutEffect(() => {    const el = ref.current;    if (!el || reduced) return;    const end = -motion.direction * el.offsetWidth * 1.4;    const animation = el.animate([      { transform: `translateX(${motion.from}px) rotate(${motion.from / 24}deg)`, opacity: 1 },      { transform: `translateX(${end}px) rotate(${-motion.direction * 18}deg)`, opacity: 0 },    ], { duration: Math.abs(motion.velocity) > 1.5 ? 300 : 420, easing: EASE_OUT, fill: 'forwards' });    return () => animation.cancel();  }, [motion, reduced]);  return <div ref={ref} aria-hidden="true" inert className="pointer-events-none absolute inset-0 z-20 overflow-hidden rounded-[22px] border border-border bg-card text-card-foreground">{children}</div>;}/** Where a card behind the front one sits: lower and smaller by depth, rising as the front card leaves. */const stackTransform = (depth: number, progress: number, fanned: boolean) =>  `translateY(${depth * (fanned ? 17 : 12) - progress * 12}px) scale(${1 - depth * 0.055 + progress * 0.055}) rotate(${fanned ? (depth % 2 ? -1 : 1) * depth * 1.3 : 0}deg)`;/** * A tactile stack. Gestures write transforms straight onto the cards, and every card has its own compositor layer, * so a drag moves pictures instead of repainting them. React changes only when a card is chosen. */export function CardDeck<T extends { id: string }>({ items, renderCard, getLabel = (item) => item.id, value, defaultValue, onValueChange, label = 'Card collection', height = 340, className = '', ...props }: CardDeckProps<T>) {  const reduced = useInteractionReducedMotion();  const [own, setOwn] = React.useState(defaultValue ?? items[0]?.id);  const selected = value ?? own;  const index = Math.max(0, items.findIndex((item) => item.id === selected));  const current = items[index];  const [leaving, setLeaving] = React.useState<DeckThrow | null>(null);  const busy = React.useRef(false);  const [fanned, setFanned] = React.useState(false);  const restoreCardFocus = React.useRef(false);  const choose = (direction: number, from?: number, velocity = 0) => {    if (items.length < 2 || !current || busy.current) return false;    const next = items[(index + direction + items.length) % items.length];    restoreCardFocus.current = Boolean(document.activeElement?.hasAttribute('data-deck-active'));    busy.current = !reduced;    const transform = frontRef.current ? getComputedStyle(frontRef.current).transform : 'none';    const origin = from ?? (transform === 'none' ? 0 : new DOMMatrixReadOnly(transform).m41);    if (!reduced) setLeaving({ id: current.id, direction, from: origin, velocity });    if (value === undefined) setOwn(next.id);    onValueChange?.(next.id);    return true;  };  const backs = React.useRef(new Map<number, HTMLDivElement>());  const fannedRef = React.useRef(false);  const onProgress = (progress: number, settle: boolean) => {    if (reduced) return;    backs.current.forEach((el, depth) => {      el.style.transition = settle ? `transform 520ms ${EASE_SETTLE}` : 'none';      el.style.transform = stackTransform(depth, progress, fannedRef.current);    });  };  const { rootRef, frontRef, dragging, handlers } = useCardDeckGesture({ id: current?.id, count: items.length, reduced, onThrow: choose, onProgress });  React.useLayoutEffect(() => {    if (restoreCardFocus.current) { frontRef.current?.focus({ preventScroll: true }); restoreCardFocus.current = false; }  }, [current?.id, frontRef]);  React.useLayoutEffect(() => {    if (leaving?.id === current?.id) {      // A controlled parent may reject a selection. Keep the original card available.      if (frontRef.current) frontRef.current.style.transform = '';      setLeaving(null); busy.current = false;    }  }, [leaving, current?.id, frontRef]);  React.useEffect(() => {    const timer = window.setTimeout(() => { setLeaving(null); busy.current = false; }, reduced ? 0 : 430);    return () => window.clearTimeout(timer);  }, [selected, leaving, reduced]);  const ordered = current ? Array.from({ length: Math.min(4, items.length) }, (_, depth) => ({ item: items[(index + depth) % items.length], depth })) : [];  const departing = leaving && items.find((item) => item.id === leaving.id);  const expanded = fanned && !reduced;  fannedRef.current = expanded;  return (    <div ref={rootRef} aria-label={label} role="region" className={`w-full text-foreground ${className}`} {...props}>      <div className="mx-auto max-w-[320px] px-3">        <div className="relative isolate" style={{ height: Math.max(120, height) }} onPointerEnter={() => setFanned(true)} onPointerLeave={() => setFanned(false)}>          {ordered.slice().reverse().map(({ item, depth }) => {            const active = depth === 0;            return (              <div key={item.id} ref={active ? frontRef : (el) => { if (el) backs.current.set(depth, el); }} tabIndex={active ? 0 : -1} role="group" aria-roledescription="card"                aria-label={active ? `${getLabel(item)}, ${index + 1} of ${items.length}. Use Left and Right to browse.` : undefined}                aria-hidden={!active || undefined} inert={!active} data-deck-active={active || undefined}                onKeyDown={active ? (event) => {                  if (event.target !== event.currentTarget) return;                  if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') { event.preventDefault(); choose(event.key === 'ArrowRight' ? 1 : -1); }                } : undefined}                onFocus={active ? () => setFanned(true) : undefined}                onBlur={active ? (event) => { if (!event.currentTarget.contains(event.relatedTarget as Node)) setFanned(false); } : undefined}                {...(active ? handlers : {})}                className={`absolute inset-0 overflow-hidden rounded-[22px] border border-border bg-card text-card-foreground ${active ? `${dragging ? 'cursor-grabbing' : 'cursor-grab'} ${INTERACTION_FOCUS}` : 'pointer-events-none'}`}                style={{                  zIndex: 10 - depth, touchAction: active ? 'pan-y' : undefined, willChange: reduced ? undefined : 'transform',                  transform: active ? undefined : stackTransform(depth, 0, expanded),                  transformOrigin: '50% 100%',                  transition: reduced || dragging ? 'none' : `transform 520ms ${EASE_SETTLE}`,                }}>                {renderCard(item, { active, index: items.indexOf(item) })}              </div>            );          })}          {leaving && departing && <DepartingCard key={leaving.id} motion={leaving} reduced={reduced}>{renderCard(departing, { active: false, index: items.indexOf(departing) })}</DepartingCard>}        </div>      </div>      {current && <div className="mt-14 flex items-center justify-center gap-5">        <button type="button" aria-label="Previous card" disabled={items.length < 2} onClick={() => choose(-1)} className={`cursor-pointer disabled:cursor-default flex size-9 items-center justify-center rounded-full border border-border transition-colors hover:bg-accent disabled:opacity-30 ${INTERACTION_FOCUS}`}><ArrowLeft className="size-3.5" aria-hidden="true" /></button>        <p aria-live="polite" aria-atomic="true" className="min-w-12 text-center font-mono text-[11px] tabular-nums text-muted-foreground"><NumberRoll value={index + 1} format={{ minimumIntegerDigits: 2 }} /> / <NumberRoll value={items.length} format={{ minimumIntegerDigits: 2 }} /></p>        <button type="button" aria-label="Next card" disabled={items.length < 2} onClick={() => choose(1)} className={`cursor-pointer disabled:cursor-default flex size-9 items-center justify-center rounded-full border border-border transition-colors hover:bg-accent disabled:opacity-30 ${INTERACTION_FOCUS}`}><ArrowRight className="size-3.5" aria-hidden="true" /></button>      </div>}    </div>  );}
components/use-card-deck-gesture.ts
'use client';import * as React from 'react';import { releaseVelocity, samplePointer, springFrames, type PointerSample } from './ui-interaction-motion';export type DeckThrow = { id: string; direction: number; from: number; velocity: number };export function useCardDeckGesture({ id, count, reduced, onThrow, onProgress }: {  id: string | undefined;  count: number;  reduced: boolean;  onThrow: (direction: number, from: number, velocity: number) => boolean;  /** How far the front card has travelled toward a throw, 0–1; the stack behind rises with it. */  onProgress: (progress: number, settle: boolean) => void;}) {  const rootRef = React.useRef<HTMLDivElement>(null);  const frontRef = React.useRef<HTMLDivElement>(null);  const animation = React.useRef<Animation | null>(null);  const drag = React.useRef<{ pointer: number; startX: number; startY: number; base: number; x: number; width: number; horizontal: boolean; samples: PointerSample[] } | null>(null);  const [dragging, setDragging] = React.useState(false);  React.useEffect(() => () => { animation.current?.cancel(); }, []);  React.useLayoutEffect(() => {    drag.current = null;    setDragging(false);    animation.current?.cancel();    if (frontRef.current) frontRef.current.style.transform = '';    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [id, reduced]);  const settle = (from: number, velocity: number) => {    const front = frontRef.current;    if (!front) return;    front.style.transform = '';    if (reduced) return;    animation.current?.cancel();    animation.current = front.animate(springFrames(from, 0, velocity).map(({ value, offset }) => ({      transform: `translateX(${value}px) rotate(${value / 24}deg)`, offset,    })), { duration: 520, easing: 'linear' });  };  const finish = (event: React.PointerEvent, cancelled: boolean) => {    const state = drag.current;    if (!state || state.pointer !== event.pointerId) return;    drag.current = null;    setDragging(false);    onProgress(0, true);    const velocity = releaseVelocity(state.samples, event.timeStamp);    const crossed = Math.abs(state.x) > Math.min(100, state.width * 0.27);    const flicked = Math.abs(state.x) > 16 && Math.abs(velocity) > 0.55 && Math.sign(velocity) === Math.sign(state.x);    if (!cancelled && state.horizontal && (crossed || flicked)) { if (!onThrow(state.x < 0 ? 1 : -1, state.x, velocity)) settle(state.x, velocity); }    else settle(state.x, cancelled ? 0 : velocity);  };  return {    rootRef, frontRef, dragging,    handlers: {      onPointerDown(event: React.PointerEvent<HTMLDivElement>) {        if (count < 2 || event.button !== 0 || !event.isPrimary || (event.target as HTMLElement).closest('a,button,input,textarea,select,[data-deck-no-drag]')) return;        const transform = getComputedStyle(event.currentTarget).transform;        const base = transform === 'none' ? 0 : new DOMMatrixReadOnly(transform).m41;        animation.current?.cancel();        if (!reduced) event.currentTarget.style.transform = `translateX(${base}px) rotate(${base / 24}deg)`;        event.currentTarget.setPointerCapture(event.pointerId);        drag.current = { pointer: event.pointerId, startX: event.clientX, startY: event.clientY, base, x: base, width: event.currentTarget.clientWidth, horizontal: false, samples: [{ x: event.clientX, time: event.timeStamp }] };      },      onPointerMove(event: React.PointerEvent<HTMLDivElement>) {        const state = drag.current;        if (!state || state.pointer !== event.pointerId) return;        const dx = event.clientX - state.startX, dy = event.clientY - state.startY;        if (!state.horizontal) {          if (Math.abs(dy) > 8 && Math.abs(dy) > Math.abs(dx)) { drag.current = null; settle(state.base, 0); onProgress(0, true); return; }          if (Math.abs(dx) < 6) return;          state.horizontal = true;          setDragging(true);        }        state.x = state.base + dx;        samplePointer(state.samples, event.clientX, event.timeStamp);        if (!reduced) {          event.currentTarget.style.transform = `translateX(${state.x}px) rotate(${Math.max(-16, Math.min(16, state.x / 24))}deg)`;          onProgress(Math.min(1, Math.abs(state.x) / (state.width * 0.65)), false);        }      },      onPointerUp: (event: React.PointerEvent) => finish(event, false),      onPointerCancel: (event: React.PointerEvent) => finish(event, true),      onLostPointerCapture: (event: React.PointerEvent) => finish(event, true),    },  };}
components/ui-interaction-motion.ts
'use client';import * as React from 'react';export const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';export const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';export const INTERACTION_FOCUS = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';const query = '(prefers-reduced-motion: reduce)';const subscribe = (notify: () => void) => {  const media = window.matchMedia(query);  media.addEventListener('change', notify);  return () => media.removeEventListener('change', notify);};export const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);export function useInteractionActivity<T extends Element>() {  const ref = React.useRef<T>(null);  const [visible, setVisible] = React.useState(false);  const [hidden, setHidden] = React.useState(false);  React.useEffect(() => {    const el = ref.current;    if (!el) return;    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));    observer.observe(el);    const visibility = () => setHidden(document.hidden);    visibility(); document.addEventListener('visibilitychange', visibility);    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };  }, []);  return { ref, active: visible && !hidden };}export function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };}/** Sample recent movement rather than averaging a long drag and losing its release velocity. */export type PointerSample = { x: number; time: number };export function samplePointer(samples: PointerSample[], x: number, time: number) {  samples.push({ x, time });  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();}export function releaseVelocity(samples: PointerSample[], now: number) {  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;  const first = samples[0], last = samples[samples.length - 1];  return (last.x - first.x) / Math.max(1, last.time - first.time);}/** A damped spring sampled by WAAPI: no React renders or perpetual frame loop. */export function springFrames(from: number, to: number, velocity = 0, duration = 520) {  const omega = 20, damping = 0.82, damped = omega * Math.sqrt(1 - damping * damping);  const displacement = from - to;  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;  return Array.from({ length: 33 }, (_, i) => {    const t = (i / 32) * duration / 1000;    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));    return { value: i === 32 ? to : value, offset: i / 32 };  });}

Registry item: /r/card-deck.json