This is the whole build of the glass credit card checkout, in the order it was written, for a made-up bank, Aether Finance. A thick slab of clear glass floats over a pastel page; a beam of light runs through it and leaves two star flares at its corners, its edges split the light into a rainbow, and whatever you type is cut into the glass with a little laser glint. Focus the security code and the card turns over. Each stage below is the page as it stood at that step. It shows a still, and Run it here starts the live page in its place. The code under each heading is only what that stage adds. The finished source is at the end, and on GitHub under the MIT licence.
There is no framework, no build step and no image: one HTML page, one style sheet and five ES modules. The glass is one WebGL 2 shader; every character on the card is real DOM text riding on the same 3D matrix, so it stays sharp.
1 · The checkout, in HTML and CSS
The card is a button (it flips), and it has two faces. Each face holds the engraving slots: the number, the expiry, the name on the front; a stripe, a signature panel and the CVV ring on the back. At this stage the sample is plain text.
<button class="card3d" id="card" type="button" aria-pressed="false" aria-labelledby="card-label" aria-describedby="card-hint">
<span class="face front" aria-hidden="true">
<span class="glint"></span>
<span class="brandmark">aether</span>
<span class="net" data-net="lumen"></span>
<span class="num engr" id="eng-num">4000 1234 5678 9017</span>
<span class="exp engr"><span class="exp-k">VALID<br>THRU</span><span class="exp-v" id="eng-exp">08 / 29</span></span>
<span class="name engr" id="eng-name">ALEX R. THOMPSON</span>
</span>
<span class="face back" aria-hidden="true">
<span class="glint"></span>
<span class="stripe"></span>
<span class="sig"><span class="sig-name" id="eng-sig">Alex R. Thompson</span></span>
<span class="cvv-ring"><span class="cvv engr" id="eng-cvv">123</span></span>
<span class="fine engr">AUTHORISED SIGNATURE · DEMO CARD · NOT A REAL ACCOUNT</span>
<span class="net net-back" data-net="lumen"></span>
</span>
<span class="sr-only" id="card-label">Card preview, front. Activate to flip.</span>
</button>
The form is an ordinary form, with the autocomplete tokens a browser needs to fill a saved card:
cc-number, cc-name, cc-exp and cc-csc. Every field has a visible
label and an error line it points to.
<div class="field" data-f="number">
<label for="cc-number">Card</label>
<div class="ctl">
<input id="cc-number" name="cc-number" type="text" inputmode="numeric" autocomplete="cc-number" autocorrect="off" spellcheck="false"
placeholder="4000 1234 5678 9017" maxlength="23" aria-describedby="cc-number-err" aria-invalid="false" required>
<span class="ctl-net" id="ctl-net" aria-hidden="true"></span>
</div>
<p class="err" id="cc-number-err"></p>
</div>
Chromium rasterises a layer in perspective at about its own size, so text on a turning card goes soft on a sharp screen. The fix: lay each face out twice as large and scale it down inside the 3D transform.
.face {
position: absolute; left: 0; top: 0; display: block;
width: calc(100% * var(--k, 2)); height: calc(100% * var(--k, 2));
font-size: calc(var(--k, 2) * 1em); border-radius: calc(var(--rc, 26px) * var(--k, 2));
backface-visibility: hidden; -webkit-backface-visibility: hidden;
transform-origin: 0 0; will-change: transform; contain: layout style;
transform: translateZ(calc(var(--th, 13px) / 2 + .5px)) scale(calc(1 / var(--k, 2)));
text-align: left; line-height: 1;
}
.face.back { transform: translateX(var(--cw, 470px)) rotateY(180deg) translateZ(calc(var(--th, 13px) / 2 + .5px)) scale(calc(1 / var(--k, 2))); }
The engraving is white type cut into the glass: a shaded top wall, a lit bottom lip and a soft glow inside.
.engr {
position: absolute; color: #fff; font-weight: 400; white-space: nowrap; will-change: transform; /* own small layer: a keystroke re-rasters one line, not the face */
font-variant-numeric: tabular-nums; font-feature-settings: 'tnum' 1, 'ss01' 1;
/* a cut into the glass: the top wall in shade, the bottom lip catching the light, a soft inner glow */
text-shadow: 0 -.05em 0 rgba(58, 38, 92, .62), 0 .05em 0 rgba(255, 255, 255, .9), 0 .02em .14em rgba(64, 44, 104, .42), 0 0 .55em rgba(255, 250, 255, .55);
}
Until the WebGL glass arrives in stage 4, the card is frosted in CSS with a conic rainbow rim. The finished page keeps this as its fallback: a browser without WebGL 2 sees exactly this, still turning in 3D.
/* no WebGL: the same layout, a CSS frosted card */
.no-gl .face.front, .no-gl .face.back {
background: linear-gradient(135deg, rgba(255, 255, 255, .5), rgba(255, 255, 255, .2) 55%, rgba(255, 255, 255, .34));
box-shadow: inset 0 0 0 1.5px rgba(255, 255, 255, .85), inset 0 0 0 4px rgba(190, 170, 240, .25), 0 1.6em 3em rgba(110, 80, 150, .18);
-webkit-backdrop-filter: blur(14px) saturate(140%); backdrop-filter: blur(14px) saturate(140%);
}
.no-gl .face::before {
content: ''; position: absolute; inset: -1px; border-radius: inherit; padding: 2px; pointer-events: none;
background: conic-gradient(from 200deg, var(--irid));
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask-composite: exclude; opacity: .8;
}

2 · A real card form
Now the module. The card type comes from the first digits, with the familiar prefix ranges, but the networks are made up: Lumen, Orbit, Prism and Nova, each with its own grouping, length, CVV length and tint.
export const NETWORKS = [
{ id: 'prism', name: 'Prism', test: /^3[47]/, lengths: [15], gaps: [4, 10], cvv: 4, tint: [0.80, 0.93, 1.00] },
{ id: 'nova', name: 'Nova', test: /^(6011|64[4-9]|65)/, lengths: [16], gaps: [4, 8, 12], cvv: 3, tint: [1.00, 0.86, 0.92] },
{ id: 'orbit', name: 'Orbit', test: /^(5[1-5]|2(2[2-9]|[3-6]\d|7[01]|720))/, lengths: [16], gaps: [4, 8, 12], cvv: 3, tint: [0.88, 0.86, 1.00] },
{ id: 'lumen', name: 'Lumen', test: /^4/, lengths: [16], gaps: [4, 8, 12], cvv: 3, tint: [1.00, 0.92, 0.86] },
];
export const GENERIC = { id: 'none', name: '', lengths: [16], gaps: [4, 8, 12], cvv: 3, tint: [1, 1, 1] };
A number is checked with the Luhn checksum, the same one every real card number passes.
export function luhn(d) {
let sum = 0, alt = false;
for (let i = d.length - 1; i >= 0; i--) {
let x = d.charCodeAt(i) - 48;
if (alt) { x *= 2; if (x > 9) x -= 9; }
sum += x; alt = !alt;
}
return d.length > 0 && sum % 10 === 0;
}
Grouping the number as you type moves the text under the caret. Count the digits before the caret, reformat, and put the caret back after the same number of digits.
function reformat(el, value, formatted, caretDigits, atEnd, inserting = false) {
el.value = formatted;
if (document.activeElement !== el) return;
let pos = formatted.length;
if (!atEnd) {
pos = 0; let seen = 0; while (pos < formatted.length && seen < caretDigits) { if (/\d/.test(formatted[pos])) seen++; pos++; }
if (inserting) while (pos < formatted.length && /[ /]/.test(formatted[pos])) pos++; // typed into a full group: hop the gap
}
el.setSelectionRange(pos, pos);
}
function onNumber(e) {
const el = F.number, v = el.value, c = el.selectionStart ?? v.length;
let d = digits(v); const n = detect(d); d = d.slice(0, maxLen(n));
reformat(el, v, groupNumber(d, n), digits(v.slice(0, c)).length, c >= v.length, /^insert/.test((e && e.inputType) || ''));
if (n.id !== net.id) {
const was = net; net = n;
if (n.id !== 'none' && d) say(`${n.name} card detected`);
if (was.cvv !== n.cvv) { F.cvv.maxLength = n.cvv; F.cvv.placeholder = n.cvv === 4 ? '1234' : '123'; const cd = digits(F.cvv.value).slice(0, n.cvv); F.cvv.value = cd; engraveCvv(cd, n.cvv, false); }
}
showNet(d ? n : detect(SAMPLE.number), !!d);
engraveNumber(d, d ? n : GENERIC);
check('number'); if (shown.number) show('number', true);
const typing = e && /^insert/.test(e.inputType || '');
if (typing && d.length === maxLen(n) && !errs.number) { show('number', false); F.name.focus(); }
refreshReady();
}
What you type is written onto the card slot by slot: typed digits, dots for the rest, the sample as a ghost when the field is empty.
function engraveNumber(d, net, { animate = true, mask = false } = {}) {
const g = groups(net), key = g.join();
if (key !== numKey) { numKey = key; ENG.num.innerHTML = g.map((n) => `<span class="grp">${'<span class="ch"></span>'.repeat(n)}</span>`).join(''); }
const els = ENG.num.querySelectorAll('.ch');
els.forEach((el, i) => {
if (!d) return slot(el, SAMPLE.number[i] || '•', 'ch ghost', false, 1);
if (mask) return slot(el, i >= els.length - 4 ? d[i] : '•', i >= els.length - 4 ? 'ch' : 'ch dot', false, 1);
if (i < d.length) return slot(el, d[i], 'ch', animate, 1);
return slot(el, '•', 'ch dot', false, 1);
});
}
Pay validates everything, puts each message under its field and moves the focus to the first one. A valid card goes through processing to a receipt, and the number on the card is masked to its last four digits.
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (busy) return;
const bad = FIELDS.filter((k) => check(k));
FIELDS.forEach((k) => show(k, !!errs[k]));
if (bad.length) {
say(bad.length === 1 ? errs[bad[0]] : `${bad.length} fields need attention. ${errs[bad[0]]}`);
F[bad[0]].focus();
return;
}
busy = true;
payBtn.classList.add('busy'); payBtn.setAttribute('aria-busy', 'true'); payTxt.textContent = 'Processing…';
FIELDS.forEach((k) => { F[k].readOnly = true; });
say('Processing payment');
await wait(reduced ? 250 : 1900);
const d = digits(F.number.value);
engraveNumber(d, net, { mask: true });
form.hidden = true; done.hidden = false;
$('#done-v').textContent = `$129.00 · ${net.name || 'Card'} •••• ${d.slice(-4)}`;
done.focus({ preventScroll: true });
say(`Payment approved. $129.00 charged to ${net.name} ending ${d.slice(-4)}.`);
payBtn.classList.remove('busy'); payBtn.removeAttribute('aria-busy'); payTxt.textContent = 'Pay $129.00';
busy = false;
});

3 · The card in 3D
One rotation matrix poses the card. It is built from three angles: the base pose, a slow idle drift, the pointer tilt, and the flip about the card's own vertical axis.
function mat(rx, ry, rz) {
const cx = Math.cos(rx), sx = Math.sin(rx), cy = Math.cos(ry), sy = Math.sin(ry), cz = Math.cos(rz), sz = Math.sin(rz);
const a = [cy, 0, sy, sx * sy, cx, -sx * cy, -cx * sy, sx, cx * cy]; // Rx · Ry
return [cz * a[0] - sz * a[3], cz * a[1] - sz * a[4], cz * a[2] - sz * a[5],
sz * a[0] + cz * a[3], sz * a[1] + cz * a[4], sz * a[2] + cz * a[5], a[6], a[7], a[8]]; // Rz · (Rx · Ry)
}
Everything moves on springs, integrated in small fixed steps so a slow frame never makes them jump. The flip spring is a little under-damped, so the card overshoots and settles like a real object.
function step(dt) {
const n = Math.max(1, Math.ceil(dt / (1 / 240))), h = dt / n;
const kF = 64, cF = 2 * Math.sqrt(kF) * 0.58;
const kD = 110, cD = 2 * Math.sqrt(kD) * 0.8;
const kP = 40, cP = 2 * Math.sqrt(kP) * 0.9;
for (let i = 0; i < n; i++) {
if (!drag || !drag.moved) { P.fv += (kF * (P.fT - P.f) - cF * P.fv) * h; P.f += P.fv * h; }
if (!drag || !drag.moved) { P.dyv += (kD * (P.dyT - P.dy) - cD * P.dyv) * h; P.dy += P.dyv * h; }
P.pxv += (kP * (P.tx - P.px) - cP * P.pxv) * h; P.px += P.pxv * h;
P.pyv += (kP * (P.ty - P.py) - cP * P.pyv) * h; P.py += P.pyv * h;
P.shakev += (-900 * P.shake - 16 * P.shakev) * h; P.shake += P.shakev * h;
}
}
The DOM card takes the matrix as a CSS matrix3d. In stage 4 the WebGL glass reads the very same matrix,
which is why the engraving and the glass line up to the pixel.
function compose(tMs) {
const t = tMs / 1000;
const idle = reduced ? 0 : 1;
const rx = (L.base[0] + idle * Math.sin(t * 0.41) * 1.7 - P.py * 7) * D2R + P.dy;
const ry = (L.base[1] + idle * Math.sin(t * 0.29 + 1.3) * 2.6 + P.px * 9) * D2R + P.f;
const rz = (L.base[2] + idle * Math.sin(t * 0.23 + 0.4) * 0.7) * D2R;
const R = mat(rx, ry, rz);
const flipLift = Math.abs(Math.sin(P.f)) * L.cw * 0.12;
const T = [P.shake, idle * Math.sin(t * 0.62) * L.cw * 0.008, flipLift];
// DOM engraving layer: the very same transform under the stage's CSS perspective
card.style.transform = `matrix3d(${R[0]},${R[3]},${R[6]},0,${R[1]},${R[4]},${R[7]},0,${R[2]},${R[5]},${R[8]},0,${T[0]},${T[1]},${T[2]},1)`;
// which face shows (for the label and the engraving layers' own visibility)
const facing = R[8] > 0;
if (facing !== compose.facing) { compose.facing = facing; card.classList.toggle('show-back', !facing); }
}
Focusing the CVV turns the card over, and leaving it turns it back. It always turns the same way, so front, back, front is one full turn.
function setFlipped(on, { announce = false } = {}) {
if (on === flipped) return;
flipped = on;
P.fT += Math.PI; // always turn the same way: front → back → front is one full turn
if (reduced) { P.f = P.fT; P.fv = 0; }
card.setAttribute('aria-pressed', String(on));
cardLabel.textContent = on ? 'Card preview, back with the security code. Activate to flip to the front.' : 'Card preview, front. Activate to flip to the back.';
if (announce) say(on ? 'Showing the back of the card' : 'Showing the front of the card');
request();
}
F[k].addEventListener('focus', () => {
if (k === 'cvv') { card.classList.add('cvv-focus'); setFlipped(true); }
else if (flipped) setFlipped(false);
});
A drag turns the card with the pointer. When you let go, it lands on whichever face is nearer, carried a little by the speed of the throw.
const endDrag = (e) => {
if (!drag || (e && e.pointerId !== drag.id)) return;
const d = drag; drag = null; card.classList.remove('is-dragging');
if (d.moved) {
suppressClick = true; setTimeout(() => { suppressClick = false; }, 0);
const proj = P.f + clamp(d.v, -14, 14) * 0.16;
P.fT = Math.round(proj / Math.PI) * Math.PI; P.dyT = 0;
const back = Math.round(P.fT / Math.PI) % 2 !== 0;
if (back !== flipped) { flipped = back; card.setAttribute('aria-pressed', String(back)); }
}
request();
};

4 · The glass, in WebGL
First the page behind it: one pastel field painted once into a 2D canvas, from the colours measured on the design. The same canvas is the page background and the texture the glass refracts, so the glass bends exactly what sits behind it.
blob(W * 0.06, H * 0.98, D * 0.48, C.coral, 0.85); // coral, bottom-left
blob(W * 0.10, H * 0.45, D * 0.30, C.apricot, 0.55); // apricot, left
blob(W * 0.48, H * 0.62, D * 0.30, C.roseLilac, 0.55); // rose-lilac, centre
blob(W * 0.98, H * 0.05, D * 0.42, C.lavender, 0.55); // lavender, top-right
blob(W * 0.95, H * 0.98, D * 0.45, C.periwinkle, 0.55); // periwinkle, bottom-right
blob(W * 0.32, H * 0.10, D * 0.22, [255, 232, 222], 0.6); // a pale haze top-left
The shader ray-marches a rounded slab. A ray enters the front face, crosses the glass and leaves through the far face or the rim, and it leaves three times: red, green and blue each with their own index of refraction. That small difference is what splits the edges into a rainbow.
// through the glass: refract in, march to the far surface, refract out with three IORs
vec3 r1 = refract(ld, nl, 1. / 1.5);
// exit: the far face analytically when it lands on the flat part, else march out through the rounded rim
vec3 q; vec3 nx;
float zb = -side * uB.z;
float tb = (zb - hp.z) / (abs(r1.z) > 1e-4 ? r1.z : -side * 1e-4);
vec3 qp = hp + r1 * max(tb, 0.);
vec2 qq = abs(qp.xy) - (uB.xy - vec2(uRc));
float d2f = length(max(qq, 0.)) + min(max(qq.x, qq.y), 0.) - uRc;
if (tb > 0. && d2f < -uB.z * 0.95) { q = qp; nx = vec3(0., 0., -side); }
else {
q = hp - nl * 0.05; float trav = 0.;
for (int i = 0; i < 14; i++) { float d = -sdCard(q); if (d < 0.06 && trav > 1.0) break; float st = max(d, 0.8); q += r1 * st; trav += st; }
nx = nrm(q);
}
vec3 cT = vec3(0.); vec3 cF = vec3(0.); vec3 bmC = vec3(0.);
float tir = 0.;
vec3 qe = uR * q + uT;
for (int k = 0; k < 3; k++) {
float ior = k == 0 ? 1.462 : (k == 1 ? 1.5 : 1.545);
vec3 r2 = refract(r1, -nx, ior);
if (dot(r2, r2) < 1e-4) { tir += 1. / 3.; r2 = reflect(r1, -nx); }
vec3 w = uR * r2;
float sb = (-uD - qe.z) / min(w.z, -0.05);
vec3 b = qe + w * sb;
vec2 sc = b.xy * uP / (uP + uD);
vec2 uv = sc * uBgXf.xy + uBgXf.zw;
vec3 m = texture(uBg, uv).rgb;
if (k == 0) cT.r = m.r; else if (k == 1) { cT.g = m.g; cF = texture(uBg, uv, 3.2).rgb; bmC = beamCol(sc) * 0.12; } else cT.b = m.b;
}
cT = mix(cT, cF, 0.38) + bmC;
The rim gets a thin-film rainbow too, brightest where the beam crosses the edge.
// rim: thin-film iridescence + dispersion fringes, hottest where the beam meets the edge
float hue = ci * 1.35 + rim * 0.55 + dot(hp.xy, vec2(0.0016, 0.0011)) + uTime * 0.015;
vec3 film = clamp((rainbow(hue) - 0.5) * 1.5 + 0.55, 0., 1.);
float rimW = smoothstep(0.2, 0.95, rim);
body = mix(body, body * 0.45 + film * 0.7 + 0.1, rimW * 0.85);
body += film * rimW * (bA * 1.1 + 0.12);
The JavaScript hands the shader one scene a frame: the matrix, the beam, the two flares on the corners nearest to it, the network tint and the processing scan.
Object.assign(scene, {
P: L.P, R, T, B: [hw, hh, L.th / 2], rc: L.rc, D: L.D, bgXf: L.bgXf, beam, beamI: L.phone ? 0.85 : 1,
sweep: 0.12 + idle * Math.sin(t * 0.33) * 0.3 + P.px * 0.12, tint: P.tint,
fl0: fl(pair[0], 1), fl1: fl(pair[1], 0.72),
err: P.err * 0.55, focus: P.focus, time: idle * t,
});
Two canvases share the work. The soft light around the card (the beam, the flares, the shadow) is drawn at 1×; the card itself at up to 2×, only inside its own projected box.
export class GlassRenderer {
constructor(cardCanvas, ambCanvas) {
this.amb = new Layer(ambCanvas, 0, 1);
this.card = this.amb.failed ? { failed: true } : new Layer(cardCanvas, 1, 2);
this.failed = !!(this.amb.failed || this.card.failed);
}
resize(w, h, dpr) { this.amb.resize(w, h, dpr); this.card.resize(w, h, dpr); }
setBackground(src) { this.amb.setBackground(src); this.card.setBackground(src); }
render(s, cardBox, amb = true) { if (amb) this.amb.render(s, null); this.card.render(s, cardBox); }
}

5 · Laser engraving, and the finish
Each typed character now drops into the glass: it starts a little large and above its slot, and lands.
const ENGRAVE = [
{ opacity: 0, transform: 'translateY(-.14em) scale(1.35)' },
{ opacity: 1, transform: 'translateY(0) scale(1)', offset: 0.32 },
{ opacity: 1, transform: 'translateY(0) scale(1)' },
];
At the same moment a glint flashes on it, and the glass gets a hot spot of its own at the same point, so the light is in the glass, not on top of it. Placing the glint needs layout, so it waits for the next frame instead of forcing one on every keystroke.
function placeGlint(el, side) {
const face = side > 0 ? frontFace : backFace, g = face.querySelector('.glint');
let x = el.offsetWidth / 2, y = el.offsetHeight / 2;
for (let n = el; n && n !== face; n = n.offsetParent) { x += n.offsetLeft; y += n.offsetTop; }
const at = `translate(${x}px, ${y}px) translate(-50%, -50%)`;
g.animate([{ opacity: 1, transform: at + ' scale(.35)' }, { opacity: 0.85, transform: at + ' scale(1)', offset: 0.25 }, { opacity: 0, transform: at + ' scale(1.5)' }],
{ duration: 640, easing: 'cubic-bezier(.2,.8,.2,1)' });
P.sparkXY = side > 0 ? [x / L.k - L.cw / 2, y / L.k - L.ch / 2, 1] : [-(x / L.k - L.cw / 2), y / L.k - L.ch / 2, -1];
P.spark = 1; request();
}
// engraving laser hot spot
if (uSpark.w > 0.001) {
float dd = length(hp.xy - uSpark.xy);
float fs = step(0., side * uSpark.z);
col += (exp(-dd * dd / 70.) * 1.2 + exp(-dd / 26.) * 0.25) * uSpark.w * fs * vec3(1.0, 0.92, 0.96);
}
Last, the frame budget. At rest only the slow idle drift moves, so the page draws 60 times a second instead of 120, and the soft ambient light is redrawn on every other frame.
// at rest only the idle drift moves (slowly): 60 updates a second are plenty, which halves the GPU work while idle
const busyNow = !atRest() || P.spark > 0.01;
frame.k = (frame.k || 0) + 1;
if (busyNow || frame.k % 2 === 0 || reduced || frame.firstResolve) compose(tMs);
// the ambient light is soft and slow: it is redrawn on every other frame (the card pass every frame it is drawn)
compose.n = (compose.n || 0) + 1;
gl.render(scene, [x0 + L.sw / 2 - 3, y0 + L.sh / 2 - 3, x1 + L.sw / 2 + 3, y1 + L.sh / 2 + 3], compose.n % 2 === 1 || compose.force);
compose.force = false;


Make it yours
- Your networks are the
NETWORKSlist incards.js(prefix, lengths, groups, CVV length, tint) and their marks inmarks.js. - Your page colours are the blobs in
backdrop.js; the glass refracts whatever you paint there. - Accessibility is already there: labelled fields with the right
autocompleteandinputmode, errors tied to their fields witharia-describedby, a live region for the card type and the result, a card you can flip with the keyboard, 44 px targets,prefers-reduced-motionhonoured, and a CSS frosted card without WebGL 2. - Nothing is stored or sent. This is a demo: wire the form to your payment provider's own fields before it handles a real card.
The code is on GitHub at UlviShabanbayli/domvybe-glass-card, under the MIT licence: use it in your own projects, commercial ones included. Aether Finance and its card networks are made up; everything on the page was made in code for this project.
Drive the DOM and the shader from one matrix. The text stays real, sharp DOM, the glass is pure WebGL, and because both read the same rotation and the same perspective, the engraving sits exactly in the glass on every frame.