Introduction
In this article we build an interactive wallet UI — a skeuomorphic card holder where three payment cards sit tucked inside a leather-brown SVG pocket. When the cursor enters the wallet, the cards fan out with staggered cubic-bezier transitions: the back card rotates left, the middle card tilts right, and the front card lifts straight up. A hidden balance figure fades in at the same time, and hovering any individual card reveals its full card number while the masked version disappears. No JavaScript drives any of the animation — only CSS transitions, transform, opacity, and the sibling combinator.
The effect relies on four CSS mechanisms working together: a shared perspective container so all three cards share the same 3D coordinate space, cubic-bezier easing with overshoot so the cards feel physically springy as they fan out, a drop-in keyframe animation so cards slide into the pocket on page load, and z-index layering so the pocket SVG always sits in front of the cards, preserving the illusion that the cards live inside it.
How to layer an SVG shape over absolutely positioned children to create a pocket illusion — how cubic-bezier(0.34, 1.56, 0.64, 1) produces an elastic overshoot on card fan transitions — how to use CSS opacity and transform together for a smooth balance reveal — how BEM naming keeps wallet, card, pocket, and eye concerns cleanly separated — and how to toggle between a masked card number and a full PAN on individual card hover without any JavaScript.
Step 1 — CSS Custom Properties
All visual values are expressed as custom properties on :root. The wallet width, card dimensions, and leather color are the three values most worth changing when adapting this component — every other value derives from or references them. Keeping the palette in tokens means switching from leather brown to midnight navy requires changing two hex values at the top of the stylesheet, not hunting through dozens of rules.
/* ── Wallet tokens ─────────────────────────── */
:root {
--wallet-width: 280px;
--wallet-height: 230px;
--card-width: 260px;
--card-height: 140px;
--leather-dark: #3b1f0e; /* shell and pocket fill */
--leather-mid: #6b3a1f; /* pocket stitching stroke */
--leather-accent: #a0734e; /* balance stars tint */
--balance-color: #e8c9a0; /* revealed balance text */
--label-color: #8b5e3c; /* "Total Balance" label */
--eye-stroke: #c49a6c; /* eye icon stroke */
--fan-duration: 0.6s;
--fan-easing: cubic-bezier(0.34, 1.56, 0.64, 1);
--drop-duration: 0.8s;
--drop-easing: cubic-bezier(0.2, 0.8, 0.2, 1);
}Step 2 — The Markup
The component has five direct children inside wallet-scene: the wallet-shell (the back leather piece), three wallet-card elements, and the wallet-pocket (the front SVG flap). Source order matters here — the pocket must come last so it paints on top of all three cards and preserves the illusion that the cards are tucked inside. BEM modifier classes (wallet-card--stripe, wallet-card--wise, wallet-card--paypal) handle per-card color and stacking without any inline styles.
<div class="wallet-scene">
<div class="wallet-shell"></div>
<div class="wallet-card wallet-card--stripe"> … </div>
<div class="wallet-card wallet-card--wise"> … </div>
<div class="wallet-card wallet-card--paypal"> … </div>
<!-- pocket comes last — paints over all cards -->
<div class="wallet-pocket">
<svg>…</svg>
<div class="wallet-pocket__body"> … </div>
</div>
</div>| Element | Role | z-index |
|---|---|---|
| wallet-shell | Back leather panel — static base of the wallet | 5 |
| wallet-card--stripe | Rearmost card — fans furthest left on hover | 10 |
| wallet-card--wise | Middle card — fans slightly right on hover | 20 |
| wallet-card--paypal | Front card — lifts straight up on hover | 30 |
| wallet-pocket | SVG front flap — always above all cards, creates the pocket illusion | 40 |
| wallet-card (hovered) | Any individually hovered card jumps to z-index 100 | 100 |
Step 3 — Card Colors and Layout
Each card uses a dark gradient background so the white text remains legible at all z-index levels. The three gradients are intentionally distinct — deep indigo for Stripe, teal-green for Wise, and navy for PayPal — so the fan animation makes it immediately clear which card is which as they separate. Every card shares the same base wallet-card class for dimensions, border-radius, padding, and transition; the modifier class only overrides background, shadow color, bottom offset, z-index, and animation delay.
.wallet-card {
position: absolute;
width: var(--card-width); /* 260px */
height: var(--card-height); /* 140px */
left: 10px;
border-radius: 16px;
padding: 16px 18px;
transition: transform var(--fan-duration) var(--fan-easing);
animation: wallet-card-drop var(--drop-duration) var(--drop-easing) backwards;
}
.wallet-card--stripe { background: linear-gradient(135deg, #0f0c29, #302b63, #24243e); bottom: 90px; z-index: 10; animation-delay: 0.1s; }
.wallet-card--wise { background: linear-gradient(135deg, #004d40, #00695c, #00897b); bottom: 65px; z-index: 20; animation-delay: 0.2s; }
.wallet-card--paypal { background: linear-gradient(135deg, #1a1a2e, #16213e, #0f3460); bottom: 40px; z-index: 30; animation-delay: 0.3s; }Step 4 — Drawing the Pocket with SVG Path
The pocket is a single SVG element containing two path elements. The outer path is a filled leather-brown shape — a rectangle with a curved top slot where the cards appear to enter. The inner path is an identical but slightly inset shape drawn with a dashed stroke and no fill, which mimics stitching along the interior edge of the pocket. Both paths use the same Bézier control points; the stitching path just subtracts 8px from every edge.
<svg viewBox="0 0 280 160" fill="none">
<!-- outer leather fill — the pocket body -->
<path
d="M0 20C0 10 5 10 10 10C20 10 25 25 40 25
L240 25C255 25 260 10 270 10C275 10 280 10 280 20
L280 120C280 155 260 160 240 160
L40 160C20 160 0 155 0 120Z"
fill="#3b1f0e"
/>
<!-- inner dashed stroke — mimics stitching -->
<path
d="M8 22C8 16 12 16 15 16C23 16 27 29 40 29
L240 29C253 29 257 16 265 16C268 16 272 16 272 22
L272 120C272 150 255 152 240 152
L40 152C25 152 8 152 8 120Z"
stroke="#6b3a1f"
stroke-width="1.5"
stroke-dasharray="6 4"
/>
</svg>The curved notch at the top of the pocket path — the C20 10 25 25 40 25 Bézier segments — is what sells the illusion. Without it the pocket would look like a flat rectangle pasted over the cards. The inward curve implies the cards are sliding out of a slot, not just floating in front of a shape.
Step 5 — Fanning Cards on Wallet Hover
When wallet-scene is hovered, each card receives a different translateY and rotate transform. The back card moves up 75px and rotates -3 degrees, the middle card moves up 45px and rotates +2 degrees, and the front card lifts a modest 10px with no rotation. This creates a natural hand-of-cards spread. The cubic-bezier easing with values above 1 in the third parameter produces an elastic overshoot — cards briefly travel past their final position before snapping back, giving the fan a satisfying physical quality.
/* lift the whole wallet slightly on hover */
.wallet-scene:hover { transform: translateY(-5px); }
/* fan each card to a different position */
.wallet-scene:hover .wallet-card--stripe { transform: translateY(-75px) rotate(-3deg); }
.wallet-scene:hover .wallet-card--wise { transform: translateY(-45px) rotate(2deg); }
.wallet-scene:hover .wallet-card--paypal { transform: translateY(-10px); }
/* individual card hover — bring it fully forward */
.wallet-card:hover { z-index: 100 !important; }
.wallet-scene:hover .wallet-card--stripe:hover { transform: translateY(-60px) scale(1.05) rotate(0); }
.wallet-scene:hover .wallet-card--wise:hover { transform: translateY(-70px) scale(1.05) rotate(0); }
.wallet-scene:hover .wallet-card--paypal:hover { transform: translateY(-60px) scale(1.05) rotate(0); }The second control point value of 1.56 — greater than 1 — is what produces the elastic overshoot. Standard easing functions keep both Y control points between 0 and 1, which means the animated value never exceeds its end state. A Y value above 1 lets the value temporarily overshoot the target before settling, mimicking the way a physical card would bounce slightly if you flicked it upward.
Step 6 — Fading the Balance In and Out
The pocket contains two overlapping elements: wallet-pocket__balance-hidden (a row of asterisks visible at rest) and wallet-pocket__balance-real (the actual dollar amount, hidden at rest). On wallet-scene hover, the hidden version fades out via opacity: 0, and the real balance fades in via opacity: 1 combined with a translateY from 10px to 0. The translateY makes the reveal feel like the number is sliding up into view rather than just crossfading, which reads as more intentional and less abrupt.
/* at rest: stars visible, real balance hidden */
.wallet-pocket__balance-hidden {
opacity: 1;
transition: opacity 0.3s ease;
}
.wallet-pocket__balance-real {
opacity: 0;
transform: translate(-50%, 10px); /* starts 10px below final position */
transition: opacity 0.3s ease, transform 0.3s ease;
position: absolute;
left: 50%;
}
/* on hover: swap visibility */
.wallet-scene:hover .wallet-pocket__balance-hidden { opacity: 0; }
.wallet-scene:hover .wallet-pocket__balance-real {
opacity: 1;
transform: translate(-50%, 0); /* slides up to final position */
}Step 7 — Revealing the Full PAN on Card Hover
Each card contains two elements inside wallet-card__pan-wrap: wallet-card__pan-masked (showing **** followed by the last four digits) and wallet-card__pan-full (the complete number in monospace). At rest, pan-full is display: none. When the individual card is hovered, pan-masked switches to display: none and pan-full switches to display: block. This is a simple display toggle rather than a fade — the swap is instant and deliberate, which feels more appropriate for revealing sensitive data than a slow crossfade would.
/* masked number visible at rest */
.wallet-card__pan-masked { display: block; font-size: 15px; letter-spacing: 2px; }
.wallet-card__pan-full { display: none; font-size: 12px; letter-spacing: 1px; font-family: 'Space Mono', monospace; }
/* swap on individual card hover */
.wallet-card:hover .wallet-card__pan-masked { display: none; }
.wallet-card:hover .wallet-card__pan-full { display: block; }Using display: none rather than opacity: 0 for the PAN toggle means the masked number does not linger as an invisible-but-selectable text node when the full number is shown. Accessibility tools and browser text selection treat display: none as fully removed, which is the correct behavior for content that should not be readable at all in its hidden state.
Tuning Reference
| Token / Property | Default | Effect |
|---|---|---|
| --wallet-width | 280px | Width of the scene, shell, pocket SVG, and all card positioning |
| --card-width / --card-height | 260px / 140px | Dimensions of every card — change both together to keep proportions |
| --leather-dark | #3b1f0e | Fill color of the shell and pocket — change this to retheme the whole wallet |
| --fan-duration | 0.6s | How long each card takes to reach its fanned-out position |
| --fan-easing | cubic-bezier(0.34, 1.56, 0.64, 1) | The elastic overshoot easing — reduce the 1.56 toward 1.0 for a tighter snap |
| --drop-duration | 0.8s | Duration of the page-load drop-in animation for each card |
| animation-delay per card | 0.1s / 0.2s / 0.3s | Stagger between each card drop — increase the gap for a more dramatic cascade |
| translateY on fan hover | -75px / -45px / -10px | How far each card rises — larger values = wider fan spread |
| rotate on fan hover | -3deg / 2deg / 0deg | Angle of each card in the fan — set all to 0 for a straight vertical lift |
| balance translateY | 10px → 0 | How far the balance number slides upward on reveal — increase for more drama |
Full Source Code
Save the following as wallet.html and open in any modern browser. All transitions, animations, and reveal effects are CSS-only. The only HTML requirements are source order (shell → cards → pocket) and the BEM class names that the CSS selectors target. No build step, no dependencies, no frameworks required.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/wallet.htmlOne scene div, one shell, three cards, one SVG pocket. The entire wallet — fanning cards, elastic overshoot, balance reveal, PAN toggle, drop-in load animation — is driven by roughly 80 lines of CSS and zero JavaScript event listeners. Every transition runs natively on the GPU.