Wallet Card Stack
A phone-screen wallet: cards overlap so only a tappable strip of each shows, tapping one lifts it to the top safe-area edge and tucks the rest under the bottom, and a downward drag — or Escape — puts it back.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/wallet-card-stack.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "WalletCardStack" component: a phone-screen
wallet where cards overlap in a pile, one opens to full detail, and the others tuck
under the bottom edge (lucide-react for three icons, plus a cn() class merger; no
other runtime dependency).
Contract
- Export a forwardRef<HTMLDivElement, WalletCardStackProps> extending
Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue">. The root spreads the
remaining native props and merges className through cn().
- cards: { id, name, network?, last4?, holder?, expiry?, note?, primary?, locked?,
lockedReason?, detail? }[]. The array is drawn BACK TO FRONT — the last item is
the one nearest the user in every variant. id must be stable: it keys the DOM
node every transform animates on. last4 renders masked; note is one free-form
pre-formatted line (the component never touches Date or Intl); primary renders a
"Default" chip, not a colour.
- value / defaultValue / onValueChange(id | null): the open card, or null for
"wallet closed". Controlled and uncontrolled both supported, and a value naming
a card that is missing or locked degrades to null instead of opening a panel for
a card that is not there.
- variant = "stack" | "rail" | "deck", default "stack". stack fans down from the
top edge; rail anchors the fan to the bottom edge so the front card is under the
thumb; deck rests folded into a pile and fans out on one tap.
- peek = 64: px of each stacked card left uncovered. Clamp to 44..160 and never
past cardHeight — that strip is the entire tap target, so 44 is a floor, not a
preference. cardHeight = 208, clamped 120..400. Non-finite numbers fall back to
the defaults rather than collapsing the pile.
- label = "Wallet" names the group; emptyLabel is shown when cards is empty.
- detail is the panel body. When it is absent, fall back to a facts list built out
of holder / expiry / last4 / note, and to one muted sentence when even those are
empty. The component owns no navigation: anything clickable inside detail is the
consumer's.
Behavior
- Three phases, and every one of them is only a transform (translate3d + scale) on
cards that never change `top`: folded deck → fanned pile → one card expanded.
Because nothing re-mounts between phases, the browser interpolates them and
focus is never dropped mid-gesture.
· fanned: card i sits at i * peek (plus a bottom-anchoring offset for rail),
z-index ascending, so each card's uncovered strip is exactly `peek` tall.
· folded deck: cards step 7px and shrink 2.2% per layer, at most 4 layers deep
(a 30-card deck must not shrink to nothing), centred in the frame.
· expanded: the open card goes to y=0, everything else translates to
frameHeight - 26 + 5px per layer, i.e. tucked under the bottom edge and
clipped, capped at 4 visible layers.
- The frame height is measured with a ResizeObserver (the observer's own first
callback is the initial measurement, so no setState runs inside the effect), and
the observer is disconnected on unmount. Transitions stay off until the first
measurement lands, otherwise every mount plays a slide from the estimate.
- Tap targets: tapping a card expands it; tapping the open card collapses it
(aria-expanded toggle); tapping a folded deck fans it; the return button at the
bottom collapses. A locked card refuses: it announces its lockedReason, prints
it on the strip and does not open — aria-disabled, never the disabled attribute,
because the user may be standing on it.
- Drag: pointer events only, on the open card. Movement over 5px starts a drag and
captures the pointer on the element the press landed on. Downward travel of 84px
dismisses, and so does a release faster than 0.5px/ms after at least 12px of
travel (velocity smoothed 0.3 old / 0.7 new so one jittery frame is not a
flick). Upward drag is damped to 0.3x and hard-capped at 28px — the card moves,
it never leaves the top. Frames are coalesced through a single rAF that is
cancelled on release, on selection change and on unmount. preventDefault is
never called: the open card sets touch-action: none, stacked cards keep pan-y so
a pile taller than the frame still scrolls, and the detail panel keeps its own
pan-y scroll region. A drag that moved sets a one-shot ref, read and cleared in
the click handler, so the release does not re-open the card it just closed.
- Keyboard, the same set minus the finger: Tab reaches every card; ArrowUp /
ArrowDown walk the fanned pile, Home / End jump to its ends; Enter / Space open
or close; Escape closes the open card, and closes a fanned deck back to its
folded pile; ArrowDown on the open card is the downward drag.
- Focus always goes to a deliberate successor, never <body>: closing hands focus
back to the card's own button, and in a deck — where the cards go inert the
instant it folds — to the deck cover instead. Fanning a deck hands focus to the
card that was on top.
- Whatever is not operable in the current phase is inert (tucked cards, a folded
deck's cards), so decoration never sits in the tab order and aria-hidden is
never put on a button.
- One polite live region announces phase changes and refusals only — never a pixel
of the drag. Each message is cleared after ~1.4s so the same message (tapping
the same locked card twice) is announced again instead of met with silence.
- Edge cases: zero cards render an empty state that keeps the frame and its safe
areas; one card still expands and its return button reads "Close card" instead
of offering a stack that is not there; long names truncate and the strip keeps
its height; a card removed from `cards` while open collapses the wallet rather
than leaving a dangling panel.
Rendering & styling
- Semantic tokens only: bg-background / bg-card / text-card-foreground / border /
text-muted-foreground / ring / bg-muted. No hex, no rgb(), no oklch().
- Monochrome hierarchy: the open card is the highest-priority surface, so it
INVERTS (bg-foreground text-background, border-foreground) instead of taking a
colour, and its secondary text becomes text-background/70. The deck's "tap to
fan" chip inverts for the same reason. Locked cards are border-dashed. Colour
stays reserved for real semantics.
- Surfaces follow the 16/12/8 radius ladder: frame and cards rounded-2xl, the
detail panel rounded-2xl, chips rounded. Type is small and tight: card title
~14px/600, strip meta 12px mono tabular-nums, labels 10px uppercase.
- Safe area: the stage pads with env(safe-area-inset-top) and
env(safe-area-inset-bottom), so the top card clears the notch and the return
button clears the home indicator — and because every offset is measured inside
that padded box, the tucked pile lands above the indicator too.
- prefers-reduced-motion: motion-reduce:transition-none everywhere. The pile still
fans, cards still open, the drag still tracks the finger — only the eased
settling is dropped.
- Accessibility: role="group" with a label on the frame; each card is a real
<button type="button"> carrying aria-expanded, an aria-label assembled from
name / network / ending last4 / default / locked reason, and aria-controls to
the panel while open; the panel is role="region" aria-labelledby the card, and
focusable so it can be scrolled by keyboard; focus rings are ring-inset, since
an outset ring on a stacked card would be clipped by its neighbour.
Customization levers
- Density: peek is the one number that decides how many cards fit a screen (44 =
packed, 88 = generous); cardHeight sets the face; the frame height is the root's
h-[30rem] default and is meant to be overridden with h-dvh in a real app.
- Presentation: pick a variant (top-anchored stack, thumb-anchored rail, folded
deck) — they share one contract, so switching is a prop, not a rewrite.
- Card face: drop network / last4 / expiry / the Default chip, or replace the
lower half with an issuer logo and a balance; the peek strip is the only part
whose height is load-bearing.
- Panel: pass detail per card (transactions, a barcode, a set of actions) or lean
on the facts-list fallback; FOOTER_HEIGHT is the space reserved for the return
button plus the tucked pile, so raise it if your button grows.
- Physics: DISMISS_PX / DISMISS_VELOCITY tune how eager the dismiss is,
RUBBER / RUBBER_MAX how stiff the top of the drag feels, DECK_STEP / DECK_SCALE
how thick a folded deck looks, PILE_VISIBLE / PILE_STEP how much of the tucked
pile peeks above the bottom edge.
- Motion: transitions are duration-300 ease-out on transform only. Keep them there
— moving them onto top/height would break the phase interpolation entirely.Concepts
- Peek strip as the tap target — a stacked card is covered by the next one, so the strip left uncovered is simultaneously its label, its hit area and its focus ring. That is why
peekhas a hard 44px floor: shrink it further and the pile becomes a picture of a control rather than a control. - Expand one, tuck the rest — opening a card does not scroll a list, it re-parks the whole wallet: one card to the top safe edge, everything else translated under the bottom edge and clipped. The tucked pile is inert decoration, and the single button layered over it carries the label and the way back.
- Fold-and-fan symmetry — the
deckvariant rests folded; a tap fans it, opening and dismissing a card folds it again. The gesture ends where it began, so the wallet has one resting shape rather than two. - Thumb-anchored rail — the same fan measured from the bottom inset instead of the top, so the card most likely to be used sits in the thumb arc and the pile grows towards the notch. Only the anchor changes; the contract does not.
- Transform-only phases — no phase changes
top,heightor the element type, so folded → fanned → expanded is one interpolation the browser can run, and no re-mount can steal focus in the middle of a gesture. - Drag with a button behind it — the downward drag is a shortcut over the return button, Escape and ArrowDown, all of which run the identical collapse. Under
prefers-reduced-motionthe easing goes away and every one of those routes still works.