Fan out card stack
A stack of payment cards fans out in an arc on hover or tap; pick one and it slides to the front.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useState, type FocusEvent } from "react";
import styles from "./demo.module.css";
const CARDS = [
{ id: "everyday", name: "Everyday", last4: "4821", tone: "ink" },
{ id: "travel", name: "Travel", last4: "0937", tone: "teal" },
{ id: "savings", name: "Savings", last4: "5560", tone: "sky" },
{ id: "business", name: "Business", last4: "7142", tone: "sand" },
];
const MID = (CARDS.length - 1) / 2;
export default function Demo() {
const [order, setOrder] = useState(CARDS.map((c) => c.id));
const [open, setOpen] = useState(false);
const front = CARDS.find((c) => c.id === order[0])!;
const pick = (id: string) => {
if (!open) return setOpen(true); // a tap on the closed stack fans it out
setOrder((o) => [id, ...o.filter((x) => x !== id)]);
setOpen(false);
};
const onBlur = (e: FocusEvent<HTMLDivElement>) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setOpen(false);
};
return (
<div className={styles.root}>
<div className={styles.wallet}>
<div
className={styles.stack}
data-open={open || undefined}
onPointerEnter={(e) => e.pointerType === "mouse" && setOpen(true)}
onPointerLeave={(e) => e.pointerType === "mouse" && setOpen(false)}
onBlur={onBlur}
>
{CARDS.map((c) => {
const i = order.indexOf(c.id);
// Fan slots run left to right from the back card to the front one.
const slot = CARDS.length - 1 - i - MID;
return (
<button
key={c.id}
type="button"
className={styles.card}
data-tone={c.tone}
data-front={i === 0 || undefined}
style={{ "--i": i, "--slot": slot, "--abs": Math.abs(slot), zIndex: CARDS.length - i } as React.CSSProperties}
onClick={() => pick(c.id)}
onFocus={(e) => e.currentTarget.matches(":focus-visible") && setOpen(true)}
aria-label={open ? `Pay with ${c.name} card ending ${c.last4}` : `${front.name} card ending ${front.last4}, show all cards`}
tabIndex={open || i === 0 ? 0 : -1}
>
<span className={styles.inner}>
<span className={styles.top}>
<span className={styles.name}>{c.name}</span>
<svg className={styles.wave} viewBox="0 0 16 16" aria-hidden="true">
<path d="M5 4.5a5 5 0 0 1 0 7M8 3a7.5 7.5 0 0 1 0 10M11 1.8a9.6 9.6 0 0 1 0 12.4" />
</svg>
</span>
<span className={styles.chip} aria-hidden="true" />
<span className={styles.bottom} aria-hidden="true">
<span className={styles.digits}>•••• {c.last4}</span>
<span className={styles.mark}><i /><i /></span>
</span>
</span>
</button>
);
})}
</div>
<p className={styles.caption} aria-live="polite">
Paying with <b>{front.name} •••• {front.last4}</b>
</p>
</div>
</div>
);
}
demo.module.css
.root {
container-type: size; display: grid; place-items: center; width: 100%; height: 100%; padding: 16px;
box-sizing: border-box; background: var(--surface); overflow: hidden;
}
.wallet { --w: min(220px, 42cqi, calc(46cqh * 1.586)); display: grid; justify-items: center; gap: 22px; }
/* The stack is one card in size; each card sits on top of it and moves with transforms only. */
.stack { position: relative; width: var(--w); aspect-ratio: 1.586; margin-top: 22px; }
.card {
--i: 0; --slot: 0; --abs: 0;
container-type: inline-size; position: absolute; inset: 0; display: block; padding: 0;
box-sizing: border-box; border: 0; border-radius: calc(var(--w) * .065); font: inherit; text-align: left; cursor: pointer;
color: var(--white); background: linear-gradient(135deg, var(--ink), color-mix(in srgb, var(--ink) 72%, var(--accent-text)));
box-shadow: 0 1px 0 color-mix(in srgb, var(--white) 18%, transparent) inset, 0 14px 30px -16px color-mix(in srgb, var(--ink) 60%, transparent);
transform-origin: 50% 0;
transform: translateY(calc(var(--i) * -8px)) scale(calc(1 - var(--i) * .05));
transition: transform .6s var(--out), translate .25s var(--out), filter .3s;
}
.card:not([data-front]) { filter: brightness(calc(1 - var(--i) * .06)); }
.card[data-tone="teal"] { background: linear-gradient(135deg, var(--accent-text), color-mix(in srgb, var(--accent-text) 55%, var(--accent))); }
.card[data-tone="sky"] { color: var(--ink); background: linear-gradient(135deg, var(--sky), var(--accent-soft)); }
.card[data-tone="sand"] { color: var(--ink); background: linear-gradient(135deg, var(--sand), color-mix(in srgb, var(--sand) 70%, var(--muted))); }
/* Fanned: spread sideways along an arc, edges lower and turned outwards. */
.stack[data-open] .card {
filter: none;
transform: translate(calc(var(--slot) * 36%), calc(var(--abs) * var(--abs) * 4% - 10%)) rotate(calc(var(--slot) * 7deg));
transform-origin: 50% 50%;
}
@media (hover: hover) {
.stack[data-open] .card:hover { translate: 0 -8px; }
}
.card:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 3px; }
.stack[data-open] .card:focus-visible { translate: 0 -8px; }
/* cqi below resolves against the card itself, so the content scales with the card. */
.inner { display: grid; grid-template-rows: auto 1fr auto; height: 100%; padding: 8cqi 8cqi 7cqi; box-sizing: border-box; }
.top { display: flex; align-items: center; justify-content: space-between; }
.name { font-size: 7.5cqi; font-weight: 700; letter-spacing: -.01em; }
.wave { width: 9cqi; height: 9cqi; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; opacity: .8; }
.chip {
align-self: center; width: 15cqi; height: 11cqi; border-radius: 2.5cqi;
background: linear-gradient(135deg, var(--sand), color-mix(in srgb, var(--sand) 60%, var(--muted)));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--ink) 15%, transparent) inset;
}
.bottom { display: flex; align-items: flex-end; justify-content: space-between; }
.digits { font-size: 7cqi; font-weight: 600; letter-spacing: .06em; font-variant-numeric: tabular-nums; }
.mark { display: flex; }
.mark i { width: 9cqi; height: 9cqi; border-radius: 50%; background: currentColor; opacity: .55; }
.mark i + i { margin-left: -3.5cqi; opacity: .3; }
.caption { margin: 0; font-size: 13px; color: var(--muted); text-align: center; }
.caption b { color: var(--ink); font-weight: 650; }
@container (max-height: 300px) {
.wallet { gap: 14px; }
.stack { margin-top: 18px; }
.caption { font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.card { transition: none; }
}
Use it for
- Wallets, payment methods or plan pickers with a handful of options
- Hero sections for finance products that want a tactile moment
- Any choice between three to five visual items
Avoid
- More than five items, where the fan gets crowded
- Choices that need text comparison, which a list does better
How it works
The order of card ids is the only state: each card gets its position in the order as --i and a fan slot as --slot. Closed, --i moves cards up and scales them down; open, --slot translates and rotates them along an arc, so a single transform transition handles both. Picking a card moves its id to the front of the order, which slides every card to its new place.
Accessibility
Cards are buttons. Keyboard focus fans the stack and Tab walks the cards, Enter picks one; the caption below is a polite live region naming the card in use. Touch users tap to fan, then tap a card. Reduced motion keeps every state but drops the transitions.