Wallet Cards

$78,547Total balance

Three cards tucked into a pocket. Hover one and it peeks out. Click it and it slides up, comes to the front and tilts with your cursor. Click anywhere to put it back, or press replay for the next card.

How it works

  1. Stack. Each card sits 36px lower than the one behind it, so only its top strip shows. The pocket front is one SVG with a curved notch, drawn above the cards.
  2. Peek. On hover a card lifts 14px and a white streak sweeps across it.
  3. Pull out. A click slides the card up until it clears the pocket edge, still behind the pocket front. Then it moves above the pocket and drops in front of it at 110%, with a small overshoot. That is two Web Animations API calls, no library.
  4. Tilt. While it is out, the card turns up to 9° towards the cursor and a diagonal highlight follows it sideways. On a phone, drag the card to tilt it.
  5. Put back. Click outside, press Esc or click the card again. The same two steps run in reverse, so the card slides back into its own slot. With reduced motion turned on, cards just swap.

Install

# Nothing to install. React and CSS only.

Usage

import { WalletCards } from "./WalletCards";

const cards = [
  { id: "black", finish: "metal", brand: <Logo />, holder: "Jane Doe", last4: "0886", expiry: "03/30" },
  { id: "grey", color: "#f2f2f2", brand: <Logo />, holder: "Jane Doe", last4: "4821", expiry: "11/29" },
  { id: "blue", color: "#3e6cff", ink: "#ffffff", brand: <Logo />, holder: "Jane Doe", last4: "7310", expiry: "07/31" },
];

// Back to front. color = card colour, ink = its text colour, finish="metal" for spun metal.
<WalletCards cards={cards} balance="$78,547" label="Total balance" />

// Optional: wallet.current.next() pulls out the next card, close() puts it back
<WalletCards ref={wallet} cards={cards} balance="$78,547" />

Source

One component and its styles. Change the pocket colours with the --wc- variables in the CSS.

'use client';

import { useEffect, useImperativeHandle, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import './WalletCards.css';

// Cards tucked into a pocket. Hover lifts a card, click pulls it out in front of the pocket where it tilts with the
// pointer, click outside (or Esc) puts it back in its slot. Web Animations API only, no packages.
// ref: next() pulls out the next card (putting the open one back first), close() puts the open card back.
const WIDTH = 360; // the wallet is drawn at this width and zoomed down to fit smaller containers
const GAP = 36; // visible strip of each card in the stack

const wait = (el, from, to, ms, easing, reduce) =>
  el.animate([{ transform: from }, { transform: to }], { duration: reduce ? 1 : ms, easing, fill: 'forwards' }).finished;

export function WalletCards({ cards, balance, label = 'Total balance', className = '', ref }) {
  const fit = useRef(null);
  const cardEls = useRef([]);
  const run = useRef({ open: null, last: -1, busy: false, down: null });
  const [scale, setScale] = useState(1);
  const [open, setOpen] = useState(null); // index of the card that is out
  const [raised, setRaised] = useState(null); // index of the card drawn above the pocket front
  const [active, setActive] = useState(false); // a card is out or moving, so hover is off
  const pocketTop = GAP * (cards.length - 1) + 48;

  useEffect(() => {
    const ro = new ResizeObserver(([e]) => setScale(Math.min(1, e.contentRect.width / WIDTH)));
    ro.observe(fit.current);
    return () => ro.disconnect();
  }, []);

  const reduce = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
  // Out: in front of the pocket, 10% bigger. Lift: high enough that the bottom clears the pocket edge.
  const outY = (el) => 48 - el.offsetTop;
  const liftY = (el) => pocketTop - el.offsetHeight - 24 - el.offsetTop;

  const tilt = (i, px = 0.5, py = 0.5) => {
    const face = cardEls.current[i]?.firstElementChild;
    if (!face) return;
    face.style.setProperty('--wc-ry', `${(px - 0.5) * 18}deg`);
    face.style.setProperty('--wc-rx', `${(0.5 - py) * 14}deg`);
    face.style.setProperty('--wc-gx', `${px * 130 - 15}%`);
  };

  const pullOut = async (i) => {
    const el = cardEls.current[i];
    const r = run.current;
    r.busy = true;
    r.open = i;
    r.last = i;
    setActive(true);
    await wait(el, 'translateY(0)', `translateY(${liftY(el)}px)`, 380, 'cubic-bezier(.3,.7,.3,1)', reduce());
    flushSync(() => {
      setRaised(i);
      setOpen(i);
    });
    await wait(el, `translateY(${liftY(el)}px)`, `translateY(${outY(el)}px) scale(1.1)`, 520, 'cubic-bezier(.34,1.45,.64,1)', reduce());
    r.busy = false;
  };

  const putBack = async (i) => {
    const el = cardEls.current[i];
    const r = run.current;
    r.busy = true;
    setOpen(null);
    tilt(i);
    await wait(el, `translateY(${outY(el)}px) scale(1.1)`, `translateY(${liftY(el)}px)`, 360, 'cubic-bezier(.3,.7,.3,1)', reduce());
    flushSync(() => setRaised(null));
    await wait(el, `translateY(${liftY(el)}px)`, 'translateY(0)', 460, 'cubic-bezier(.34,1.3,.64,1)', reduce());
    r.open = null;
    r.busy = false;
    setActive(false);
  };

  const onCard = async (i) => {
    const r = run.current;
    if (r.busy || r.down?.moved) return; // a drag to tilt is not a tap
    if (r.open === i) return putBack(i);
    if (r.open !== null) await putBack(r.open);
    pullOut(i);
  };

  useImperativeHandle(ref, () => ({
    next: async () => {
      const r = run.current;
      if (r.busy) return;
      const i = (r.last + 1) % cards.length;
      if (r.open !== null) await putBack(r.open);
      pullOut(i);
    },
    close: () => {
      const r = run.current;
      if (r.open !== null && !r.busy) putBack(r.open);
    },
  }));

  const onMove = (i, e) => {
    const r = run.current;
    if (r.down && Math.hypot(e.clientX - r.down.x, e.clientY - r.down.y) > 8) r.down.moved = true;
    if (r.open !== i || r.busy || reduce()) return;
    const box = e.currentTarget.getBoundingClientRect();
    tilt(i, (e.clientX - box.left) / box.width, (e.clientY - box.top) / box.height);
  };

  // Click outside or Esc puts the card back.
  useEffect(() => {
    const r = run.current;
    const down = (e) => (r.down = { x: e.clientX, y: e.clientY, moved: false });
    const click = (e) => {
      if (r.open !== null && !r.busy && !cardEls.current[r.open].contains(e.target)) putBack(r.open);
    };
    const key = (e) => {
      if (e.key === 'Escape' && r.open !== null && !r.busy) putBack(r.open);
    };
    addEventListener('pointerdown', down, true);
    document.addEventListener('click', click);
    document.addEventListener('keydown', key);
    return () => {
      removeEventListener('pointerdown', down, true);
      document.removeEventListener('click', click);
      document.removeEventListener('keydown', key);
    };
  });

  return (
    <div ref={fit} className={`wc-fit ${className}`}>
      <div
        className={active ? 'wc is-active' : 'wc'}
        style={{ zoom: scale, '--wc-pocket-top': `${pocketTop}px` }}
      >
        <div className="wc-back" />

        {cards.map((c, i) => (
          <button
            type="button"
            key={c.id}
            ref={(el)=> (cardEls.current[i]= el)}
            className={`wc-card${c.finish= 'metal' ? ' is-metal' : ''}${open= i ? ' is-open' : ''}`}
            style={{ top: i * GAP, zIndex: raised= i ? 20 : i + 1, '--wc-color': c.color, '--wc-ink': c.ink }}
            aria-label={`${c.title ?? 'Card'} ending ${c.last4}`}
            aria-expanded={open= i}
            onClick={()=> onCard(i)}
            onPointerMove={(e)=> onMove(i, e)}
            onPointerLeave={()=> run.current.open= i && !run.current.busy && tilt(i)}
          >
            <span className="wc-face">
              <span className="wc-top">
                <span className="wc-dots">
                  <i />
                  <i />
                  <i />
                </span>
                <span className="wc-brand">{c.brand}</span>
              </span>
              <span className="wc-details">
                <span className="wc-row">
                  <span>{c.holder}</span>
                  <span>{c.expiry}</span>
                </span>
                <span className="wc-num">
                  <b>****</b> <b>****</b> <b>****</b> {c.last4}
                </span>
              </span>
              <span className="wc-shine" />
              <span className="wc-glare" />
            </span>
          </button>
        ))}

        <div className="wc-pocket">
          <svg viewBox="0 0 360 160" preserveAspectRatio="none" aria-hidden="true">
            <path
              className="wc-pocket-fill"
              d="M0 22 Q0 0 22 0 L105 0 C135 0 140 26 180 26 C220 26 225 0 255 0 L338 0 Q360 0 360 22 L360 134 Q360 160 334 160 L26 160 Q0 160 0 134 Z"
            />
            <path
              className="wc-stitch"
              d="M10 28 Q10 10 28 10 L102 10 C132 10 138 36 180 36 C222 36 228 10 258 10 L332 10 Q350 10 350 28 L350 132 Q350 150 332 150 L28 150 Q10 150 10 132 Z"
            />
            <circle className="wc-rivet" cx="26" cy="26" r="5" />
            <circle className="wc-rivet" cx="334" cy="26" r="5" />
          </svg>
          <div className="wc-balance">
            <strong>{balance}</strong>
            <span>{label}</span>
          </div>
        </div>
      </div>
    </div>
  );
}