Card Stack
Click-through card stack. The deck loops; cards shuffle in one motion.
Installation
CLI
pnpm dlx shadcn@latest add https://animata.design/r/card/card-stack.json
The registry item installs card-stack.tsx.
Manual
Install dependencies
npm install motionCreate the component
mkdir -p components/animata/card && touch components/animata/card/card-stack.tsx"use client";
import { AnimatePresence, type HTMLMotionProps, motion, type Transition } from "motion/react";
import {
type ComponentProps,
cloneElement,
createContext,
isValidElement,
type ReactNode,
use,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { cn } from "@/lib/utils";
export interface CardStackItem {
id: string;
}
export interface CardStackLayerMotion {
className: string;
initial: HTMLMotionProps<"div">["initial"];
animate: HTMLMotionProps<"div">["animate"];
exit?: HTMLMotionProps<"div">["exit"];
transition: Transition;
style?: HTMLMotionProps<"div">["style"];
}
const DEFAULT_STACK_DEPTH = 3;
const DEFAULT_AUTOPLAY_INTERVAL = 4500;
/** How long before the next click is accepted — decoupled from exit spring settle */
const STEP_COOLDOWN_MS = 260;
/** Peek offsets as % of each card’s height so stacks scale with card size */
const STACK_LAYER_PRESETS = [
{ y: "0%", scale: 1, rotate: 0, zIndex: 20 },
{ y: "-5%", scale: 0.84, rotate: -1, zIndex: 5 },
{ y: "-7.5%", scale: 0.72, rotate: 1, zIndex: 0 },
] as const;
const PROMOTE_SPRING: Transition = {
type: "spring",
visualDuration: 0.22,
bounce: 0,
};
/** Top-card press — same transform channel as stack promote / throw */
const PRESS_SPRING: Transition = {
type: "spring",
visualDuration: 0.1,
bounce: 0,
};
const PRESS_SCALE_FACTOR = 0.985;
const THROW_SPRING: Transition = {
type: "spring",
visualDuration: 0.24,
bounce: 0.06,
restSpeed: 2,
restDelta: 0.001,
};
export interface CardStackThrowImpulse {
x: string;
rotate: number;
yVelocity: number;
rotateVelocity: number;
}
export function createCardStackThrowImpulse(): CardStackThrowImpulse {
const drift = Math.random() - 0.5;
return {
x: `${(drift * 7).toFixed(2)}%`,
rotate: drift * 6 + (Math.random() - 0.5) * 1.5,
yVelocity: 3.5 + Math.random() * 2.8,
rotateVelocity: drift * 10,
};
}
const CARD_STACK_STACK_ORIGIN = "50% 0%";
const CARD_STACK_EXIT_Y = "200%";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function getReducedMotionSnapshot() {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function usePrefersReducedMotion() {
return useSyncExternalStore(subscribeReducedMotion, getReducedMotionSnapshot, () => false);
}
export function getCardStackLayers(
reducedMotion: boolean,
depth = DEFAULT_STACK_DEPTH,
): CardStackLayerMotion[] {
const stackTransition: Transition = reducedMotion ? { duration: 0 } : PROMOTE_SPRING;
const layers: CardStackLayerMotion[] = STACK_LAYER_PRESETS.map((preset) => ({
className: "",
initial: {
y: preset.y,
rotate: preset.rotate,
scale: preset.scale,
opacity: 1,
zIndex: preset.zIndex,
},
animate: {
y: preset.y,
rotate: preset.rotate,
scale: preset.scale,
opacity: 1,
zIndex: preset.zIndex,
},
exit: reducedMotion
? {
y: preset.y,
scale: preset.scale,
opacity: 1,
zIndex: preset.zIndex,
}
: {
y: CARD_STACK_EXIT_Y,
rotate: 0,
scale: 0.93,
opacity: 0,
zIndex: 30,
},
transition: stackTransition,
style: { transformOrigin: CARD_STACK_STACK_ORIGIN },
}));
return layers.slice(0, depth);
}
function getLayerScale(layer: CardStackLayerMotion): number {
const target = layer.animate;
if (
target &&
typeof target === "object" &&
"scale" in target &&
typeof target.scale === "number"
) {
return target.scale;
}
return 1;
}
function getCardStackInitial(
stackIndex: number,
depth: number,
layer: CardStackLayerMotion,
): HTMLMotionProps<"div">["initial"] {
if (stackIndex !== 0 && stackIndex !== depth - 1) {
return false;
}
return layer.initial;
}
function getCardStackExit(
stackIndex: number,
layer: CardStackLayerMotion,
reducedMotion: boolean,
throwImpulse: CardStackThrowImpulse | null,
): HTMLMotionProps<"div">["exit"] {
if (stackIndex !== 0) {
return undefined;
}
if (reducedMotion) {
return layer.exit;
}
const impulse = throwImpulse ?? createCardStackThrowImpulse();
return {
y: CARD_STACK_EXIT_Y,
x: impulse.x,
rotate: impulse.rotate,
scale: 0.9,
opacity: 0,
zIndex: 30,
transition: {
y: { ...THROW_SPRING, velocity: impulse.yVelocity * 0.4 },
x: THROW_SPRING,
rotate: { ...THROW_SPRING, velocity: impulse.rotateVelocity * 0.35 },
scale: THROW_SPRING,
opacity: { type: "tween", duration: 0.16, ease: [0.4, 0, 1, 1] },
},
};
}
interface CardStackContextValue<T extends CardStackItem = CardStackItem> {
items: T[];
visibleItems: T[];
activeItem: T | undefined;
depth: number;
isAnimating: boolean;
pressActive: boolean;
setPressActive: (active: boolean) => void;
throwImpulse: CardStackThrowImpulse | null;
advance: () => void;
handleExitComplete: () => void;
reducedMotion: boolean;
layers: CardStackLayerMotion[];
}
const CardStackContext = createContext<CardStackContextValue | null>(null);
export function useCardStack<T extends CardStackItem = CardStackItem>() {
const context = use(CardStackContext);
if (!context) {
throw new Error("CardStack primitives must be used within <CardStack>.");
}
return context as CardStackContextValue<T>;
}
interface CardStackRootProps<T extends CardStackItem> {
items: T[];
depth?: number;
autoplay?: boolean;
autoplayInterval?: number;
onItemsChange?: (items: T[]) => void;
children: ReactNode;
}
function CardStackRoot<T extends CardStackItem>({
items,
depth = DEFAULT_STACK_DEPTH,
autoplay = false,
autoplayInterval = DEFAULT_AUTOPLAY_INTERVAL,
onItemsChange,
children,
}: CardStackRootProps<T>) {
const reducedMotion = usePrefersReducedMotion();
const stackDepth = Math.min(Math.max(1, depth), STACK_LAYER_PRESETS.length);
const layers = useMemo(
() => getCardStackLayers(reducedMotion, stackDepth),
[reducedMotion, stackDepth],
);
const [itemList, setItemList] = useState(items);
const [isAnimating, setIsAnimating] = useState(false);
const [pressActive, setPressActive] = useState(false);
const [throwImpulse, setThrowImpulse] = useState<CardStackThrowImpulse | null>(null);
const isAnimatingRef = useRef(false);
const stepTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const autoplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const advanceRef = useRef<() => void>(() => {});
const onItemsChangeRef = useRef(onItemsChange);
onItemsChangeRef.current = onItemsChange;
const skipItemsChangeNotifyRef = useRef(false);
const hasUserRotatedRef = useRef(false);
const visibleItems = itemList.slice(0, stackDepth);
const activeItem = visibleItems[0];
const clearStepTimer = useCallback(() => {
if (stepTimerRef.current !== null) {
clearTimeout(stepTimerRef.current);
stepTimerRef.current = null;
}
}, []);
const clearAutoplayTimer = useCallback(() => {
if (autoplayTimerRef.current !== null) {
clearTimeout(autoplayTimerRef.current);
autoplayTimerRef.current = null;
}
}, []);
useEffect(() => {
clearStepTimer();
clearAutoplayTimer();
isAnimatingRef.current = false;
setIsAnimating(false);
skipItemsChangeNotifyRef.current = true;
setItemList(items);
}, [items, clearStepTimer, clearAutoplayTimer]);
useEffect(() => {
if (skipItemsChangeNotifyRef.current) {
skipItemsChangeNotifyRef.current = false;
return;
}
if (!hasUserRotatedRef.current) return;
onItemsChangeRef.current?.(itemList as T[]);
}, [itemList]);
const rotateOne = useCallback(() => {
hasUserRotatedRef.current = true;
setItemList((current) => {
if (current.length <= 1) return current;
const next = [...current];
next.push(next.shift()!);
return next;
});
}, []);
const finishStep = useCallback(() => {
clearStepTimer();
if (!isAnimatingRef.current) return false;
isAnimatingRef.current = false;
setIsAnimating(false);
return true;
}, [clearStepTimer]);
const scheduleAutoplay = useCallback(() => {
clearAutoplayTimer();
if (!autoplay || reducedMotion || itemList.length <= 1) return;
if (document.hidden) return;
autoplayTimerRef.current = setTimeout(() => {
autoplayTimerRef.current = null;
advanceRef.current();
}, autoplayInterval);
}, [autoplay, autoplayInterval, clearAutoplayTimer, itemList.length, reducedMotion]);
const finishStepAndScheduleAutoplay = useCallback(() => {
if (finishStep()) {
scheduleAutoplay();
}
}, [finishStep, scheduleAutoplay]);
const advance = useCallback(() => {
if (itemList.length <= 1 || isAnimatingRef.current) return;
clearAutoplayTimer();
if (reducedMotion) {
rotateOne();
scheduleAutoplay();
return;
}
isAnimatingRef.current = true;
setIsAnimating(true);
setThrowImpulse(createCardStackThrowImpulse());
clearStepTimer();
rotateOne();
stepTimerRef.current = setTimeout(finishStepAndScheduleAutoplay, STEP_COOLDOWN_MS);
}, [
clearAutoplayTimer,
clearStepTimer,
finishStepAndScheduleAutoplay,
itemList.length,
reducedMotion,
rotateOne,
scheduleAutoplay,
]);
advanceRef.current = advance;
const handleExitComplete = useCallback(() => {
finishStepAndScheduleAutoplay();
}, [finishStepAndScheduleAutoplay]);
useEffect(() => {
scheduleAutoplay();
return () => {
clearStepTimer();
clearAutoplayTimer();
};
}, [clearAutoplayTimer, clearStepTimer, scheduleAutoplay]);
useEffect(() => {
const onVisibilityChange = () => {
if (document.hidden) {
clearAutoplayTimer();
return;
}
if (!isAnimatingRef.current) {
scheduleAutoplay();
}
};
document.addEventListener("visibilitychange", onVisibilityChange);
return () => document.removeEventListener("visibilitychange", onVisibilityChange);
}, [clearAutoplayTimer, scheduleAutoplay]);
const value = useMemo(
() => ({
items: itemList,
visibleItems,
activeItem,
depth: stackDepth,
isAnimating,
pressActive,
setPressActive,
throwImpulse,
advance,
handleExitComplete,
reducedMotion,
layers,
}),
[
itemList,
visibleItems,
activeItem,
stackDepth,
isAnimating,
pressActive,
throwImpulse,
advance,
handleExitComplete,
reducedMotion,
layers,
],
);
return <CardStackContext value={value}>{children}</CardStackContext>;
}
export interface CardStackTriggerProps {
/** When true, renders an absolute inset-0 overlay for click-anywhere advance. When false (default), renders a real `<button>` for a discrete control. */
full?: boolean;
"aria-label"?: string;
className?: string;
children?: ReactNode;
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
onKeyDown?: (event: React.KeyboardEvent<HTMLElement>) => void;
onPointerDown?: (event: React.PointerEvent<HTMLElement>) => void;
onPointerUp?: (event: React.PointerEvent<HTMLElement>) => void;
onPointerLeave?: (event: React.PointerEvent<HTMLElement>) => void;
onPointerCancel?: (event: React.PointerEvent<HTMLElement>) => void;
}
function useCardStackTriggerHandlers({
isAnimating,
setPressActive,
advance,
onClick,
onKeyDown,
onPointerDown,
onPointerUp,
onPointerLeave,
onPointerCancel,
}: {
isAnimating: boolean;
setPressActive: (active: boolean) => void;
advance: () => void;
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
onKeyDown?: (event: React.KeyboardEvent<HTMLElement>) => void;
onPointerDown?: (event: React.PointerEvent<HTMLElement>) => void;
onPointerUp?: (event: React.PointerEvent<HTMLElement>) => void;
onPointerLeave?: (event: React.PointerEvent<HTMLElement>) => void;
onPointerCancel?: (event: React.PointerEvent<HTMLElement>) => void;
}) {
return {
onPointerDown: (event: React.PointerEvent<HTMLElement>) => {
onPointerDown?.(event);
if (!event.defaultPrevented && !isAnimating) {
setPressActive(true);
}
},
onPointerUp: (event: React.PointerEvent<HTMLElement>) => {
onPointerUp?.(event);
window.setTimeout(() => setPressActive(false), 0);
},
onPointerLeave: (event: React.PointerEvent<HTMLElement>) => {
onPointerLeave?.(event);
setPressActive(false);
},
onPointerCancel: (event: React.PointerEvent<HTMLElement>) => {
onPointerCancel?.(event);
setPressActive(false);
},
onClick: (event: React.MouseEvent<HTMLElement>) => {
onClick?.(event);
if (!event.defaultPrevented) {
advance();
}
},
onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented) return;
if (event.key === "Enter" || event.key === " " || event.key === "Spacebar") {
event.preventDefault();
advance();
}
},
};
}
function CardStackTrigger({
full = false,
"aria-label": ariaLabel = "Show next card",
className,
children,
onClick,
onKeyDown,
onPointerDown,
onPointerUp,
onPointerLeave,
onPointerCancel,
}: CardStackTriggerProps) {
const { advance, isAnimating, setPressActive } = useCardStack();
const handlers = useCardStackTriggerHandlers({
isAnimating,
setPressActive,
advance,
onClick,
onKeyDown,
onPointerDown,
onPointerUp,
onPointerLeave,
onPointerCancel,
});
if (full) {
return (
// biome-ignore lint/a11y/useSemanticElements: overlay sits above card content; a wrapping <button> would be invalid markup
<div
role="button"
tabIndex={0}
aria-label={ariaLabel}
{...handlers}
className={cn(
"absolute inset-0 z-40 cursor-pointer outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
className,
)}
/>
);
}
return (
<button
type="button"
aria-label={ariaLabel}
onPointerDown={handlers.onPointerDown}
onPointerUp={handlers.onPointerUp}
onPointerLeave={handlers.onPointerLeave}
onPointerCancel={handlers.onPointerCancel}
onClick={handlers.onClick}
onKeyDown={onKeyDown}
className={cn(
"inline-flex shrink-0 cursor-pointer items-center justify-center outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
className,
)}
>
{children}
</button>
);
}
function CardStackViewport({ className, children, ...props }: ComponentProps<"div">) {
return (
<div className={cn("relative w-full overflow-visible", className)} {...props}>
{children}
</div>
);
}
interface CardStackListProps<T extends CardStackItem> {
children: (item: T, index: number, layer: CardStackLayerMotion) => ReactNode;
}
function CardStackList<T extends CardStackItem>({ children }: CardStackListProps<T>) {
const { visibleItems, layers, handleExitComplete } = useCardStack<T>();
return (
<AnimatePresence initial={false} mode="sync" onExitComplete={handleExitComplete}>
{visibleItems.map((item, index) => {
const layer = layers[index]!;
const node = children(item, index, layer);
if (isValidElement<CardStackCardProps>(node) && node.type === CardStackCard) {
return cloneElement<CardStackCardProps>(node, {
key: item.id,
stackIndex: index,
layer,
});
}
return node;
})}
</AnimatePresence>
);
}
interface CardStackCardProps extends HTMLMotionProps<"div"> {
layer: CardStackLayerMotion;
stackIndex: number;
stackDepth?: number;
}
function CardStackCard({
layer,
stackIndex,
stackDepth,
className,
style,
...props
}: CardStackCardProps) {
const { depth, isAnimating, pressActive, reducedMotion, throwImpulse } = useCardStack();
const total = stackDepth ?? depth;
const initial = getCardStackInitial(stackIndex, depth, layer);
const exit = getCardStackExit(stackIndex, layer, reducedMotion, throwImpulse);
const baseScale = getLayerScale(layer);
const isPressed = stackIndex === 0 && pressActive && !reducedMotion && !isAnimating;
const animateTarget =
typeof layer.animate === "object" && layer.animate !== null && !Array.isArray(layer.animate)
? layer.animate
: {};
const animate = isPressed
? { ...animateTarget, scale: baseScale * PRESS_SCALE_FACTOR }
: layer.animate;
const transition = isPressed ? PRESS_SPRING : layer.transition;
return (
<motion.div
className={cn(
"absolute inset-x-0 top-0 w-full will-change-transform motion-reduce:transition-none",
layer.className,
className,
)}
aria-roledescription={`Card ${stackIndex + 1} of ${total}`}
style={{
transformOrigin: CARD_STACK_STACK_ORIGIN,
...layer.style,
...style,
}}
initial={initial}
animate={animate}
exit={exit}
transition={transition}
{...props}
/>
);
}
const CardStack = Object.assign(CardStackRoot, {
Trigger: CardStackTrigger,
Viewport: CardStackViewport,
List: CardStackList,
Card: CardStackCard,
}) as typeof CardStackRoot & {
Trigger: typeof CardStackTrigger;
Viewport: typeof CardStackViewport;
List: typeof CardStackList;
Card: typeof CardStackCard;
};
export default CardStack;
export {
CardStack,
CardStackCard,
CardStackList,
CardStackRoot,
CardStackTrigger,
CardStackViewport,
};Usage
CardStack is bare bones: rotation state, layered motion, and advance controls. You bring the card markup and styling.
Pass items with stable ids, set depth (1–3 visible cards), and render each card in CardStack.List. Extend the item type with whatever fields your UI needs.
import CardStack, { type CardStackItem } from "@/animata/card/card-stack";
type Person = CardStackItem & { name: string; role: string };
const items: Person[] = [
{ id: "hari", name: "Hari", role: "Engineer" },
// …
];
<CardStack items={items} depth={3}>
<section className="relative mx-auto w-full max-w-sm">
<CardStack.Viewport className="min-h-96">
<CardStack.List>
{(item, index, layer) => (
<CardStack.Card key={item.id} layer={layer} stackIndex={index} className="rounded-2xl bg-card p-4 shadow-lg">
<p className="font-medium">{item.name}</p>
<p className="text-sm text-muted-foreground">{item.role}</p>
{index === 0 ? (
<CardStack.Trigger aria-label="Show next card">Next</CardStack.Trigger>
) : null}
</CardStack.Card>
)}
</CardStack.List>
</CardStack.Viewport>
</section>
</CardStack>CardStack.Trigger defaults to a real <button>. Wrap an icon or label and place it where you want. Only put it on the front card (index === 0) so back cards stay inert.
For click-anywhere advance, pass full and render the trigger as a sibling after CardStack.Viewport inside a relative container:
<section className="relative ...">
<CardStack.Viewport>...</CardStack.Viewport>
<CardStack.Trigger full aria-label="Show next card" />
</section>Hook onItemsChange on the root if you want the rotated array. useCardStack() exposes activeItem, advance, and layer presets.
The Storybook preview composes a profile card demo on top of the stack (masked photos, header, metrics). That layout lives in the story source — copy and adapt it, or build your own card shell inside CardStack.Card.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | T[] | — | Array of items with stable |
depth | number | 3 | Visible cards in the stack (1–3). |
autoplay | boolean | false | Auto-advance when the tab is visible. |
autoplayInterval | number | 4500 | Milliseconds between autoplay steps. |
onItemsChange | (items: T[]) => void | — | Called after each rotation with the reordered array. |
| Prop | Type | Default | Description |
|---|---|---|---|
full | boolean | false | When true, renders an absolute inset-0 overlay for click-anywhere advance. When false, renders a real |
aria-label | string | "Show next card" | Accessible name. Required for the |
How it works
The root holds the full items array but only renders the first depth entries. On advance, the front item moves to the back. Same cards, infinite loop, no full remount.
CardStack.List uses AnimatePresence with mode="sync". Click and the front card exits while the cards behind move up into the next layer at the same time. One shuffle, not exit-then-enter.
Stacking order comes from Motion zIndex, not Tailwind z-*: front at 20, middle at 5, back at 0. On exit the front card jumps to 30 so it slides out over the stack rising underneath.
Only one shuffle runs at a time. Extra clicks during the animation are dropped.
The back slot peeks in from a smaller offset when a card first becomes visible. Cards moving up from the middle use initial={false} so they tween from where they already are instead of popping in again.
With prefers-reduced-motion, rotation is instant and durations go to zero.
Changelog
2026-06-12
- Trimmed the registry component to stack mechanics only:
CardStack,Viewport,List,Card, andTrigger. You supply card markup and styling;CardStackItemis{ id: string }plus whatever fields your UI needs. - Moved profile card UI (SVG masks, header, avatar, media, metrics) out of the registry bundle into the Storybook demo composition (
card-stack-profile.tsxin the repo, not shipped via CLI). - Added
fullonCardStack.Trigger: default is a real<button>for a discrete control;fullrenders an absolute inset-0 overlay for click-anywhere advance inside arelativecontainer. - Fixed invalid HTML from wrapping card flow content in a
<button>.
Credits
Built by hari