"use client";
import * as React from "react";
import { motion, useMotionValue, useReducedMotion, useTransform, type PanInfo, type Transition } from "motion/react";
export interface CardStackItem {
id: string | number;
content: React.ReactNode;
/** Short name announced with the position, e.g. "Card 2 of 5: Pricing". */
label?: string;
}
type SpringConfig = { stiffness: number; damping: number; mass: number };
export interface CardStackProps extends Omit<React.ComponentPropsWithoutRef<"section">, "children"> {
/** Cards to show. Alternatively pass each card as a child. */
items?: CardStackItem[];
children?: React.ReactNode;
/** Vertical offset between stacked cards in px. */
spacing?: number;
/** Rotation in degrees added per card of depth (alternating direction). */
rotation?: number;
/** Scale reduction per card of depth. */
scaleStep?: number;
/** How many cards peek out behind the front card. */
visible?: number;
/** Wrap around at the ends. */
loop?: boolean;
spring?: SpringConfig;
/** Drag distance in px needed to change card (fast flicks also count). */
threshold?: number;
/** Show previous/next buttons and the position counter. */
showControls?: boolean;
accent?: string;
/** Accessible name of the carousel. */
label?: string;
defaultIndex?: number;
onIndexChange?: (index: number) => void;
/** Extra classes for every card surface. */
cardClassName?: string;
}
interface CardProps {
depth: number;
total: number;
isFront: boolean;
spacing: number;
rotation: number;
scaleStep: number;
visible: number;
transition: Transition;
reduceMotion: boolean;
draggable: boolean;
onDragEnd: (info: PanInfo) => void;
className: string;
ariaLabel: string;
children: React.ReactNode;
}
function StackCard({
depth,
total,
isFront,
spacing,
rotation,
scaleStep,
visible,
transition,
reduceMotion,
draggable,
onDragEnd,
className,
ariaLabel,
children,
}: CardProps) {
const x = useMotionValue(0);
// Tilt follows the drag like a card held at its base.
const tilt = useTransform(x, [-260, 0, 260], reduceMotion ? [0, 0, 0] : [-12, 0, 12]);
let target;
if (depth < 0) {
// Already passed (non-looping stacks): slip away to the left.
target = { x: -90, y: 0, scale: 0.94, rotateZ: -8, opacity: 0 };
} else {
const d = Math.min(depth, visible);
target = {
x: 0,
y: d * spacing,
scale: 1 - d * scaleStep,
rotateZ: depth === 0 ? 0 : (depth % 2 ? 1 : -1) * rotation * Math.min(depth, visible),
opacity: depth > visible ? 0 : 1 - d * 0.08,
};
}
return (
<motion.div
role="group"
aria-roledescription="card"
aria-label={ariaLabel}
aria-hidden={!isFront}
inert={!isFront}
className={`absolute inset-0 overflow-hidden rounded-2xl border border-zinc-900/10 bg-white shadow-[0_1px_0_rgba(255,255,255,0.6)_inset,0_24px_48px_-28px_rgba(0,0,0,0.45)] dark:border-white/10 dark:bg-zinc-900 dark:shadow-[0_1px_0_rgba(255,255,255,0.06)_inset,0_24px_48px_-24px_rgba(0,0,0,0.8)] ${
draggable ? "cursor-grab touch-pan-y active:cursor-grabbing" : ""
} ${isFront ? "" : "pointer-events-none"} ${className}`}
style={{ x, rotate: tilt, zIndex: total - Math.abs(depth), transformOrigin: "50% 85%" }}
initial={false}
animate={target}
transition={transition}
drag={draggable ? "x" : false}
dragSnapToOrigin
dragElastic={0.65}
onDragEnd={(_, info) => onDragEnd(info)}
>
{children}
</motion.div>
);
}
/**
* A stack of cards in perspective. Swipe or drag the front card, use the arrow keys on the
* focused stack, or the buttons; the current position is announced politely.
*/
export function CardStack({
items,
children,
spacing = 14,
rotation = 3,
scaleStep = 0.05,
visible = 3,
loop = true,
spring = { stiffness: 320, damping: 30, mass: 0.9 },
threshold = 90,
showControls = true,
accent = "#FF7448",
label = "Cards",
defaultIndex = 0,
onIndexChange,
cardClassName = "",
className = "",
style,
...props
}: CardStackProps) {
const reduceMotion = useReducedMotion() ?? false;
const hintId = React.useId();
const cards: CardStackItem[] =
items ??
React.Children.toArray(children).map((child, i) => ({
id: React.isValidElement(child) && child.key !== null ? child.key : i,
content: child,
}));
const total = cards.length;
const [index, setIndex] = React.useState(() => Math.max(0, Math.min(defaultIndex, Math.max(0, total - 1))));
const [announce, setAnnounce] = React.useState(false);
const current = total ? Math.min(index, total - 1) : 0;
const go = (next: number) => {
if (!total) return;
const target = loop ? ((next % total) + total) % total : Math.max(0, Math.min(total - 1, next));
if (target === current) return;
setIndex(target);
setAnnounce(true);
onIndexChange?.(target);
};
const canPrev = loop ? total > 1 : current > 0;
const canNext = loop ? total > 1 : current < total - 1;
const handleDragEnd = (info: PanInfo) => {
const swipe = Math.abs(info.offset.x) > threshold || Math.abs(info.velocity.x) > 650;
if (!swipe) return;
// Throwing the card left reveals the next one; throwing it right brings the previous back.
if (info.offset.x < 0 && canNext) go(current + 1);
else if (info.offset.x > 0 && canPrev) go(current - 1);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.target !== event.currentTarget) return;
const keys: Record<string, () => void> = {
ArrowLeft: () => go(current - 1),
ArrowRight: () => go(current + 1),
Home: () => go(0),
End: () => go(total - 1),
};
const action = keys[event.key];
if (!action) return;
event.preventDefault();
action();
};
const transition: Transition = reduceMotion ? { duration: 0 } : { type: "spring", ...spring };
const position = (i: number) => `Card ${i + 1} of ${total}${cards[i]?.label ? `: ${cards[i].label}` : ""}`;
const pad = (n: number) => String(n).padStart(2, "0");
return (
<section
aria-roledescription="carousel"
aria-label={label}
className={`w-full ${className}`}
style={{ ["--cs-accent" as string]: accent, ...style }}
{...props}
>
<div
tabIndex={0}
aria-describedby={hintId}
onKeyDown={handleKeyDown}
className="relative aspect-[4/5] w-full rounded-2xl outline-none [perspective:1200px] focus-visible:ring-2 focus-visible:ring-[var(--cs-accent)] focus-visible:ring-offset-4 focus-visible:ring-offset-transparent"
style={{ marginBottom: Math.min(visible, Math.max(0, total - 1)) * spacing + 8 }}
>
{cards.map((card, i) => {
const depth = loop ? (((i - current) % total) + total) % total : i - current;
const isFront = depth === 0;
return (
<StackCard
key={card.id}
depth={depth}
total={total}
isFront={isFront}
spacing={spacing}
rotation={rotation}
scaleStep={scaleStep}
visible={visible}
transition={transition}
reduceMotion={reduceMotion}
draggable={isFront && total > 1}
onDragEnd={handleDragEnd}
className={cardClassName}
ariaLabel={position(i)}
>
{card.content}
</StackCard>
);
})}
</div>
<p id={hintId} className="sr-only">
Use the left and right arrow keys, or swipe the front card, to move through the stack.
</p>
<p className="sr-only" aria-live="polite" aria-atomic="true">
{announce && total ? position(current) : ""}
</p>
{showControls && total > 1 ? (
<div className="mt-4 flex items-center justify-between gap-3">
<button
type="button"
aria-label="Previous card"
disabled={!canPrev}
onClick={() => go(current - 1)}
className="inline-flex size-9 items-center justify-center rounded-full border border-zinc-900/15 text-zinc-700 transition-colors hover:border-zinc-900/40 hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--cs-accent)] disabled:cursor-not-allowed disabled:opacity-35 dark:border-white/15 dark:text-zinc-300 dark:hover:border-white/40 dark:hover:text-white"
>
<svg viewBox="0 0 12 12" className="size-3" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path d="M7.5 2.5 4 6l3.5 3.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<div className="flex items-center gap-3" aria-hidden="true">
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 tabular-nums dark:text-zinc-400">
<span className="text-zinc-900 dark:text-zinc-100">{pad(current + 1)}</span> / {pad(total)}
</span>
<span className="flex items-center gap-1">
{cards.map((card, i) => (
<span
key={card.id}
className={`h-1 rounded-full transition-all duration-300 ${
i === current ? "w-4 bg-[var(--cs-accent)]" : "w-1 bg-zinc-900/20 dark:bg-white/20"
}`}
/>
))}
</span>
</div>
<button
type="button"
aria-label="Next card"
disabled={!canNext}
onClick={() => go(current + 1)}
className="inline-flex size-9 items-center justify-center rounded-full border border-zinc-900/15 text-zinc-700 transition-colors hover:border-zinc-900/40 hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--cs-accent)] disabled:cursor-not-allowed disabled:opacity-35 dark:border-white/15 dark:text-zinc-300 dark:hover:border-white/40 dark:hover:text-white"
>
<svg viewBox="0 0 12 12" className="size-3" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path d="M4.5 2.5 8 6 4.5 9.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
) : null}
</section>
);
}