Introduction
In this article we build CardFlip — a polished animated credit card form from scratch. The card preview updates in real time as the user types, and flips to reveal the back whenever the CVV field is focused. No frameworks, no CSS preprocessors, no external UI libraries. Just HTML, CSS custom properties, keyframe animations, and a small amount of vanilla JavaScript for the input mirroring.
The component is built from two main regions: a 3-D card scene containing a front face and a back face, and a glassmorphism form panel below it. Every color, size, and timing token lives in CSS custom properties at :root, making the whole component trivially re-themeable. An animated aurora effect on the card surface is achieved with a single conic-gradient pseudo-element and one @keyframes rule.
How to set up a CSS 3-D perspective scene for a card flip — how to use backface-visibility: hidden to show only one face at a time — how to mirror live form input onto a card preview with vanilla JS — how to create an animated conic-gradient aurora with a single keyframe — how to build a glassmorphism form panel with CSS backdrop-filter — how to structure design tokens with CSS custom properties for a fully themeable component.
How the Flip Works
The flip effect relies on three CSS properties working together: perspective on the scene container, transform-style: preserve-3d on the card inner wrapper, and backface-visibility: hidden on each face. At rest, the front face is visible. When the .flipped class is added to the inner wrapper, it rotates 180deg around the Y axis. The back face starts pre-rotated at 180deg, so it comes into view exactly as the front disappears.
The flip is just one CSS class toggle — .flipped adds transform: rotateY(180deg) to the inner wrapper. JavaScript adds and removes that class on CVV focus and blur.
This is why backface-visibility: hidden is essential on both faces. Without it, the back face would show through the front face as a mirror image during the rotation — breaking the illusion entirely.
Step 1 — CSS Custom Properties
All values are declared as custom properties on :root. Tokens are grouped into palette, geometry, and motion categories. This makes the component self-documenting and trivial to re-theme by changing only the :root block.
:root {
/* palette */
--color-bg: #0f0f13;
--color-card-from: #1a1a2e;
--color-card-mid: #16213e;
--color-card-to: #0f3460;
--color-accent: #a855f7;
--color-accent-deep: #7c3aed;
--color-pink: #ec4899;
--color-blue: #3b82f6;
--color-text: rgba(255, 255, 255, 0.9);
--color-text-muted: rgba(255, 255, 255, 0.5);
--color-form-bg: rgba(255, 255, 255, 0.04);
--color-input-bg: rgba(255, 255, 255, 0.06);
--color-border: rgba(255, 255, 255, 0.08);
--color-border-input: rgba(255, 255, 255, 0.1);
/* geometry */
--card-width: 380px;
--card-height: 240px;
--card-radius: 20px;
--perspective: 1000px;
/* motion */
--duration-flip: 0.7s;
--easing-flip: cubic-bezier(0.4, 0, 0.2, 1);
--duration-aurora: 8s;
--duration-blob: 12s;
}Keeping card palette tokens (--color-card-*) separate from form palette tokens (--color-form-bg, --color-input-bg) means you can re-skin just the card — for example swapping to a gold luxury theme — without touching the form styles at all.
Step 2 — The Markup
The component splits into two top-level sections: .card-scene and .form-card. Inside the scene, .card-inner is the 3-D pivot element that holds .card-front and .card-back as absolute children. The form panel below contains four field groups — card number, card holder, expiry selects, and CVV.
<div class="wrapper">
<!-- 3-D card scene -->
<div class="card-scene">
<div class="card-inner" id="cardInner">
<div class="card-front">
<div class="card-top">...</div>
<div class="card-number-display" id="cardNumDisplay">
<span id="g1">####</span>
<span id="g2">####</span>
<span id="g3">####</span>
<span id="g4">####</span>
</div>
<div class="card-bottom">
<div class="card-info-value" id="holderDisplay">FULL NAME</div>
<div class="card-info-value" id="expiryDisplay">MM/YY</div>
</div>
</div>
<div class="card-back">
<div class="magnetic-stripe"></div>
<div class="cvv-section">
<div class="cvv-band">
<span class="cvv-dots" id="cvvDisplay">***</span>
</div>
</div>
</div>
</div>
</div>
<!-- Form panel -->
<div class="form-card">
<input id="cardNumber" type="text" autocomplete="off" />
<input id="cardHolder" type="text" autocomplete="off" />
<select id="expMonth">...</select>
<select id="expYear">...</select>
<input id="cvv" type="password" autocomplete="off" />
<button>Pay Now</button>
</div>
</div>| Element | Role | Animated |
|---|---|---|
| .card-scene | Perspective container — sets the 3-D viewport | No |
| .card-inner | 3-D pivot — receives .flipped class on CVV focus | Yes — rotateY(180deg) |
| .card-front | Front face — visible by default | No |
| .card-back | Back face — pre-rotated 180deg, visible when flipped | No |
| #cardNumDisplay | Live card number — four grouped spans | No |
| #holderDisplay | Live cardholder name | No |
| #expiryDisplay | Live expiry date | No |
| .magnetic-stripe | Decorative black stripe on card back | No |
| #cvvDisplay | Masked CVV dots on card back | No |
| .card-front::before | Aurora conic-gradient overlay | Yes — aurora 8s |
Step 3 — Setting Up the Perspective
Three CSS properties chain together to make the flip work. perspective on .card-scene defines the vanishing point — 1000px gives a realistic depth without too much distortion. transform-style: preserve-3d on .card-inner tells the browser to keep children in 3-D space rather than flattening them. backface-visibility: hidden on both faces hides them when they face away from the viewer.
.card-scene {
width: var(--card-width);
height: var(--card-height);
perspective: var(--perspective);
margin: 0 auto 32px;
}
.card-inner {
width: 100%;
height: 100%;
position: relative;
transform-style: preserve-3d;
transition: transform var(--duration-flip) var(--easing-flip);
}
/* Class toggled by JS on CVV focus / blur */
.card-inner.flipped {
transform: rotateY(180deg);
}
.card-front,
.card-back {
position: absolute;
inset: 0;
border-radius: var(--card-radius);
backface-visibility: hidden;
-webkit-backface-visibility: hidden; /* Safari */
overflow: hidden;
}
/* Back face starts rotated — comes into view when .flipped */
.card-back {
transform: rotateY(180deg);
}Safari still requires the -webkit- prefix for backface-visibility. Without it, both faces are visible simultaneously in Safari during the rotation, producing a ghost double-image. Always include both the prefixed and unprefixed declarations.
Step 4 — Building the Front Face
The card front uses a three-stop linear-gradient as its base background, giving a deep navy-to-dark-blue ramp. The card is divided into three vertical zones via flexbox column layout: a top row for branding and the Mastercard logo, a middle row for the card number, and a bottom row for holder name and expiry.
.card-front {
background: linear-gradient(
135deg,
var(--color-card-from) 0%,
var(--color-card-mid) 40%,
var(--color-card-to) 100%
);
padding: 28px;
display: flex;
flex-direction: column;
justify-content: space-between;
box-shadow:
0 30px 60px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.05);
}
/* Number display — single flex row, monospace font */
.card-number-display {
font-family: 'Space Mono', monospace;
font-size: 15px;
letter-spacing: 2px;
color: var(--color-text);
white-space: nowrap;
display: flex;
gap: 10px;
align-items: center;
position: relative;
z-index: 1;
}
/* Mastercard logo — two overlapping circles via absolute positioning */
.mastercard-logo {
width: 54px;
height: 36px;
position: relative;
}
.mc-circle {
width: 36px;
height: 36px;
border-radius: 50%;
position: absolute;
}
.mc-left { background: #eb001b; left: 0; }
.mc-right { background: #f79e1b; left: 18px; }The gold chip is a div with a linear-gradient background. Its internal grid lines — the horizontal and vertical separator lines visible on real EMV chips — are faked with ::before and ::after pseudo-elements set to 1px height/width with a semi-transparent dark color.
Step 5 — The Back Face
The card back shares the same gradient background as the front, and the same aurora animation. Two additional elements appear only on the back: .magnetic-stripe — a full-width absolute-positioned div spanning the top third of the card — and .cvv-section containing the white CVV band.
.magnetic-stripe {
position: absolute;
top: 48px;
left: 0;
right: 0;
height: 52px;
background: linear-gradient(
180deg,
#111 0%,
#222 50%,
#111 100%
);
z-index: 1;
}
.cvv-band {
background: rgba(255, 255, 255, 0.92);
border-radius: 4px;
height: 40px;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 14px;
}
.cvv-dots {
font-family: 'Space Mono', monospace;
font-size: 16px;
color: #111;
letter-spacing: 4px;
}The CVV display shows one asterisk per digit typed — "*".repeat(val.length) in JavaScript — falling back to "***" when the field is empty. This gives users real-time visual confirmation that their input is being captured, without ever exposing the actual digits.
Step 6 — The Rotating Aurora Effect
The aurora is a ::before pseudo-element on both card faces. It is sized to 200% × 200% and positioned at −50% top and left, so it always fully covers the card regardless of rotation. The background is a conic-gradient with four color stops producing a sweeping purple-blue-pink light that slowly rotates behind the card surface.
.card-front::before,
.card-back::before {
content: '';
position: absolute;
width: 200%;
height: 200%;
top: -50%;
left: -50%;
background: conic-gradient(
from 0deg at 50% 50%,
transparent 0deg,
rgba(168, 85, 247, 0.3) 60deg,
rgba(59, 130, 246, 0.2) 120deg,
transparent 180deg,
rgba(236, 72, 153, 0.2) 240deg,
transparent 300deg
);
animation: aurora var(--duration-aurora) linear infinite;
}
@keyframes aurora {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}A conic-gradient rotates around its center point. If the pseudo-element were only 100% × 100%, the corners of the card would clip the rotating gradient as it spins, causing a visible edge sweep. Sizing it to 200% and centering it at −50% ensures the gradient always fully covers the card with smooth edges at every rotation angle.
Step 7 — Glassmorphism Form Panel
The form panel uses backdrop-filter: blur(20px) on a semi-transparent dark background to create a glassmorphism effect that layers naturally over the animated background blobs. Input fields share a common style: semi-transparent background, subtle border, and a purple glow on focus via box-shadow.
.form-card {
background: var(--color-form-bg);
border: 1px solid var(--color-border);
border-radius: 24px;
padding: 32px;
backdrop-filter: blur(20px);
}
input, select {
width: 100%;
background: var(--color-input-bg);
border: 1px solid var(--color-border-input);
border-radius: 12px;
padding: 14px 16px;
font-size: 15px;
color: #fff;
outline: none;
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
-webkit-appearance: none;
autocomplete: off;
}
input:focus, select:focus {
border-color: rgba(168, 85, 247, 0.6);
background: rgba(168, 85, 247, 0.08);
box-shadow: 0 0 0 3px rgba(168, 85, 247, 0.12);
}
/* CVV gets a pink accent instead of purple */
.cvv-field input:focus {
border-color: rgba(236, 72, 153, 0.7);
background: rgba(236, 72, 153, 0.08);
box-shadow: 0 0 0 3px rgba(236, 72, 153, 0.12);
}The expiry and CVV fields share a three-column grid row. Using grid-template-columns: 1fr 1fr 1fr keeps the month and year selects equal width while giving the CVV field the same proportional space — no magic pixel values required.
Step 8 — Mirroring Inputs onto the Card
The JavaScript is intentionally minimal — four event listeners, one class toggle. Each input field has an input or change event that reads the current value and writes it to the corresponding display element on the card. The card number listener also formats the raw digit string into grouped segments for display.
// Card Number — format into four groups, update four spans
document.getElementById('cardNumber').addEventListener('input', function () {
const val = this.value.replace(/\D/g, '').slice(0, 16);
// Re-format input field with spaces
this.value = val.replace(/(.{4})/g, '$1 ').trim();
// Update each span with actual digits or # placeholders
const raw = [val.slice(0,4), val.slice(4,8), val.slice(8,12), val.slice(12,16)];
['g1','g2','g3','g4'].forEach((id, i) => {
document.getElementById(id).textContent = raw[i].padEnd(4, '#');
});
});
// Card Holder — uppercase mirror
document.getElementById('cardHolder').addEventListener('input', function () {
document.getElementById('holderDisplay').textContent =
this.value.toUpperCase() || 'FULL NAME';
});
// Expiry — read both selects, combine
function updateExpiry() {
const m = document.getElementById('expMonth').value;
const y = document.getElementById('expYear').value;
document.getElementById('expiryDisplay').textContent =
(m || 'MM') + '/' + (y || 'YY');
}
document.getElementById('expMonth').addEventListener('change', updateExpiry);
document.getElementById('expYear').addEventListener('change', updateExpiry);
// CVV — flip card on focus, restore on blur, mask digits
const cvvInput = document.getElementById('cvv');
const cardInner = document.getElementById('cardInner');
cvvInput.addEventListener('focus', () => cardInner.classList.add('flipped'));
cvvInput.addEventListener('blur', () => cardInner.classList.remove('flipped'));
cvvInput.addEventListener('input', function () {
const val = this.value.replace(/\D/g, '').slice(0, 4);
this.value = val;
document.getElementById('cvvDisplay').textContent =
'*'.repeat(val.length) || '***';
});Browser autofill can silently populate payment fields with saved card data before the user interacts with them, bypassing the live mirroring logic entirely. Setting autocomplete="off" on all inputs ensures the card preview always reflects only what the user actively types — not what the browser pre-filled.
The card number display pads each group with # characters using padEnd(4, "#") — so partial input like "123" shows as "123#" rather than a mis-aligned gap.
Tuning Reference
| Token / Property | Default | Effect |
|---|---|---|
| --duration-flip | 0.7s | Card flip speed. Lower = snappier. Above 1s feels sluggish |
| --easing-flip | cubic-bezier(0.4,0,0.2,1) | Flip easing. Try `ease-in-out` for a simpler curve |
| --perspective | 1000px | Lower = more dramatic distortion. Higher = flatter flip |
| --duration-aurora | 8s | Aurora rotation speed. Higher = slower, more subtle glow |
| --duration-blob | 12s | Background blob drift speed. Purely decorative |
| --card-radius | 20px | Card corner radius. 0 = sharp corporate look |
| --color-accent | #a855f7 | Primary focus ring and button gradient color |
| --color-pink | #ec4899 | CVV field focus ring color — intentionally distinct |
| backface-visibility | hidden | Must be hidden on both faces or ghost image appears |
| -webkit-backface-visibility | hidden | Required for Safari — do not omit |
| conic-gradient stops | 0/60/120/180/240/300deg | Aurora color sweep width. More stops = richer glow |
Full Source Code
Save the following as animated-credit-card-form.html and open in any modern browser. Zero dependencies, zero build step.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/animated-credit-card-form.htmlEvery effect in CardFlip — the flip, the aurora, the glassmorphism form — is pure CSS. JavaScript does only one thing: move text from inputs to the card display.