컴포넌트
카드 스택
쌓인 카드를 좌우로 밀어 결정하고 되돌릴 수 있어요.
- #arc
- #component
- #swipe
- #cards
- #gesture
- #decision
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/card-stack.json파일
components/arc/card-stack/card-stack.module.csscomponents/arc/card-stack/card-stack.tsx
npm 패키지
motion@^14.2.0
함께 오는 항목
card-stack.module.csscard-stack.module.csscard-stack.module.csscss
.root { --card-height: 19rem; --stack-peek: 24px; display: grid; width: 100%; justify-items: center; gap: var(--space-5); font-family: var(--font-body); letter-spacing: var(--tracking-body); }
/* The stage reserves the top card plus the edges peeking out below it, so nothing reflows as cards come and go. */
.stage { position: relative; isolation: isolate; width: min(100%, 20rem); height: calc(var(--card-height) + var(--stack-peek)); outline: none; }
/* Each slot scales from its bottom edge, so the cards behind show a thin, even edge below the one in front. */
.slot { position: absolute; top: 0; right: 0; left: 0; height: var(--card-height); pointer-events: none; transform-origin: 50% 100%; }
.card { position: absolute; inset: 0; border-radius: var(--radius-surface); pointer-events: auto; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; }
.card[data-top] { cursor: grab; }
.card[data-dragging] { cursor: grabbing; }
.card:not([data-top]) { pointer-events: none; }
/* Resting cards sit flat on their borders; a card in the hand or in flight floats and casts a shadow. */
.card::before { position: absolute; inset: 0; border-radius: inherit; box-shadow: var(--shadow-floating); content: ""; opacity: 0; pointer-events: none; transition: opacity var(--duration-fast) var(--ease-standard); }
.card[data-dragging]::before, .card[data-flying]::before { opacity: 1; }
.face { position: relative; height: 100%; overflow: hidden; border: 1px solid var(--border); border-radius: inherit; background: var(--surface); color: var(--foreground); }
.shade { position: absolute; inset: 0; background: oklch(0% 0 0 / .035); pointer-events: none; }
:global(:root[data-theme="dark"]) .shade { background: oklch(0% 0 0 / .22); }
.stamp { position: absolute; top: var(--space-4); display: inline-flex; height: 2rem; align-items: center; gap: 6px; padding: 0 var(--space-3) 0 10px; border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--surface-raised); color: var(--foreground); font-size: var(--text-sm); font-weight: 500; white-space: nowrap; pointer-events: none; }
.stamp[data-side="right"] { left: var(--space-4); border-color: transparent; background: color-mix(in oklab, var(--success) 9%, var(--surface-raised)); }
.stamp[data-side="right"] svg { color: var(--success); }
.stamp[data-side="left"] { right: var(--space-4); }
.stamp[data-side="left"] svg { color: var(--text-secondary); }
/* Cards returning to the back of the stack carry no verdict. */
.card:not([data-top]):not([data-flying]) .stamp { visibility: hidden; }
/* Keyboard focus on the stage outlines the top card instead of the whole stage. */
.stage:focus-visible { --focus-outline-width: 0px; }
.stage:focus-visible .card[data-top], .stage:focus-visible .empty { outline: 2px solid var(--focus-outline, color-mix(in oklab, var(--accent-strong) 72%, transparent)); outline-offset: 3px; }
.empty { position: absolute; top: 0; right: 0; left: 0; display: grid; height: var(--card-height); place-content: center; justify-items: center; gap: var(--space-3); padding: var(--space-6); border: 1px solid var(--border); border-radius: var(--radius-surface); background: var(--surface); text-align: center; }
.emptyTitle { margin: 0; font-size: var(--text-base); font-weight: 500; }
.controls { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-2); }
.control { display: inline-flex; min-height: var(--control-height-md); align-items: center; justify-content: center; gap: var(--space-2); padding: 0 var(--space-4); border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard); }
.control svg { flex: none; }
/* The approving action follows the selected accent. */
.control[data-tone="primary"] { border-color: var(--accent); background: var(--accent); color: var(--accent-foreground); }
.control[data-tone="quiet"] { border-color: transparent; background: transparent; color: var(--text-secondary); }
@media (hover: hover) and (pointer: fine) {
.control:not([aria-disabled]):hover { background: var(--surface-muted); }
.control[data-tone="primary"]:not([aria-disabled]):hover { border-color: var(--accent-strong); background: var(--accent-strong); }
.control[data-tone="quiet"]:not([aria-disabled]):hover { color: var(--foreground); }
}
.control:not([aria-disabled]):active { background: var(--surface-muted); }
.control[data-tone="primary"]:not([aria-disabled]):active { border-color: var(--accent-strong); background: var(--accent-strong); }
.control[aria-disabled] { cursor: default; opacity: .42; }
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (max-width: 26rem) {
.root { --card-height: 17.5rem; gap: var(--space-4); }
.controls { flex-wrap: nowrap; }
.control { padding-inline: var(--space-3); }
}
/* On the narrowest screens Undo keeps its icon and its accessible name, so all three controls stay on one row. */
@media (max-width: 22.5rem) {
.control[data-tone="quiet"] { width: var(--control-height-md); padding: 0; }
.control[data-tone="quiet"] .controlLabel { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
}
@media (prefers-reduced-motion: reduce) {
.card::before, .control { transition: none; }
}
card-stack.tsxcard-stack.tsxcard-stack.tsxtsx
"use client";
import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
import type { KeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode } from "react";
import { AnimatePresence, animate, motion, useMotionValue, useSpring, useTransform } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import type { MotionValue } from "motion/react";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./card-stack.module.css";
import { playUiSound } from "../sound/play";
import { UiIcon } from "../theme/ui-icon";
export type CardDecision = "left" | "right";
/**
* A deck of cards reviewed one at a time: drag or flick the top card left or right, or use the buttons and arrow keys.
* Use it for quick, one-by-one triage where each item deserves a moment of attention: shortlisting destinations, candidates, or design options.
* Use a list or table when people need to compare items side by side.
*/
export interface CardStackProps<T> {
items: T[];
getKey: (item: T) => string;
/** A short name for the card, used in its accessible label and announcements, such as "제주". */
getLabel: (item: T) => string;
renderCard: (item: T) => ReactNode;
onDecide?: (item: T, decision: CardDecision) => void;
onUndo?: (item: T, decision: CardDecision) => void;
onReset?: () => void;
/** Action names for each side, used on the buttons and the stamps that appear while dragging. */
labels?: { left: string; right: string };
/** Past tense of each action for announcements, such as "넘겼어요" and "담아 뒀어요". */
outcomes?: { left: string; right: string };
/** Accessible name for the stack. */
label?: string;
/** Shown once every card has been decided. Receives a function that brings all cards back. */
renderEmpty?: (reset: () => void) => ReactNode;
className?: string;
}
type Point = { x: number; y: number };
type Exit = Point & { tilt: number };
type Return = Exit & { delay: number };
/** Cards rendered in the stack. The last one waits invisibly one step behind and fades in as it rises. */
const VISIBLE = 4;
/** How far each card behind peeks out below the one in front, and how much smaller it is. */
const PEEK = 12;
const SHRINK = .05;
/** How far the cards behind rise while the top card is dragged away, in stack steps. */
const LIFT = .4;
/** Drag distance at which the cards behind have fully risen. */
const LIFT_DISTANCE = 160;
/** Degrees of rotation per pixel of horizontal travel. */
const ROTATE = .065;
/** A release faster than this (px/s) throws the card even when it has only moved a little. */
const FLICK = 500;
/** Vertical travel is not a decision, so past a small free zone it resists like a rubber band. */
const FREE_Y = 48;
const STRETCH_Y = 140;
const noop = () => () => {};
const resistY = (raw: number) => {
const distance = Math.abs(raw);
return distance <= FREE_Y ? raw : Math.sign(raw) * (FREE_Y + (1 - 1 / ((distance - FREE_Y) * .55 / STRETCH_Y + 1)) * STRETCH_Y);
};
const unresistY = (shown: number) => {
const distance = Math.abs(shown);
if (distance <= FREE_Y) return shown;
const stretch = Math.min(distance - FREE_Y, STRETCH_Y - 1);
return Math.sign(shown) * (FREE_Y + (1 / (1 - stretch / STRETCH_Y) - 1) * STRETCH_Y / .55);
};
const clampUnit = (value: number) => Math.min(1, Math.max(0, value));
const throwSpring = { type: "spring", visualDuration: .5, bounce: 0 } as const;
const fadeOut = { duration: motionTokens.duration.standard, delay: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } as const;
const reducedFade = { duration: motionTokens.duration.fast, ease: "linear" } as const;
function velocityOf(samples: { t: number; x: number; y: number }[], now: number): Point {
const recent = samples.filter(sample => now - sample.t <= 80);
const first = recent[0], last = recent[recent.length - 1];
if (!first || !last || first === last || now - last.t > 60) return { x: 0, y: 0 };
const seconds = (last.t - first.t) / 1000;
return { x: (last.x - first.x) / seconds, y: (last.y - first.y) / seconds };
}
interface CardProps {
id: string;
depth: number;
decision: CardDecision | null;
returnFrom?: Return;
interactive: boolean;
zIndex: number;
label: string;
labels: { left: string; right: string };
lift: MotionValue<number>;
reduced: boolean;
fling: (id: string) => Point | undefined;
onRelease: (id: string, offset: Point, velocity: Point, width: number) => boolean;
onThrown: (id: string, exit: Exit) => void;
onGone: (id: string) => void;
children: ReactNode;
}
function StackCard({ id, depth, decision, returnFrom, interactive, zIndex, label, labels, lift, reduced, fling, onRelease, onThrown, onGone, children }: CardProps) {
const cardRef = useRef<HTMLDivElement>(null);
// A card brought back by undo starts where it left the screen; with reduced motion it simply fades in at home.
const travel = returnFrom && !reduced ? returnFrom : undefined;
const x = useMotionValue(travel?.x ?? 0);
const y = useMotionValue(travel?.y ?? 0);
const tilt = useMotionValue(travel?.tilt ?? 1);
const opacity = useMotionValue(returnFrom ? 0 : 1);
const rotate = useTransform([x, tilt], ([offset = 0, sign = 0]: number[]) => offset * ROTATE * sign);
// Depth springs toward its slot; while the top card is dragged away, the cards behind already start to rise.
const depthTarget = useTransform(lift, value => depth - (depth > 0 ? value * LIFT : 0));
const depthSpring = useSpring(depthTarget, motionTokens.spring.smooth);
const shown = reduced ? depthTarget : depthSpring;
const scale = useTransform(shown, value => 1 - value * SHRINK);
const lower = useTransform(shown, value => value * PEEK);
const presence = useTransform(shown, [VISIBLE - 2, VISIBLE - 1], [1, 0]);
const shade = useTransform(shown, [0, 2], [0, 1]);
const keep = useTransform(x, [16, 96], [0, 1]);
const pass = useTransform(x, [-96, -16], [1, 0]);
const drag = useRef<{ ox: number; oy: number; samples: { t: number; x: number; y: number }[] } | null>(null);
const settled = useRef(!returnFrom);
const wasDecided = useRef(false);
// A card flying home from undo or start over leaves the stack alone: the cards behind make room in one motion instead of
// pausing half-risen until it lands. Grabbing it mid-flight hands the stack back to the drag.
const homing = useRef(false);
// The top card reports how far it has travelled, so the stack behind responds to the drag and the throw.
useEffect(() => {
if (!interactive) return;
const update = () => { if (!homing.current) lift.set(clampUnit(Math.hypot(x.get(), y.get() * .5) / LIFT_DISTANCE)); };
const offX = x.on("change", update), offY = y.on("change", update);
return () => { offX(); offY(); };
}, [interactive, lift, x, y]);
useLayoutEffect(() => {
if (decision) {
// A key or button can decide the card mid-drag; the throw takes over from the finger.
drag.current = null;
homing.current = false;
cardRef.current?.removeAttribute("data-dragging");
wasDecided.current = true;
const direction = decision === "right" ? 1 : -1;
const width = cardRef.current?.offsetWidth ?? 320;
const cx = x.get(), cy = y.get();
// Buttons and keys throw with a default flick; a drag keeps the finger's velocity.
const velocity = fling(id) ?? { x: direction * 900, y: -120 };
const speed = Math.hypot(velocity.x, velocity.y);
// Leave along the flick when it heads the chosen way, otherwise along the drag, otherwise straight out.
let ux = velocity.x, uy = velocity.y;
if (speed < 200 || Math.sign(ux) !== direction) { ux = Math.abs(cx) > 8 ? cx : direction; uy = Math.abs(cx) > 8 ? cy * .5 : -.12; }
const length = Math.hypot(ux, uy) || 1;
ux /= length; uy /= length;
if (Math.abs(ux) < .45) { ux = direction * .45; uy = Math.sign(uy || -1) * Math.sqrt(1 - .45 * .45); }
// Far enough to clear a typical preview or card column; the fade finishes before the spring does.
const reach = width * 1.6;
const exit = { x: cx + ux * reach, y: cy + uy * reach, tilt: tilt.get() };
onThrown(id, exit);
if (reduced) { animate(opacity, 0, { ...reducedFade, onComplete: () => onGone(id) }); return; }
animate(x, exit.x, { ...throwSpring, velocity: velocity.x });
animate(y, exit.y, { ...throwSpring, velocity: velocity.y });
animate(opacity, 0, { ...fadeOut, onComplete: () => onGone(id) });
return;
}
// Undo, or a first mount from where the card left: come back from there, catching any flight in progress.
if (!wasDecided.current && settled.current) return;
wasDecided.current = false;
settled.current = true;
const delay = returnFrom?.delay ?? 0;
if (reduced) { x.jump(0); y.jump(0); animate(opacity, 1, { ...reducedFade, delay }); return; }
homing.current = true;
animate(x, 0, { ...motionTokens.spring.smooth, delay, onComplete: () => { homing.current = false; } });
animate(y, 0, { ...motionTokens.spring.smooth, delay });
animate(opacity, 1, { duration: motionTokens.duration.fast, delay, ease: [...motionTokens.ease.enter] });
// Only the decision drives this effect; the return position is read once at that moment.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [decision]);
function down(event: ReactPointerEvent<HTMLDivElement>) {
if (!interactive || event.button !== 0 || !event.isPrimary) return;
if (event.target instanceof Element && event.target.closest("button, a, input, select, textarea, [role='button']")) return;
x.stop(); y.stop();
homing.current = false;
// Grabbing the top half tips the card the way the hand moves; grabbing the bottom half tips it the other way.
if (Math.abs(x.get()) < 2) {
const rect = event.currentTarget.getBoundingClientRect();
tilt.set(event.clientY < rect.top + rect.height / 2 ? 1 : -1);
}
drag.current = { ox: event.clientX - x.get(), oy: event.clientY - unresistY(y.get()), samples: [{ t: event.timeStamp, x: event.clientX, y: event.clientY }] };
event.currentTarget.setPointerCapture(event.pointerId);
event.currentTarget.setAttribute("data-dragging", "");
}
function move(event: ReactPointerEvent<HTMLDivElement>) {
const state = drag.current;
if (!state || !interactive) return;
x.set(event.clientX - state.ox);
y.set(resistY(event.clientY - state.oy));
state.samples.push({ t: event.timeStamp, x: event.clientX, y: event.clientY });
if (state.samples.length > 12) state.samples.shift();
}
function up(event: ReactPointerEvent<HTMLDivElement>) {
const state = drag.current;
drag.current = null;
event.currentTarget.removeAttribute("data-dragging");
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
if (!state || !interactive) return;
const velocity = event.type === "pointercancel" ? { x: 0, y: 0 } : velocityOf(state.samples, event.timeStamp);
if (onRelease(id, { x: x.get(), y: y.get() }, velocity, event.currentTarget.offsetWidth)) return;
// Below the threshold the card springs home, keeping the release velocity so it settles instead of stopping dead.
if (reduced) { x.jump(0); y.jump(0); return; }
animate(x, 0, { ...motionTokens.spring.morph, velocity: velocity.x });
animate(y, 0, { ...motionTokens.spring.morph, velocity: velocity.y });
}
const flying = decision !== null;
return <motion.div className={styles.slot} style={{ zIndex, scale, y: lower, opacity: presence }} aria-hidden={interactive ? undefined : true} inert={!interactive}>
<motion.div
ref={cardRef}
className={styles.card}
style={{ x, y, rotate, opacity }}
data-top={interactive ? "" : undefined}
data-flying={flying ? "" : undefined}
role={interactive ? "group" : undefined}
aria-label={interactive ? label : undefined}
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
>
<div className={styles.face}>
{children}
<motion.span className={styles.shade} style={{ opacity: shade }} aria-hidden="true" />
</div>
<motion.span className={styles.stamp} data-side="right" style={{ opacity: keep }} aria-hidden="true"><UiIcon iconRole="check" size={16} strokeWidth={2} />{labels.right}</motion.span>
<motion.span className={styles.stamp} data-side="left" style={{ opacity: pass }} aria-hidden="true"><UiIcon iconRole="close" size={16} strokeWidth={2} />{labels.left}</motion.span>
</motion.div>
</motion.div>;
}
function Control({ children, onClick, disabled, reduced, tone }: { children: ReactNode; onClick: () => void; disabled: boolean; reduced: boolean; tone?: "primary" | "quiet" }) {
// aria-disabled keeps focus on the button when the stack runs out, instead of dropping it to the page.
return <motion.button type="button" className={styles.control} data-tone={tone} aria-disabled={disabled || undefined} onClick={() => { if (!disabled) onClick(); }} whileTap={{ scale: reduced || disabled ? 1 : .97 }} transition={motionTokens.spring.snappy}>{children}</motion.button>;
}
/** Renders a custom empty state; the reset handler travels as a prop so render never touches the stack's refs. */
function EmptySlot({ render, onReset }: { render: (reset: () => void) => ReactNode; onReset: () => void }) { return <>{render(onReset)}</>; }
export function CardStack<T>({ items, getKey, getLabel, renderCard, onDecide, onUndo, onReset, labels = { left: "넘기기", right: "담아 두기" }, outcomes, label = "카드", renderEmpty, className }: CardStackProps<T>) {
const hydrated = useSyncExternalStore(noop, () => true, () => false);
const reduced = !!useReducedMotion() && hydrated;
const hintId = useId();
const [position, setPosition] = useState(0);
const [history, setHistory] = useState<{ key: string; decision: CardDecision }[]>([]);
const [flying, setFlying] = useState<string[]>([]);
const [returning, setReturning] = useState<Record<string, Return>>({});
const [message, setMessage] = useState("");
const lift = useMotionValue(0);
// Where each thrown card left the screen, so undo and start over can bring it back from there. Only those handlers read it,
// so it lives in a ref: recording an exit must not re-render the stack in the very frame the throw starts.
const exits = useRef<Record<string, Exit>>({});
const flings = useRef(new Map<string, Point>());
const top = items[position];
const remaining = Math.max(0, items.length - position);
function decide(decision: CardDecision) {
if (!top) return;
playUiSound("tick");
const key = getKey(top);
lift.set(0);
setPosition(position + 1);
setHistory([...history, { key, decision }]);
setFlying(current => [...current, key]);
setReturning({});
const left = remaining - 1;
setMessage(`${getLabel(top)}${outcomes ? ` ${outcomes[decision]}` : `: ${labels[decision]}`}. ${left ? `${left}장 남았어요.` : "모든 카드를 확인했어요."}`);
onDecide?.(top, decision);
}
function undo() {
const last = history[history.length - 1];
const item = last && items.find(entry => getKey(entry) === last.key);
if (!last || !item) return;
playUiSound("tick");
const exit = exits.current[last.key];
setPosition(position - 1);
setHistory(history.slice(0, -1));
setFlying(current => current.filter(key => key !== last.key));
setReturning(exit ? { [last.key]: { ...exit, delay: 0 } } : {});
setMessage(`${getLabel(item)} 카드를 되돌렸어요.`);
onUndo?.(item, last.decision);
}
function reset() {
if (!history.length) return;
playUiSound("tick");
// The deck deals itself back: the card that will sit deepest returns first, the top card last.
const count = Math.min(VISIBLE, items.length);
const back: Record<string, Return> = {};
items.slice(0, count).forEach((item, index) => {
const key = getKey(item), exit = exits.current[key];
if (exit && index < position) back[key] = { ...exit, delay: (count - 1 - index) * .06 };
});
setPosition(0);
setHistory([]);
setFlying([]);
setReturning(back);
setMessage("모든 카드를 처음 자리로 되돌렸어요.");
onReset?.();
}
function release(key: string, offset: Point, velocity: Point, width: number) {
const direction = Math.sign(offset.x);
if (!direction || !top || getKey(top) !== key) return false;
const flick = Math.abs(velocity.x) > FLICK && Math.sign(velocity.x) === direction && Math.abs(offset.x) > 12;
const far = Math.abs(offset.x) > Math.min(width * .35, 140) && !(Math.sign(velocity.x) === -direction && Math.abs(velocity.x) > 300);
if (!flick && !far) return false;
flings.current.set(key, velocity);
decide(direction > 0 ? "right" : "left");
return true;
}
function key(event: KeyboardEvent<HTMLDivElement>) {
if (event.target !== event.currentTarget) return;
if (event.key === "ArrowLeft" || event.key === "ArrowRight") { event.preventDefault(); decide(event.key === "ArrowRight" ? "right" : "left"); }
else if (event.key === "Backspace" || ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z")) { event.preventDefault(); undo(); }
}
const decided = new Map(history.map(entry => [entry.key, entry.decision]));
const windowKeys = new Set(items.slice(position, position + VISIBLE).map(getKey));
const rendered = items.filter(item => windowKeys.has(getKey(item)) || flying.includes(getKey(item)));
const empty = position >= items.length && flying.length === 0;
return <div className={[styles.root, className].filter(Boolean).join(" ")}>
<div className={styles.stage} role="group" aria-roledescription="카드 더미" aria-label={label} aria-describedby={hintId} tabIndex={0} onKeyDown={key}>
{rendered.map(item => {
const id = getKey(item), index = items.indexOf(item), decision = decided.get(id) ?? null;
const depth = decision ? 0 : index - position;
return <StackCard
key={id}
id={id}
depth={depth}
decision={decision}
returnFrom={returning[id]}
interactive={!decision && depth === 0}
zIndex={decision ? 20 + flying.indexOf(id) : VISIBLE - depth}
label={`${getLabel(item)}, 전체 ${items.length}장 중 ${index + 1}번째`}
labels={labels}
lift={lift}
reduced={reduced}
fling={cardKey => { const velocity = flings.current.get(cardKey); flings.current.delete(cardKey); return velocity; }}
onRelease={release}
onThrown={(cardKey, exit) => { exits.current[cardKey] = exit; }}
onGone={cardKey => setFlying(current => current.filter(entry => entry !== cardKey))}
>{renderCard(item)}</StackCard>;
})}
<AnimatePresence initial={false}>
{empty && <motion.div key="empty" className={styles.empty} initial={reduced ? { opacity: 0 } : { opacity: 0, y: 8, filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={{ opacity: 0, transition: { duration: motionTokens.duration.instant } }} transition={reduced ? reducedFade : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>
{renderEmpty ? <EmptySlot render={renderEmpty} onReset={reset} /> : <>
<p className={styles.emptyTitle}>모든 카드를 확인했어요</p>
<button type="button" className={styles.control} onClick={reset}><UiIcon iconRole="reset" size={16} strokeWidth={1.75} aria-hidden="true" />처음부터 다시 보기</button>
</>}
</motion.div>}
</AnimatePresence>
</div>
<div className={styles.controls}>
<Control onClick={() => decide("left")} disabled={!top} reduced={reduced}><UiIcon iconRole="close" size={16} strokeWidth={1.75} aria-hidden="true" />{labels.left}</Control>
<Control onClick={undo} disabled={!history.length} reduced={reduced} tone="quiet"><UiIcon iconRole="reset" size={16} strokeWidth={1.75} aria-hidden="true" /><span className={styles.controlLabel}>되돌리기</span></Control>
<Control onClick={() => decide("right")} disabled={!top} reduced={reduced} tone="primary"><UiIcon iconRole="check" size={16} strokeWidth={1.75} aria-hidden="true" />{labels.right}</Control>
</div>
<p id={hintId} className={styles.srOnly}>맨 위 카드를 끌거나 왼쪽·오른쪽 화살표 키로 결정해요. 왼쪽은 {labels.left}, 오른쪽은 {labels.right}이에요. 백스페이스 키로 마지막 결정을 되돌릴 수 있어요.</p>
<p className={styles.srOnly} role="status" aria-live="polite">{message}</p>
</div>;
}
export default CardStack;