Form — Card / payment input group
A polished credit-card payment field group with a live, flipping card preview. The number input formats into brand-correct groups as you type, detects Visa, Mastercard, Amex and Discover from the prefix, and runs a real Luhn checksum for inline validation. Expiry is masked to MM/YY with a past-date guard, the CVC adapts to 3 or 4 digits by brand and flips the card to its back on focus, and cardholder name plus billing postal code round out a fully keyboard-accessible, demo-only checkout.
MCP
Code
:root {
--brand: #5b5bf0;
--brand-d: #4646d6;
--brand-700: #3a3ab8;
--brand-50: #eef0ff;
--accent: #00b4a6;
--accent-soft: #d8f5f2;
--ink: #101322;
--ink-2: #3a4060;
--muted: #6c7393;
--bg: #f6f7fb;
--white: #ffffff;
--surface: #ffffff;
--line: rgba(16, 19, 34, 0.1);
--line-2: rgba(16, 19, 34, 0.16);
--ok: #2f9e6f;
--warn: #d98a2b;
--danger: #d4503e;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--sh-1: 0 1px 2px rgba(16, 19, 34, 0.08);
--sh-2: 0 8px 24px rgba(16, 19, 34, 0.08);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
color: var(--ink);
background: var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 40px 20px;
background:
radial-gradient(900px 420px at 12% -8%, var(--brand-50), transparent 60%),
radial-gradient(700px 360px at 108% 12%, var(--accent-soft), transparent 55%),
var(--bg);
}
/* ── Card ── */
.card {
position: relative;
width: min(460px, 100%);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-2);
padding: 30px 30px 26px;
overflow: hidden;
}
.card__head {
margin-bottom: 20px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.01em;
color: var(--brand-700);
background: var(--brand-50);
border: 1px solid rgba(91, 91, 240, 0.18);
padding: 5px 10px;
border-radius: 999px;
}
.badge svg {
color: var(--brand);
}
.card__title {
margin-top: 14px;
font-size: 24px;
font-weight: 800;
letter-spacing: -0.02em;
}
.card__sub {
margin-top: 6px;
font-size: 14px;
color: var(--muted);
}
/* ── Live card preview ── */
.preview {
perspective: 1200px;
margin-bottom: 22px;
}
.preview__inner {
position: relative;
width: 100%;
aspect-ratio: 1.586 / 1;
transform-style: preserve-3d;
transition: transform 0.55s cubic-bezier(0.4, 0.1, 0.2, 1);
}
.preview__inner.is-flipped {
transform: rotateY(180deg);
}
.preview__face {
position: absolute;
inset: 0;
border-radius: var(--r-md);
padding: 18px 20px;
color: var(--white);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
box-shadow: 0 12px 30px rgba(58, 58, 184, 0.35);
background:
radial-gradient(120% 120% at 0% 0%, rgba(255, 255, 255, 0.22), transparent 45%),
linear-gradient(135deg, var(--brand) 0%, var(--brand-700) 60%, var(--ink) 130%);
overflow: hidden;
}
.preview__face::after {
content: "";
position: absolute;
right: -40px;
bottom: -60px;
width: 180px;
height: 180px;
border-radius: 50%;
background: rgba(0, 180, 166, 0.28);
filter: blur(6px);
}
.preview__front {
display: flex;
flex-direction: column;
}
.preview__top {
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
z-index: 1;
}
.preview__chip {
width: 42px;
height: 30px;
border-radius: 6px;
background: linear-gradient(135deg, #f5d77a, #c9a23c);
position: relative;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12);
}
.preview__chip::before {
content: "";
position: absolute;
inset: 6px 8px;
border-radius: 3px;
background:
linear-gradient(90deg, transparent 48%, rgba(0, 0, 0, 0.25) 48% 52%, transparent 52%),
linear-gradient(0deg, transparent 48%, rgba(0, 0, 0, 0.25) 48% 52%, transparent 52%);
}
.preview__brand {
font-size: 13px;
font-weight: 800;
letter-spacing: 0.06em;
min-height: 26px;
display: inline-flex;
align-items: center;
}
.preview__brand svg {
display: block;
height: 26px;
width: auto;
}
.preview__num {
margin-top: auto;
font-size: clamp(17px, 5.2vw, 22px);
font-weight: 600;
letter-spacing: 0.08em;
font-variant-numeric: tabular-nums;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
position: relative;
z-index: 1;
}
.preview__row {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
margin-top: 14px;
position: relative;
z-index: 1;
}
.preview__group {
display: grid;
gap: 3px;
min-width: 0;
}
.preview__group--exp {
text-align: right;
}
.preview__cap {
font-size: 8.5px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.62);
}
.preview__val {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 26ch;
}
/* Back face */
.preview__back {
transform: rotateY(180deg);
padding: 0;
}
.preview__stripe {
margin-top: 18px;
height: 38px;
background: rgba(0, 0, 0, 0.55);
}
.preview__sign {
display: flex;
align-items: center;
gap: 0;
margin: 16px 18px 0;
}
.preview__sign-line {
flex: 1;
height: 30px;
border-radius: 4px;
background: repeating-linear-gradient(
-45deg,
#efe9da,
#efe9da 6px,
#e2dccb 6px,
#e2dccb 12px
);
}
.preview__cvc {
background: var(--white);
color: var(--ink);
font-weight: 700;
font-size: 13px;
letter-spacing: 0.18em;
font-variant-numeric: tabular-nums;
padding: 6px 12px;
border-radius: 0 4px 4px 0;
min-width: 56px;
text-align: center;
}
.preview__back-note {
margin: 12px 18px 0;
font-size: 8.5px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.55);
position: relative;
z-index: 1;
}
/* ── Form ── */
.form {
display: grid;
gap: 16px;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.field {
display: grid;
gap: 7px;
min-width: 0;
}
.field__label {
font-size: 13px;
font-weight: 600;
color: var(--ink-2);
}
.req {
color: var(--danger);
font-weight: 700;
}
.control {
position: relative;
display: flex;
align-items: center;
}
.control--narrow {
max-width: 180px;
}
.input {
width: 100%;
font: inherit;
font-size: 15px;
color: var(--ink);
background: var(--white);
border: 1.5px solid var(--line-2);
border-radius: var(--r-md);
padding: 12px 42px 12px 14px;
box-shadow: var(--sh-1);
transition:
border-color 0.16s ease,
box-shadow 0.16s ease,
background 0.16s ease;
}
.input--number {
padding-left: 48px;
font-variant-numeric: tabular-nums;
letter-spacing: 0.04em;
}
.input::placeholder {
color: #aab0c8;
letter-spacing: 0;
}
.input:hover:not(:focus) {
border-color: rgba(16, 19, 34, 0.26);
}
.input:focus-visible,
.input:focus {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 4px rgba(91, 91, 240, 0.16);
}
/* Brand glyph inside the number control */
.control__brand {
position: absolute;
left: 14px;
width: 26px;
height: 18px;
display: grid;
place-items: center;
pointer-events: none;
color: var(--muted);
}
.control__brand svg {
display: block;
height: 18px;
width: auto;
}
/* Status icon inside control */
.control__icon {
position: absolute;
right: 14px;
width: 18px;
height: 18px;
display: grid;
place-items: center;
opacity: 0;
transform: scale(0.7);
transition:
opacity 0.18s ease,
transform 0.18s ease;
pointer-events: none;
}
.control__icon::before {
content: "";
width: 18px;
height: 18px;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}
/* ── Valid state ── */
.field.is-valid .input {
border-color: var(--ok);
}
.field.is-valid .input:focus-visible,
.field.is-valid .input:focus {
box-shadow: 0 0 0 4px rgba(47, 158, 111, 0.16);
}
.field.is-valid .control__icon {
opacity: 1;
transform: scale(1);
}
.field.is-valid .control__icon::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%232f9e6f' d='M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z'/%3E%3C/svg%3E");
}
/* ── Error state ── */
.field.is-error .input {
border-color: var(--danger);
background: #fff7f5;
}
.field.is-error .input:focus-visible,
.field.is-error .input:focus {
box-shadow: 0 0 0 4px rgba(212, 80, 62, 0.16);
}
.field.is-error .control__icon {
opacity: 1;
transform: scale(1);
}
.field.is-error .control__icon::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23d4503e' d='M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20m1 15h-2v-2h2zm0-4h-2V7h2z'/%3E%3C/svg%3E");
}
.field.is-error .control__brand {
color: var(--danger);
}
/* ── Helper / error text ── */
.help {
font-size: 12.5px;
color: var(--muted);
min-height: 1.1em;
transition: color 0.15s ease;
}
.help.is-error {
color: var(--danger);
font-weight: 500;
}
.help.is-ok {
color: var(--ok);
font-weight: 500;
}
/* ── Submit ── */
.submit {
position: relative;
margin-top: 4px;
width: 100%;
font: inherit;
font-size: 15px;
font-weight: 700;
color: var(--white);
background: linear-gradient(180deg, var(--brand), var(--brand-d));
border: none;
border-radius: var(--r-md);
padding: 14px 16px;
cursor: pointer;
box-shadow: 0 8px 18px rgba(70, 70, 214, 0.28);
transition:
transform 0.12s ease,
box-shadow 0.16s ease,
background 0.16s ease,
opacity 0.16s ease;
}
.submit:hover:not(:disabled) {
background: linear-gradient(180deg, var(--brand-d), var(--brand-700));
}
.submit:active:not(:disabled) {
transform: translateY(1px);
}
.submit:focus-visible {
outline: none;
box-shadow:
0 0 0 4px rgba(91, 91, 240, 0.3),
0 8px 18px rgba(70, 70, 214, 0.28);
}
.submit:disabled {
cursor: not-allowed;
opacity: 0.55;
background: var(--ink-2);
box-shadow: none;
}
.submit__spin {
display: none;
width: 16px;
height: 16px;
margin-left: 9px;
vertical-align: -3px;
border: 2px solid rgba(255, 255, 255, 0.4);
border-top-color: var(--white);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.submit.is-loading .submit__spin {
display: inline-block;
}
.submit.is-loading .submit__label {
opacity: 0.85;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.form__foot {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 12px;
color: var(--muted);
text-align: center;
}
.form__foot svg {
color: var(--ok);
flex: none;
}
/* ── Success overlay ── */
.done {
position: absolute;
inset: 0;
background: var(--surface);
display: grid;
place-content: center;
justify-items: center;
text-align: center;
gap: 8px;
padding: 32px;
animation: fade 0.3s ease;
}
.done[hidden] {
display: none;
}
@keyframes fade {
from {
opacity: 0;
transform: translateY(8px);
}
}
.done__seal {
width: 64px;
height: 64px;
display: grid;
place-items: center;
color: var(--white);
background: linear-gradient(180deg, var(--ok), #257a57);
border-radius: 50%;
box-shadow: 0 8px 20px rgba(47, 158, 111, 0.35);
margin-bottom: 6px;
animation: pop 0.4s cubic-bezier(0.18, 1.4, 0.4, 1);
}
@keyframes pop {
from {
transform: scale(0.4);
opacity: 0;
}
}
.done__title {
font-size: 22px;
font-weight: 800;
letter-spacing: -0.01em;
}
.done__title:focus-visible {
outline: none;
}
.done__text {
font-size: 14px;
color: var(--muted);
max-width: 28ch;
}
.done__text strong {
color: var(--ink-2);
font-variant-numeric: tabular-nums;
}
.ghost {
margin-top: 10px;
font: inherit;
font-size: 14px;
font-weight: 600;
color: var(--brand-d);
background: var(--brand-50);
border: 1px solid rgba(91, 91, 240, 0.2);
border-radius: var(--r-md);
padding: 10px 18px;
cursor: pointer;
transition: background 0.15s ease;
}
.ghost:hover {
background: #e3e6ff;
}
.ghost:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(91, 91, 240, 0.3);
}
/* ── Toast ── */
.toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translate(-50%, 140%);
background: var(--ink);
color: var(--white);
font-size: 13.5px;
font-weight: 500;
padding: 11px 18px;
border-radius: 999px;
box-shadow: var(--sh-2);
opacity: 0;
transition:
transform 0.3s cubic-bezier(0.2, 0.9, 0.3, 1),
opacity 0.3s ease;
pointer-events: none;
z-index: 50;
}
.toast.is-show {
transform: translate(-50%, 0);
opacity: 1;
}
.toast.is-error {
background: var(--danger);
}
/* ── Responsive ── */
@media (max-width: 520px) {
.page {
padding: 18px 14px;
align-items: start;
}
.card {
padding: 22px 18px 20px;
border-radius: var(--r-md);
}
.card__title {
font-size: 21px;
}
.row {
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.control--narrow {
max-width: none;
}
.preview__face {
padding: 15px 16px;
}
.toast {
left: 14px;
right: 14px;
bottom: 14px;
transform: translateY(140%);
text-align: center;
}
.toast.is-show {
transform: translateY(0);
}
}
@media (max-width: 360px) {
.preview__num {
letter-spacing: 0.04em;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}/* Card / payment input group — vanilla JS demo.
Live formatting, brand detection, Luhn check, animated card preview. */
(function () {
"use strict";
var form = document.getElementById("pay");
if (!form) return;
var numberEl = document.getElementById("cc-number");
var nameEl = document.getElementById("cc-name");
var expEl = document.getElementById("cc-exp");
var cvcEl = document.getElementById("cc-cvc");
var zipEl = document.getElementById("cc-zip");
var submitBtn = document.getElementById("submit");
// Preview nodes
var cardInner = document.querySelector("[data-card-inner]");
var pvNumber = document.querySelector("[data-pv-number]");
var pvName = document.querySelector("[data-pv-name]");
var pvExp = document.querySelector("[data-pv-exp]");
var pvCvc = document.querySelector("[data-pv-cvc]");
var brandMark = document.querySelector("[data-brand-mark]");
var brandIcon = document.querySelector("[data-brand-icon]");
// Done overlay
var doneEl = document.getElementById("done");
var doneCard = document.querySelector("[data-done-card]");
var resetBtn = document.getElementById("reset");
// Toast
var toastEl = document.getElementById("toast");
var toastTimer;
function toast(msg, isError) {
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.classList.toggle("is-error", !!isError);
toastEl.classList.add("is-show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("is-show");
}, 2600);
}
/* ── Brand detection by prefix ── */
// Returns { id, label, glyph(color), gaps, lengths, cvc }
var BRANDS = {
visa: { label: "VISA", lengths: [16, 19], cvc: 3 },
mastercard: { label: "Mastercard", lengths: [16], cvc: 3 },
amex: { label: "AMEX", lengths: [15], cvc: 4 },
discover: { label: "Discover", lengths: [16, 19], cvc: 3 },
};
function detectBrand(digits) {
if (/^4/.test(digits)) return "visa";
if (/^3[47]/.test(digits)) return "amex";
if (/^(5[1-5]|2[2-7])/.test(digits)) return "mastercard";
if (/^(6011|65|64[4-9])/.test(digits)) return "discover";
return null;
}
// Inline SVG marks (mono / colored), no external assets.
function brandSvg(id, light) {
var stroke = light ? "#ffffff" : "currentColor";
if (id === "visa") {
return (
'<svg viewBox="0 0 48 16" aria-hidden="true">' +
'<text x="0" y="13" font-family="Inter, sans-serif" font-size="14" font-style="italic" ' +
'font-weight="800" letter-spacing="0.5" fill="' +
(light ? "#ffffff" : "#1a1f71") +
'">VISA</text></svg>'
);
}
if (id === "mastercard") {
return (
'<svg viewBox="0 0 40 24" aria-hidden="true">' +
'<circle cx="15" cy="12" r="10" fill="#eb001b"/>' +
'<circle cx="25" cy="12" r="10" fill="#f79e1b"/>' +
'<path d="M20 4.6a10 10 0 0 0 0 14.8 10 10 0 0 0 0-14.8z" fill="#ff5f00"/></svg>'
);
}
if (id === "amex") {
return (
'<svg viewBox="0 0 48 16" aria-hidden="true">' +
'<rect x="0" y="1" width="48" height="14" rx="2" fill="' +
(light ? "rgba(255,255,255,0.18)" : "#2e77bb") +
'"/>' +
'<text x="24" y="11.5" text-anchor="middle" font-family="Inter, sans-serif" font-size="7.5" ' +
'font-weight="800" letter-spacing="0.5" fill="#ffffff">AMEX</text></svg>'
);
}
if (id === "discover") {
return (
'<svg viewBox="0 0 56 16" aria-hidden="true">' +
'<text x="0" y="13" font-family="Inter, sans-serif" font-size="11" font-weight="800" ' +
'fill="' +
(light ? "#ffffff" : "#101322") +
'">DISC</text>' +
'<circle cx="50" cy="9" r="5" fill="#f76b1c"/></svg>'
);
}
// generic card glyph
return (
'<svg viewBox="0 0 24 18" aria-hidden="true">' +
'<rect x="1" y="1" width="22" height="16" rx="3" fill="none" stroke="' +
stroke +
'" stroke-width="1.6"/>' +
'<rect x="1" y="5" width="22" height="3" fill="' +
stroke +
'"/></svg>'
);
}
function gapsFor(brand) {
return brand === "amex" ? [4, 10] : [4, 8, 12, 16];
}
function maxDigitsFor(brand) {
if (brand === "amex") return 15;
var b = BRANDS[brand];
return b ? Math.max.apply(null, b.lengths) : 16;
}
function cvcLenFor(brand) {
var b = BRANDS[brand];
return b ? b.cvc : 3;
}
/* ── Luhn check ── */
function luhnValid(digits) {
if (digits.length < 12) return false;
var sum = 0;
var alt = false;
for (var i = digits.length - 1; i >= 0; i--) {
var n = parseInt(digits.charAt(i), 10);
if (alt) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
alt = !alt;
}
return sum % 10 === 0;
}
/* ── Formatting helpers ── */
function onlyDigits(s) {
return (s || "").replace(/\D+/g, "");
}
function formatCardNumber(digits, brand) {
var gaps = gapsFor(brand);
var out = "";
for (var i = 0; i < digits.length; i++) {
if (gaps.indexOf(i) !== -1 && i !== 0) out += " ";
out += digits.charAt(i);
}
return out;
}
function formatExpiry(digits) {
digits = digits.slice(0, 4);
if (digits.length >= 3) return digits.slice(0, 2) + "/" + digits.slice(2);
return digits;
}
/* ── State ── */
var state = { number: false, name: false, exp: false, cvc: false, zip: false };
var touched = {};
var currentBrand = null;
function fieldEl(name) {
return document.querySelector('.field[data-field="' + name + '"]');
}
function helpEl(name) {
return document.getElementById(name + "-help");
}
var DEFAULT_HELP = {
number: "Visa, Mastercard and Amex are detected automatically.",
name: "Exactly as printed on the card.",
exp: "Month / year.",
cvc: "3 digits on the back.",
zip: "5 digits, ZIP+4 accepted.",
};
function setState(name, input, ok, errMsg, okMsg) {
state[name] = ok;
var fe = fieldEl(name);
var he = helpEl(name);
var showError = touched[name] && !ok;
fe.classList.toggle("is-error", showError);
fe.classList.toggle("is-valid", ok);
input.setAttribute("aria-invalid", showError ? "true" : "false");
if (he) {
if (showError) {
he.textContent = errMsg;
he.className = "help is-error";
he.setAttribute("role", "alert");
} else if (ok && okMsg) {
he.textContent = okMsg;
he.className = "help is-ok";
he.removeAttribute("role");
} else {
he.textContent = DEFAULT_HELP[name];
he.className = "help";
he.removeAttribute("role");
}
}
refreshSubmit();
}
function refreshSubmit() {
var allOk = state.number && state.name && state.exp && state.cvc && state.zip;
submitBtn.disabled = !allOk;
}
/* ── Validators ── */
function validateNumber() {
var digits = onlyDigits(numberEl.value);
var brand = detectBrand(digits);
applyBrand(brand);
var lengths = brand && BRANDS[brand] ? BRANDS[brand].lengths : [13, 14, 15, 16, 17, 18, 19];
var lengthOk = lengths.indexOf(digits.length) !== -1;
var ok = lengthOk && luhnValid(digits);
var msg;
if (digits.length === 0) msg = "Enter the long number on the front of your card.";
else if (!lengthOk) msg = "Card number looks incomplete.";
else if (!luhnValid(digits)) msg = "This card number is invalid (checksum failed).";
setState("number", numberEl, ok, msg, brand ? BRANDS[brand].label + " card recognised." : "Looks good.");
return ok;
}
function validateName() {
var v = nameEl.value.trim();
var ok = v.length >= 2 && /^[\p{L}][\p{L} '.-]*$/u.test(v);
var msg = v.length === 0 ? "Add the name printed on the card." : "Use letters, spaces, apostrophes or hyphens.";
setState("name", nameEl, ok, msg);
return ok;
}
function validateExp() {
var digits = onlyDigits(expEl.value);
var ok = false;
var msg = "Use the MM/YY format.";
if (digits.length === 4) {
var mm = parseInt(digits.slice(0, 2), 10);
var yy = parseInt(digits.slice(2), 10);
if (mm >= 1 && mm <= 12) {
var now = new Date();
var curY = now.getFullYear() % 100;
var curM = now.getMonth() + 1;
if (yy > curY || (yy === curY && mm >= curM)) {
ok = true;
} else {
msg = "That date is in the past.";
}
} else {
msg = "Month must be 01-12.";
}
} else if (digits.length === 0) {
msg = "Enter the expiry date.";
}
setState("exp", expEl, ok, msg);
return ok;
}
function validateCvc() {
var need = cvcLenFor(currentBrand);
var digits = onlyDigits(cvcEl.value);
var ok = digits.length === need;
var msg = digits.length === 0 ? "Enter the security code." : "CVC must be " + need + " digits for this card.";
setState("cvc", cvcEl, ok, msg);
return ok;
}
function validateZip() {
var v = zipEl.value.trim();
var ok = /^\d{5}(-?\d{4})?$/.test(v);
var msg = v.length === 0 ? "Enter your billing ZIP." : "Use 5 digits, or ZIP+4 (12345-6789).";
setState("zip", zipEl, ok, msg);
return ok;
}
/* ── Brand application (icon + preview + CVC mask) ── */
function applyBrand(brand) {
if (brand === currentBrand) {
updateBrandIconState();
return;
}
currentBrand = brand;
var iconSvg = brandSvg(brand || "generic", false);
brandIcon.innerHTML = iconSvg;
brandMark.innerHTML = brand ? brandSvg(brand, true) : "";
// Re-mask CVC for new brand width
cvcEl.maxLength = brand === "amex" ? 4 : 4; // allow up to 4; validate exact
cvcEl.placeholder = cvcLenFor(brand) === 4 ? "1234" : "123";
var cvcHelp = helpEl("cvc");
if (cvcHelp && !touched.cvc) {
DEFAULT_HELP.cvc = cvcLenFor(brand) === 4 ? "4 digits on the front." : "3 digits on the back.";
cvcHelp.textContent = DEFAULT_HELP.cvc;
} else {
DEFAULT_HELP.cvc = cvcLenFor(brand) === 4 ? "4 digits on the front." : "3 digits on the back.";
}
}
function updateBrandIconState() {
// keep icon color in sync with error state handled by CSS
}
/* ── Preview sync ── */
function syncPreview() {
var digits = onlyDigits(numberEl.value);
var formatted = formatCardNumber(digits, currentBrand);
var max = maxDigitsFor(currentBrand);
// build masked display: typed digits then bullets to fill the brand template
var template = currentBrand === "amex" ? "•••• •••••• •••••" : "•••• •••• •••• ••••";
var display = formatted;
if (formatted.length === 0) {
display = template;
} else {
// pad remaining with bullets matching the group layout
var remainingDigits = max - digits.length;
if (remainingDigits > 0) {
var pad = formatCardNumber(digits + Array(remainingDigits + 1).join("0"), currentBrand);
// replace the padded zeros (after the real digits) with bullets
var realLen = formatted.length;
display = formatted + pad.slice(realLen).replace(/0/g, "•");
}
}
pvNumber.textContent = display;
pvName.textContent = nameEl.value.trim() ? nameEl.value.trim().toUpperCase() : "FULL NAME";
pvExp.textContent = expEl.value ? formatExpiry(onlyDigits(expEl.value)).padEnd(5, "·").slice(0, 5) : "MM/YY";
var cvcDigits = onlyDigits(cvcEl.value);
pvCvc.textContent = cvcDigits ? cvcDigits.replace(/./g, "•") : "•••";
}
/* ── Input wiring ── */
numberEl.addEventListener("input", function () {
var digits = onlyDigits(numberEl.value);
var brand = detectBrand(digits);
var max = maxDigitsFor(brand);
digits = digits.slice(0, max);
var caretAtEnd = numberEl.selectionStart === numberEl.value.length;
numberEl.value = formatCardNumber(digits, brand);
applyBrand(brand);
if (touched.number) validateNumber();
else updateBrandLabelOnly(brand);
syncPreview();
if (caretAtEnd) {
numberEl.selectionStart = numberEl.selectionEnd = numberEl.value.length;
}
});
function updateBrandLabelOnly(brand) {
// when not yet touched/blurred, just keep helper informative
var he = helpEl("number");
if (he && !touched.number) {
he.textContent = brand
? BRANDS[brand].label + " detected."
: DEFAULT_HELP.number;
}
}
expEl.addEventListener("input", function () {
expEl.value = formatExpiry(onlyDigits(expEl.value));
if (touched.exp) validateExp();
syncPreview();
});
cvcEl.addEventListener("input", function () {
var need = cvcLenFor(currentBrand);
cvcEl.value = onlyDigits(cvcEl.value).slice(0, need);
if (touched.cvc) validateCvc();
syncPreview();
});
nameEl.addEventListener("input", function () {
if (touched.name) validateName();
syncPreview();
});
zipEl.addEventListener("input", function () {
if (touched.zip) validateZip();
});
// Validate on blur (mark touched)
function bindBlur(el, name, fn) {
el.addEventListener("blur", function () {
touched[name] = true;
fn();
});
}
bindBlur(numberEl, "number", validateNumber);
bindBlur(nameEl, "name", validateName);
bindBlur(expEl, "exp", validateExp);
bindBlur(cvcEl, "cvc", validateCvc);
bindBlur(zipEl, "zip", validateZip);
// Flip card when focusing CVC (mirrors the physical card)
cvcEl.addEventListener("focus", function () {
cardInner.classList.add("is-flipped");
});
cvcEl.addEventListener("blur", function () {
cardInner.classList.remove("is-flipped");
});
/* ── Submit ── */
form.addEventListener("submit", function (e) {
e.preventDefault();
// Mark all touched and validate everything
["number", "name", "exp", "cvc", "zip"].forEach(function (n) {
touched[n] = true;
});
var results = [
validateNumber(),
validateName(),
validateExp(),
validateCvc(),
validateZip(),
];
if (results.indexOf(false) !== -1) {
// focus first invalid
var order = [numberEl, nameEl, expEl, cvcEl, zipEl];
var keys = ["number", "name", "exp", "cvc", "zip"];
for (var i = 0; i < keys.length; i++) {
if (!state[keys[i]]) {
order[i].focus();
break;
}
}
toast("Please fix the highlighted fields.", true);
return;
}
// Simulate processing
submitBtn.disabled = true;
submitBtn.classList.add("is-loading");
var label = submitBtn.querySelector(".submit__label");
var prevLabel = label.textContent;
label.textContent = "Processing…";
setTimeout(function () {
submitBtn.classList.remove("is-loading");
label.textContent = prevLabel;
var last4 = onlyDigits(numberEl.value).slice(-4);
var brandLabel = currentBrand ? BRANDS[currentBrand].label : "card";
if (doneCard) doneCard.textContent = brandLabel + " •••• " + last4;
doneEl.hidden = false;
var title = doneEl.querySelector(".done__title");
if (title) title.focus();
toast("Payment confirmed (demo).");
}, 1100);
});
resetBtn.addEventListener("click", function () {
form.reset();
doneEl.hidden = true;
touched = {};
state = { number: false, name: false, exp: false, cvc: false, zip: false };
currentBrand = null;
["number", "name", "exp", "cvc", "zip"].forEach(function (n) {
var fe = fieldEl(n);
var he = helpEl(n);
fe.classList.remove("is-error", "is-valid");
if (he) {
he.textContent = DEFAULT_HELP[n];
he.className = "help";
he.removeAttribute("role");
}
});
brandIcon.innerHTML = brandSvg("generic", false);
brandMark.innerHTML = "";
cardInner.classList.remove("is-flipped");
syncPreview();
refreshSubmit();
numberEl.focus();
});
/* ── Init ── */
brandIcon.innerHTML = brandSvg("generic", false);
refreshSubmit();
syncPreview();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Card / payment input group</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="page">
<section class="card" aria-labelledby="form-title">
<header class="card__head">
<span class="badge">
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path
fill="currentColor"
d="M4 5h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2m0 4v8h16v-8zm0-2h16V7H4z"
/>
</svg>
Secure checkout
</span>
<h1 id="form-title" class="card__title">Pay with card</h1>
<p class="card__sub">
Demo only — nothing is charged. Enter any test card to see live formatting,
brand detection and a Luhn check.
</p>
</header>
<!-- ── Live card preview ── -->
<div class="preview" data-preview>
<div class="preview__inner" data-card-inner>
<!-- Front -->
<div class="preview__face preview__front">
<div class="preview__top">
<span class="preview__chip" aria-hidden="true"></span>
<span class="preview__brand" data-brand-mark aria-hidden="true"></span>
</div>
<div class="preview__num" data-pv-number>•••• •••• •••• ••••</div>
<div class="preview__row">
<div class="preview__group">
<span class="preview__cap">Card holder</span>
<span class="preview__val" data-pv-name>FULL NAME</span>
</div>
<div class="preview__group preview__group--exp">
<span class="preview__cap">Expires</span>
<span class="preview__val" data-pv-exp>MM/YY</span>
</div>
</div>
</div>
<!-- Back -->
<div class="preview__face preview__back">
<div class="preview__stripe" aria-hidden="true"></div>
<div class="preview__sign">
<span class="preview__sign-line" aria-hidden="true"></span>
<span class="preview__cvc" data-pv-cvc>•••</span>
</div>
<div class="preview__back-note">CVC / security code</div>
</div>
</div>
</div>
<form id="pay" class="form" novalidate>
<!-- Card number -->
<div class="field" data-field="number">
<label class="field__label" for="cc-number">
Card number <span class="req" aria-hidden="true">*</span>
</label>
<div class="control">
<span class="control__brand" data-brand-icon aria-hidden="true"></span>
<input
id="cc-number"
name="number"
type="text"
class="input input--number"
autocomplete="cc-number"
inputmode="numeric"
placeholder="1234 5678 9012 3456"
maxlength="23"
required
aria-required="true"
aria-describedby="number-help"
/>
<span class="control__icon" aria-hidden="true"></span>
</div>
<p id="number-help" class="help">Visa, Mastercard and Amex are detected automatically.</p>
</div>
<!-- Name -->
<div class="field" data-field="name">
<label class="field__label" for="cc-name">
Cardholder name <span class="req" aria-hidden="true">*</span>
</label>
<div class="control">
<input
id="cc-name"
name="name"
type="text"
class="input"
autocomplete="cc-name"
placeholder="Mara Delacroix"
required
aria-required="true"
aria-describedby="name-help"
/>
<span class="control__icon" aria-hidden="true"></span>
</div>
<p id="name-help" class="help">Exactly as printed on the card.</p>
</div>
<div class="row">
<!-- Expiry -->
<div class="field" data-field="exp">
<label class="field__label" for="cc-exp">
Expiry <span class="req" aria-hidden="true">*</span>
</label>
<div class="control">
<input
id="cc-exp"
name="exp"
type="text"
class="input"
autocomplete="cc-exp"
inputmode="numeric"
placeholder="MM/YY"
maxlength="5"
required
aria-required="true"
aria-describedby="exp-help"
/>
<span class="control__icon" aria-hidden="true"></span>
</div>
<p id="exp-help" class="help">Month / year.</p>
</div>
<!-- CVC -->
<div class="field" data-field="cvc">
<label class="field__label" for="cc-cvc">
CVC <span class="req" aria-hidden="true">*</span>
</label>
<div class="control">
<input
id="cc-cvc"
name="cvc"
type="text"
class="input"
autocomplete="cc-csc"
inputmode="numeric"
placeholder="123"
maxlength="4"
required
aria-required="true"
aria-describedby="cvc-help"
/>
<span class="control__icon" aria-hidden="true"></span>
</div>
<p id="cvc-help" class="help">3 digits on the back.</p>
</div>
</div>
<!-- Postal code -->
<div class="field" data-field="zip">
<label class="field__label" for="cc-zip">
Billing postal code <span class="req" aria-hidden="true">*</span>
</label>
<div class="control control--narrow">
<input
id="cc-zip"
name="zip"
type="text"
class="input"
autocomplete="postal-code"
inputmode="numeric"
placeholder="94105"
maxlength="10"
required
aria-required="true"
aria-describedby="zip-help"
/>
<span class="control__icon" aria-hidden="true"></span>
</div>
<p id="zip-help" class="help">5 digits, ZIP+4 accepted.</p>
</div>
<button id="submit" type="submit" class="submit">
<span class="submit__label">Pay $48.00</span>
<span class="submit__spin" aria-hidden="true"></span>
</button>
<p class="form__foot">
<svg viewBox="0 0 24 24" width="13" height="13" aria-hidden="true">
<path
fill="currentColor"
d="M12 1 4 4v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V4zm0 10.9h6.3c-.5 3.5-2.8 6.7-6.3 7.8z"
/>
</svg>
Test data only. We never store real card details.
</p>
</form>
<!-- Success overlay -->
<div id="done" class="done" hidden>
<div class="done__seal" aria-hidden="true">
<svg viewBox="0 0 24 24" width="30" height="30">
<path
fill="currentColor"
d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"
/>
</svg>
</div>
<h2 class="done__title" tabindex="-1">Payment confirmed (demo)</h2>
<p class="done__text">
$48.00 charged to <strong data-done-card>your card</strong>. A receipt was sent to
your email.
</p>
<button type="button" class="ghost" id="reset">Make another payment</button>
</div>
</section>
</main>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Card / payment input group
A self-contained checkout card field set built entirely in vanilla JavaScript. As you type the card number it formats into the right groups — four-by-four for most networks, the 4-6-5 layout for American Express — while the issuing brand is detected from the leading digits and surfaced both inside the input and on a 3D card preview drawn in CSS. A live Luhn checksum validates the number the moment a complete card is entered, so invalid sequences are flagged inline with aria-invalid and a helper message rather than waiting for submit.
The animated preview mirrors every field in real time: the masked number, the uppercased cardholder name, and the MM/YY expiry. The expiry input is masked and guarded against past dates, and the CVC field adapts its required length to the detected brand (four digits for Amex, three otherwise). Focusing the CVC flips the card to reveal the signature strip and security code, exactly as you would turn a physical card over.
Validation runs on blur and again on submit, focusing the first invalid field and announcing problems through an aria-live toast. A successful submit simulates processing and then reveals a “Payment confirmed (demo)” state showing the brand and last four digits, with a button to start over. Everything is client-side, the layout collapses cleanly to a single column at 360px, and motion is disabled under prefers-reduced-motion.
Demo only — no network requests, no charges, and only fictional or test card data.