Library / Tutorial

Bronze OTP,
step by step.

A verification-code input where every digit is poured in molten bronze, crusts over with glowing cracks and cools to polished metal; the right code runs a golden light across the castings. One real input does the typing. Three stages, each a live page you can run.

3Stages
2Canvases
0Libraries

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.

index.html — the plate, the moulds and the one 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.

bronze.css — the tile width
--tw: max(min(11vw, calc((100svh - 470px) / 2.08), 270px), min(9vw, 90px));
--th: calc(var(--tw) * 1.36);
bronze.css — the portrait grid
--tw: max(min(calc((100vw - 24px) / 3.56), calc((100svh - 470px) / 3.32), 220px), min(22vw, 80px));
bronze.css — the real input, invisible over the moulds
#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.

bronze.css — the focus rim
.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.

bronze.css — the CSS bronze
.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)); }
Stage 1 · HTML and CSSOpen ↗
The Bellmont page in plain HTML and CSS: the headline Enter the code, cast in bronze. above a dark cast-iron plate with six moulds; the first three hold bronze numerals 4 8 2, the fourth a glowing orange 9, and the fifth has a gold focus rim.

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.

app.js — overwrite, never insert
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.

app.js — value to castings
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.

app.js — one mould at time t
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.

atlas.js — the numeral atlas
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.

foundry.js — two canvases
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 });
app.js — the SVG clip follows the digits
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.

shaders.js — the ribbon
// 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.

shaders.js — crust with glowing cracks
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);
shaders.js — temper colours
// 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.

shaders.js — the hot numeral
// 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.

app.js — when to draw
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; }
Stage 2 · cast in bronzeOpen ↗
The plate in WebGL: 4 polished bronze, 8 and 2 in temper colours, a 9 glowing orange under a crust of hot cracks, a fresh 1 and a ribbon of molten bronze pouring into the sixth mould with the level half up.

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.

app.js — the check
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);
}
shaders.js — the golden sweep on the numerals
// 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));
bronze.css — the shake
.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.

app.js — the lock
function lock(until) {
  S.lockUntil = until; store.set(KEY_LOCK, String(until));
  S.phase = 'locked'; tickClock(); syncDom(); wake();
}
app.js — once a second: expiry and cooldowns
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.

app.js — context lost and restored
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) {}
};
Stage 3 · the finished inputOpen ↗
The finished input after the right code: six polished bronze numerals 4 8 2 9 1 3 with a golden light sweeping across them, a green Verified button and the line Verified. Welcome back, Ana.
Stage 3 · on a phoneOpen ↗
The input on a phone: the headline on two lines over a 2 by 3 grid of big moulds; 4 8 2 in polished bronze and temper colours, a 9 under a glowing crust, a fresh 1 and a ribbon of molten bronze pouring into the last mould, with the Verify button full width below.

Make it yours

  • Your check goes where the demo compares with 482913: a fetch to your server, then the same success or error branch.
  • Your metal is a handful of numbers: POUR, DRAIN and SETTLE in app.js, the cooling curve in look(), the film's phases in film().
  • 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-motion honoured, 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.

The one idea worth keeping

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.