Mobile

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronDown, Lock, Wallet } from "lucide-react"
import { cn } from "@/lib/utils"

/** Card face height (px). A payment card is ~1.6:1, so this suits a 340–360px wide phone column. */
const DEFAULT_CARD_HEIGHT = 208
const MIN_CARD_HEIGHT = 120
const MAX_CARD_HEIGHT = 400
/**
 * How much of a stacked card stays uncovered. That strip is the whole tap target,
 * so it is never allowed below 44px — a 20px sliver is a control nobody can hit.
 */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/wallet-card-stack.json

Prompt

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 peek has 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 deck variant 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, height or 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-motion the easing goes away and every one of those routes still works.

On This Page