This is the whole build of the bronze OTP, in the order it was written: the two-step verification page of a made-up private bank, Bellmont. Type a digit and molten bronze pours from the gate into its mould, the level rises with a glowing meniscus, a crust with hot cracks forms over it, and over thirteen seconds it cools through dark red and temper colours to polished metal. 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 and no build step: one HTML page, one style sheet and five ES modules. The bronze is two WebGL
canvases. The input is one real <input autocomplete="one-time-code"> lying over the moulds, so SMS
autofill, paste, password managers and screen readers work exactly as they do on any other code field.
1 · The page, in HTML and CSS
Start with the plate: six moulds in two groups of three, each with its engraved number, and over them all the one real input, transparent, 16 px so iOS never zooms. The moulds are only pictures of the input.
<div class="plate" id="plate">
<span class="eng eng-l" aria-hidden="true">Bellmont Foundry</span>
<span class="eng eng-r" aria-hidden="true">Nº 0482 · Bronze C93200</span>
<div class="tiles" id="tiles">
<div class="grp">
<div class="slot filled"><b>4</b><i class="num" aria-hidden="true">01</i></div>
<div class="slot filled"><b>8</b><i class="num" aria-hidden="true">02</i></div>
<div class="slot filled"><b>2</b><i class="num" aria-hidden="true">03</i></div>
</div>
<i class="dash" aria-hidden="true"></i>
<div class="grp">
<div class="slot filled hot"><b>9</b><i class="num" aria-hidden="true">04</i></div>
<div class="slot is-active"><b></b><i class="num" aria-hidden="true">05</i></div>
<div class="slot"><b></b><i class="num" aria-hidden="true">06</i></div>
</div>
<label class="sr-only" for="code" id="code-label">Verification code, 6 digits</label>
<input id="code" name="code" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" pattern="[0-9]*" value="4829"
spellcheck="false" autocapitalize="off" aria-describedby="status" data-fit>
One custom property, the tile width --tw, drives the whole stage: the moulds, the gaps, the plate's
padding and radius, the bezels. On a landscape screen it is 11 % of the width, so the bronze dominates any desktop. On a
portrait phone the six moulds become a 2 × 3 grid of big tiles.
--tw: max(min(11vw, calc((100svh - 470px) / 2.08), 270px), min(9vw, 90px));
--th: calc(var(--tw) * 1.36);
--tw: max(min(calc((100vw - 24px) / 3.56), calc((100svh - 470px) / 3.32), 220px), min(22vw, 80px));
#code { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 3; margin: 0; padding: 0; border: 0; border-radius: var(--br); background: transparent; color: transparent; caret-color: transparent;
font: 16px/1 'Inter'; letter-spacing: 0; text-indent: -9999px; opacity: 0; cursor: text; -webkit-appearance: none; appearance: none; }
The next mould glows: a gold rim that pulses on the compositor, so it never wakes the canvas.
.slot::after { content: ''; position: absolute; inset: calc(var(--tw) * -.012); border-radius: calc(var(--br) * 1.08); pointer-events: none; z-index: 2;
border: 2px solid rgba(255, 196, 120, .95); box-shadow: 0 0 0 1px rgba(255, 150, 70, .35), 0 0 calc(var(--tw) * .12) rgba(255, 150, 60, .55), inset 0 0 calc(var(--tw) * .1) rgba(255, 150, 60, .35);
opacity: 0; transition: opacity .2s; }
.otp.focused .slot.is-active::after { opacity: 1; animation: rim 1.8s ease-in-out infinite; }
@keyframes rim { 0%, 100% { opacity: 1; } 50% { opacity: .62; } }
For now the bronze is CSS: gradients for the faces, a clipped gradient for the numerals and a hot radial glow for a fresh casting. The finished page keeps exactly this as its fallback, for a browser without WebGL or a lost GPU context.
.no-fx .slot.hot { background: radial-gradient(90% 80% at 50% 50%, #ffcf6a, #ff8a2a 45%, #a33d0c); box-shadow: 0 0 0 calc(var(--tw) * .04) #f0b060, 0 0 40px rgba(255, 130, 40, .6); }
.no-fx .slot b { visibility: visible; color: #e9c48b; background: linear-gradient(180deg, #fff1cf, #e2b06c 45%, #8e5a2a); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
filter: drop-shadow(2px 3px 0 rgba(30, 16, 6, .8)); }

2 · Cast in bronze
The field behaves like a row of boxes without being one: a digit typed in the middle overwrites instead of inserting, letters are refused, and a click on a mould selects its digit.
inp.addEventListener('keydown', (e) => {
if (/^\d$/.test(e.key) && !e.metaKey && !e.ctrlKey) {
const a = inp.selectionStart, b = inp.selectionEnd;
if (a === b && a < inp.value.length) inp.setSelectionRange(a, a + 1); // overwrite, never insert
} else if (e.key.length === 1 && !/\d/.test(e.key) && !e.metaKey && !e.ctrlKey && !e.altKey) e.preventDefault();
requestAnimationFrame(setActiveFromCaret);
});
Every change of the value is compared with the moulds: a new digit pours, a changed one re-melts and recasts, a deleted one drains. A paste or an SMS autofill pours all six one after another, a tenth of a second apart.
function applyValue(src = 'type') {
const clean = el.input.value.replace(/\D/g, '').slice(0, 6);
if (clean !== el.input.value) { const pos = el.input.selectionStart; el.input.value = clean; try { el.input.setSelectionRange(Math.min(pos, clean.length), Math.min(pos, clean.length)); } catch (e) {} }
const nd = clean.split('').map(Number), old = S.digits, t = clock();
let born = 0;
const many = nd.filter((v, i) => old[i] === undefined).length > 1;
for (let i = 0; i < 6; i++) {
if (nd[i] === old[i]) continue;
if (nd[i] === undefined) drain(i, t);
else if (old[i] === undefined) { pour(i, nd[i], t, many ? born * 0.1 : 0); born++; }
else remelt(i, nd[i], t);
}
S.digits = nd;
S.lastInput = t; syncClip(); syncDom(); wake();
}
Each mould is a tiny model in time: the level, the temperature, the temper film, the numeral's visibility and the ribbon's head and tail. The temperature holds for 0.8 s and then falls exponentially; the film creeps in from 2.4 s. Nothing is stored per frame: every look is a function of the clock.
function look(i, t) {
const sl = S.slots[i];
let L = 0, T = 0, tn = 0, vis = 1, head = 0, tail = 1, d = sl.d;
const age = t - sl.castAt;
const cool = () => {
T = age < 0.8 ? 1 : Math.exp(-(age - 0.8) / 2.1); if (T < 0.004) T = 0;
tn = clamp01((age - 2.4) / 10.1);
};
if (sl.mode === 'empty') { L = 0; d = -1; }
else if (sl.mode === 'pour') {
const k = t - sl.t0;
head = clamp01(k / 0.16); L = smooth(0.0, 1.0, clamp01((k - 0.08) / 0.5)); tail = clamp01((k - 0.44) / 0.2);
T = 1; if (tail >= 1) head = 0;
} else if (sl.mode === 'solid') { L = 1; cool(); }
else if (sl.mode === 'remelt') {
const k = t - sl.t0; L = 1;
cool(); T = Math.max(T, smooth(0, 0.14, k)); if (k < 0.14) tn *= 1 - smooth(0, 0.14, k);
vis = k < 0.24 ? 1 - smooth(0.0, 0.22, k) : smooth(0.3, 0.62, k);
if (k >= 0.24) d = sl.next;
} else if (sl.mode === 'drain') {
const k = t - sl.t0;
T = Math.max(sl.mode0T || 0, 0.92 * smooth(0, 0.14, k));
vis = 1 - smooth(0.02, 0.26, k);
L = 1 - smooth(0.0, 1.0, clamp01((k - 0.12) / 0.42));
}
return { L, T, tn, vis, head, tail, flash: 0, d };
}
The numerals come from a font atlas baked once: Playfair Display 900 drawn into a canvas, then blurred into a body, a chamfer and a shadow, so the shader can raise a polished, chamfered numeral out of a flat texture.
export function buildAtlas() {
const W = CELL_W * 10, H = CELL_H;
const c = document.createElement('canvas'); c.width = W; c.height = H;
const g = c.getContext('2d', { willReadFrequently: true });
g.fillStyle = '#000'; g.fillRect(0, 0, W, H);
g.fillStyle = '#fff'; g.textAlign = 'center'; g.textBaseline = 'alphabetic';
g.font = `900 ${AF}px ${FAMILY}`;
for (let d = 0; d < 10; d++) g.fillText(String(d), d * CELL_W + CELL_W / 2, BASE_ROW);
Two canvases do the work. The plate canvas draws iron, bezels, sand, the cast faces and the pour at up to 1.5×. The
metal canvas draws only the numerals at up to 2×, and it is clipped by an SVG <text> of the same
digits, so every edge of a numeral stays vector-sharp at any zoom.
this.fx = makeGL(this.fxCanvas, { alpha: true, dprMax: 1.5, maxPixels: 2.4e6 });
this.mt = makeGL(this.metalCanvas, { alpha: true, dprMax: 2, maxPixels: 2.0e6 });
function syncClip() {
const g = S.geo; if (!g || !clipText.length) return;
clipText.forEach((n, i) => {
const sl = S.slots[i], d = sl.mode === 'remelt' && clock() - sl.t0 >= 0.24 ? sl.next : sl.d;
const v = d >= 0 ? String(d) : '';
if (n.textContent !== v) n.textContent = v;
n.setAttribute('display', v ? 'inline' : 'none');
});
}
The pour is one continuous ribbon from the gate to the rising surface. It never breaks into drops: its width tapers and wobbles, its head runs ahead and its tail lets go at the end.
// the pour: one continuous ribbon of molten bronze from the gate down to the rising surface (never drops)
float ribbon(vec2 q, vec4 S, vec4 sx, float ys, float t){
float head=sx.y, tail=sx.z;
if(head<=0.0 || tail>=1.0) return 0.0;
float top=-S.w*0.5;
float bot=mix(top, ys, head);
float y0=mix(top, ys, tail*tail);
if(q.y<y0 || q.y>bot+1.0) return 0.0;
float k=clamp((q.y-top)/S.w,0.0,1.0);
float xc=S.z*0.010*sin(q.y*0.045+t*6.0);
float wr=S.z*mix(0.072,0.050,k)*(1.0+0.10*sin(q.y*0.11-t*19.0));
wr*=(tail>0.0?smoothstep(y0, y0+S.w*0.10, q.y):1.0)*(1.0-0.5*tail)+0.0001;
As it cools, a crust forms: two layers of warped noise give a network of cracks that stay hot while the plates between them darken. Then the temper film, a thin-film interference colour, travels from straw through bronze and purple to blue before the patina settles.
vec3 hotFace(vec2 q, float T, float tw, float t, float seed, out float crackOut){
vec2 uv=q/(tw*2.4)+seed*0.37;
vec2 wv=vec2(texture(uNoise, uv*0.8+vec2(0.0,-t*0.01)).g, texture(uNoise, uv*0.8+vec2(0.5,t*0.008)).b)-0.5;
float n1=texture(uNoise, uv*0.55+wv*0.18+vec2(0.0,-t*0.012)).g;
float n2=texture(uNoise, uv*1.25+wv*0.25+vec2(t*0.009,0.02)).b;
float w=n1*0.62+n2*0.38;
float cw=mix(0.010,0.04,smoothstep(0.3,1.0,T));
float rd=abs(w-0.5);
float c1=1.0-smoothstep(0.0,cw,rd);
float r2=abs(texture(uNoise, uv*2.6+wv*0.3+vec2(0.31,0.77)).b-0.5);
float c2=1.0-smoothstep(0.0,cw*0.6,r2);
float crack=max(c1,c2*0.7);
float halo=max(1.0-smoothstep(0.0,cw*4.5,rd), (1.0-smoothstep(0.0,cw*3.0,r2))*0.6);
// thin-film temper colours (straw → bronze → purple → blue), phase in 0..1
vec3 film(float ph){ return 0.5+0.5*cos(6.2832*(ph*vec3(1.0,0.86,0.72)+vec3(0.0,0.24,0.5))); }
The numeral stays hotter than the face around it, so for a moment it is a glowing figure in the crust.
// heat: the numeral stays hotter than the face (a glowing figure in the crust), core hottest
if(T>0.003){
float Td=pow(T,0.78)*mix(0.86,1.0,core);
vec3 em=blackbody(Td*0.98)*(0.04+1.55*pow(Td,2.8));
col=col*(1.0-0.95*smoothstep(0.08,0.5,T))+em;
}
The loop runs at full rate only while metal moves, at 60 fps while a casting glows, at 30 fps while the film forms, and then it sleeps: no draws at all once everything has cooled.
const full = moving(t) || ptrLive || P.str > 0.004;
const mid = !full && hot(t);
const slow = !full && !mid && (cooling(t) || (t - S.idleFrom < 14.2 && !reduced.matches));
if (!full && !mid && !slow && S.holdT === null) { draw(t); S.rate = 'sleep'; S.sleeping = true; return; }

3 · The finish
Now the code is checked. Verify, or Enter, sends it; the demo waits 900 ms. The right code runs a golden light across every casting and says welcome back. A wrong one shakes the plate, flashes the metal dull red and drains the moulds right to left, and says how many attempts are left.
function submit(e) {
e && e.preventDefault();
if (S.phase !== 'entry' && S.phase !== 'error') return;
if (S.digits.length < 6) { setAlert(`Enter all six digits. ${6 - S.digits.length} to go.`); return; }
setAlert(''); setPhase('busy'); setStatus('Checking your code…');
setTimeout(() => {
const t = clock();
if (S.digits.join('') === DEMO) {
S.phase = 'ok'; S.okAt = t; S.attempts = 0; store.del(KEY_ATT);
syncDom();
el.status.className = 'status ok'; el.status.dataset.text = '';
el.status.innerHTML = 'Verified. Welcome back, Ana. <button type="button" id="again">Start over</button>';
$('#again').addEventListener('click', startOver);
el.verify.focus();
} else {
S.attempts++; store.set(KEY_ATT, String(S.attempts));
S.errAt = t;
el.plate.classList.remove('shake'); void el.plate.offsetWidth; if (!reduced.matches) el.plate.classList.add('shake');
S.slots.forEach((sl) => { sl.errAt = t; });
for (let i = 5; i >= 0; i--) drain(i, t, reduced.matches ? 0 : 0.55 + (5 - i) * 0.07);
S.digits = []; el.input.value = '';
if (S.attempts >= MAX_ATT) { lock(Date.now() + LOCK_S * 1000); }
else {
S.phase = 'error';
const left = MAX_ATT - S.attempts;
setAlert(`That code doesn't match. ${left} attempt${left > 1 ? 's' : ''} left.`);
syncDom();
el.input.disabled = false; el.input.focus();
try { el.input.setSelectionRange(0, 0); } catch (e2) {}
}
}
syncClip(); wake();
}, FAST ? 60 : 900);
}
// success sweep
if(uFx.y>=0.0){
float sw=(p.x+p.y*0.35-uPlate.x)/(uPlate.z+uPlate.w*0.35);
float band=exp(-pow((sw-uFx.y*1.5+0.25)/0.06,2.0));
col+=vec3(1.0,0.82,0.52)*band*(0.8+1.6*pow(max(dot(n,normalize(vec3(-0.3,-0.4,1.0))),0.0),8.0));
.plate.shake { animation: shake .5s cubic-bezier(.36, .07, .19, .97); }
@keyframes shake { 10%, 90% { transform: translateX(-2px); } 20%, 80% { transform: translateX(5px); } 30%, 50%, 70% { transform: translateX(-9px); } 40%, 60% { transform: translateX(9px); } }
Three wrong codes lock the plate for 30 seconds: the iron goes cold and grey, and the lock is stored, so a reload does not reset it. The code expires after a minute, and Resend has its own stored cooldown.
function lock(until) {
S.lockUntil = until; store.set(KEY_LOCK, String(until));
S.phase = 'locked'; tickClock(); syncDom(); wake();
}
function tickClock() {
const now = Date.now();
const left = Math.max(0, Math.ceil((S.expiresAt - now) / 1000));
if (S.phase !== 'ok') {
el.tm.textContent = fmt(left);
el.timer.classList.toggle('low', left <= 10 && left > 0);
el.timer.classList.toggle('out', left === 0);
el.timerL.textContent = left === 0 ? 'Code expired' : 'Code expires in';
}
if (left === 0 && (S.phase === 'entry' || S.phase === 'error')) {
S.phase = 'expired'; setAlert('This code has expired. Send a new one to continue.'); syncDom();
}
If the GPU context is lost, the page switches to the CSS bronze from stage 1 and comes back when the context returns. With reduced motion every digit is cast at once and nothing moves.
foundry.fx.onlost = foundry.mt.onlost = () => { document.documentElement.classList.add('no-fx'); cancelAnimationFrame(raf); raf = 0; };
foundry.fx.onrestored = foundry.mt.onrestored = () => {
if (foundry.fx.lost || foundry.mt.lost) return;
try { foundry.setup(); document.documentElement.classList.remove('no-fx'); layout(); } catch (e) {}
};


Make it yours
- Your check goes where the demo compares with
482913: afetchto your server, then the same success or error branch. - Your metal is a handful of numbers:
POUR,DRAINandSETTLEin app.js, the cooling curve inlook(), the film's phases infilm(). - Accessibility is already there: one real labelled input with
autocomplete="one-time-code"and a numeric keyboard, status and errors announced, visible focus, 44 px targets,prefers-reduced-motionhonoured, the loop asleep at rest, and a CSS bronze without WebGL.
Bellmont is a made-up bank. Everything on the page was made in code for this project: the iron, the bronze, the pour and the light are shaders.
Leave the input alone. One real field does the typing, the paste, the autofill and the screen reader; the moulds are only a picture of its value, redrawn from a model that is a pure function of time.