Library / Tutorial

Hourglass OTP,
step by step.

A one-time-code step whose resend timer is a glass hourglass: about 8,300 simulated sand grains drain through the neck, metered so the top bulb empties when the code expires, and turning the hourglass over is the resend. Three stages, each a live page you can run.

3Stages
8,300Grains
0Libraries

This is the whole build of the hourglass OTP, in the order it was written: the sign-in step of a made-up brand, Sable. The resend timer is a glass hourglass with real simulated sand: it drains through the neck and piles up below, and the top bulb empties exactly when the code expires. Then you turn the hourglass over, and that is the resend. 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 four ES modules. The studio, the glass, the walnut, the brass and every grain are one fragment shader on one canvas; every word and control is HTML on top.

1 · The page, in HTML and CSS

Start with the form: six real inputs in a fieldset with a legend. The first one carries autocomplete="one-time-code", so a phone can offer the code from the text message; all six ask for the numeric keyboard.

index.html — the six boxes
<fieldset class="otp" id="otp" aria-describedby="sub">
  <legend class="sr">One-time code, 6 digits</legend>
  <input id="d1" class="dg filled" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" maxlength="6" aria-label="Digit 1 of 6" value="4" data-fit>
  <input class="dg filled" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="off" maxlength="1" aria-label="Digit 2 of 6" value="8" data-fit>
  <input class="dg filled" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="off" maxlength="1" aria-label="Digit 3 of 6" value="2" data-fit>
  <input class="dg filled" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="off" maxlength="1" aria-label="Digit 4 of 6" value="9" data-fit>
  <input class="dg" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="off" maxlength="1" aria-label="Digit 5 of 6" data-fit autofocus>
  <input class="dg" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="off" maxlength="1" aria-label="Digit 6 of 6" data-fit>
</fieldset>
hourglass.css — a code box
.dg { width: max(48px, calc(68 * var(--u))); height: max(58px, calc(82 * var(--u))); flex: 0 0 auto; border: 0; border-radius: max(12px, calc(16 * var(--u))); padding: 0; text-align: center;
  font: 500 max(26px, calc(34 * var(--u)))/1 'Fraunces', serif; color: var(--ink); background: var(--slot); caret-color: #b07a3a;
  box-shadow: 0 0 0 1px rgba(120, 95, 60, .18) inset, 0 1px 0 rgba(255, 255, 255, .9) inset, 0 6px 18px rgba(90, 60, 30, .06);
  outline: 0; transition: box-shadow .2s, transform .18s cubic-bezier(.2, .8, .2, 1), background .2s, color .2s; -webkit-appearance: none; appearance: none; }
hourglass.css — the focused box lifts
.dg:focus { box-shadow: 0 0 0 2px var(--ring) inset, 0 0 0 6px rgba(195, 146, 88, .2), 0 10px 26px rgba(150, 100, 40, .14); background: #fffdf8; transform: translateY(-2px); }

On a portrait screen the hourglass goes first and the form stacks under it.

hourglass.css — stacked on a phone
.stage { order: -1; }
.stage { position: relative; left: auto; right: auto; top: auto; width: 100%; height: clamp(300px, 49svh, 600px); min-height: 0; }

For now the hourglass is CSS: walnut caps, brass posts and a frosted glass cut by a clip-path. One custom property, --p, the share of the minute that has passed, sets both sand levels. The finished page keeps exactly this as its fallback, for a browser without WebGL.

hourglass.css — the frosted hourglass
.no-fx .fb { display: block; position: absolute; left: var(--fx, 50%); top: var(--fy, 50%); width: calc(var(--fs, 300px) * 1.12); height: calc(var(--fs, 300px) * 1.96);
  transform: translate(-50%, -50%) rotate(var(--fr, 0deg)); transition: transform .9s cubic-bezier(.3, 1.3, .4, 1); }
hourglass.css — the sand levels follow --p
.fb-sand.top { bottom: 51%; height: calc((1 - var(--p, .6)) * 34%); clip-path: polygon(0 0, 100% 0, 100% 100%, 54% 100%, 50% 80%, 46% 100%, 0 100%); }
.fb-sand.bot { bottom: 0; height: calc(var(--p, .6) * 36%); clip-path: polygon(0 30%, 50% 0, 100% 30%, 100% 100%, 0 100%); }
.fb-stream { position: absolute; left: 49.4%; width: 1.2%; top: 49%; bottom: calc(var(--p, .6) * 36% - 2%); background: #c99a5c; }
Stage 1 · HTML and CSSOpen ↗
The Sable sign-in page in plain HTML and CSS: the headline Check your phone. above six code boxes holding 4 8 2 9 with the fifth focused, a disabled Verify button and Resend code in 0:23; on the right a frosted CSS hourglass with walnut caps and brass posts, its sand partly run down.

2 · Real sand

The sand is a grid of 140 × 336 cells in the hourglass's own frame, about 8,300 of them filled. Every grain is one cell and keeps its own tone as it moves, so the pile shows real speckle and streaks.

sand.js — the grid
constructor(GW = 140, GH = 336, fill = 0.58) {
  this.GW = GW; this.GH = GH; this.CS = (2 * GHALF) / GH;
  const n = GW * GH;
  this.c = new Uint8Array(n);         // 0 empty, 1 wall, 128..255 grain (its tone)

Each step, a grain tries the cell along gravity, then the two cells 45° either side. When gravity points straight down, a knight's slide (two across, one down) brings the angle of repose to about 27°, like real sand. That is what makes the crater in the top bulb and the cone below.

sand.js — one grain, one step
const m = this.rnd() < f ? kB : kA;
let t = k + off[m];
if (c[t] !== 0) {
  const s = this.rnd() < 0.5 ? 1 : 7;
  const m1 = (m + s) & 7, m2 = (m + 8 - s) & 7;
  t = k + off[m1];
  if (c[t] !== 0) {
    t = k + off[m2];
    if (c[t] !== 0) {
      t = -1;
      // knight's slide: only when gravity is (nearly) along an axis and the side cell is free
      if ((m & 1) === 0 && (f < 0.12 || f > 0.88) && this.rnd() < 0.5) {
        const side = (m + (s === 1 ? 2 : 6)) & 7;
        const a1 = k + off[side];
        if (c[a1] === 0) { const t2 = a1 + off[side] + off[m]; if (c[t2] === 0 && c[a1 + off[side]] === 0) t = t2; }
      }
      if (t < 0) continue;
    }
  }
}
c[t] = v; c[k] = 0; st[t] = sid; moves++;

The neck is a wall. Grains only pass it through drain(), which takes one from the crater's mouth and drops it on the top of the pile. The stream you see between them is drawn by the shader.

sand.js — through the neck
drain() {
  const { c, GW } = this;
  let k = -1;
  // the mouth: the lowest open rows over the neck, centre outwards
  for (let j = this.jDrain; j < this.jDrain + 3 && k < 0; j++) {
    for (let d = 0; d < 6 && k < 0; d++) {
      const a = j * GW + this.ic1 + d, b = j * GW + this.ic0 - d;
      if (c[a] >= 128) k = a; else if (c[b] >= 128) k = b;
    }
  }
  // nothing at the mouth yet: take the lowest grain left in the top bulb (keeps the timer exact)
  for (let j = this.jDrain; j < this.GH && k < 0; j++) {
    const o = j * GW; let best = -1, bd = 1e9;
    for (let i = 0; i < GW; i++) if (c[o + i] >= 128) { const dd = Math.abs(i - GW / 2); if (dd < bd) { bd = dd; best = o + i; } }
    k = best;
  }
  if (k < 0) return false;
  const v = c[k]; c[k] = 0;
  const jk = (k / GW) | 0; this.mark(jk);
  // land on the pile: top of the centre column
  const i = this.rnd() < 0.5 ? this.ic0 : this.ic1;
  let j = this.jUnder;
  if (c[j * GW + i] !== 0) { this.dirty = true; return true; }   // bottom full to the neck (cannot happen with the fill ratio)
  while (j > 0 && c[(j - 1) * GW + i] === 0) j--;
  c[j * GW + i] = v; this.mark(j); this.dirty = true;
  return true;
}

The timer meters the drain: by now, this many grains must have passed. So the top bulb is empty exactly when the code expires, whatever the frame rate.

app.js — sand metered to the clock
if (st.phase === 'running' && !tilted && !st.dragging) {
  const target = Math.floor(st.N0 * Math.min(1, elapsed() / DUR));
  let guard = 0;
  while (st.done < target && guard++ < 600) { if (!sand.drain()) { st.done = target; break; } st.done++; drained = true; }

Turning the hourglass is a damped spring, driven by a click, Enter, the arrow keys or a drag. While it turns, the grid feels the real gravity, so the sand slides along the bulb. Past 90° the grid is relabelled by 180°, which nothing on screen shows, so the metering frame stays upright. Tilts under 7° stay under the angle of repose, so a wobble moves nothing.

app.js — gravity in the grid frame
function gravityGrid() {
  // a few degrees of tilt (a wobble, an early tug) stays under sand's angle of repose: nothing slides
  const k = Math.round(st.ang / PI), dev = st.ang - k * PI;
  const a = Math.abs(dev) < 0.12 ? k * PI : st.ang;
  const s = Math.sin(a), c = Math.cos(a);
  let gx = -s, gy = -c;
  if (st.parity) { gx = -gx; gy = -gy; }
  if (gy > 0.2) { sand.rotate180(); st.parity ^= 1; gx = -gx; gy = -gy; }
  return [gx, gy];
}
app.js — the spring
function physics(dt) {
  if (!st.dragging && (st.vel !== 0 || st.ang !== st.target)) {
    const K = 46, C = 8.4, n = Math.max(1, Math.ceil(dt * 480)), h = dt / n;
    for (let i = 0; i < n; i++) { const a = K * (st.target - st.ang) - C * st.vel; st.vel += a * h; st.ang += st.vel * h; }
    if (Math.abs(st.target - st.ang) < 0.0015 && Math.abs(st.vel) < 0.02) { st.ang = st.target; st.vel = 0; settled(); }
  }
}
app.js — drag to turn
flip.addEventListener('pointermove', (e) => {
  if (!drag || e.pointerId !== drag.id) return;
  const dx = e.clientX - drag.x0;
  if (!drag.moved && Math.abs(dx) > 6) { drag.moved = true; st.dragging = true; stage.classList.add('dragging'); st.vel = 0; }
  if (!drag.moved) return;
  const prev = st.ang;
  if (st.phase === 'expired' && !RM.matches) st.ang = drag.a0 + Math.max(-1.15 * PI, Math.min(1.15 * PI, (dx / (1.5 * L.S)) * PI));
  else st.ang = drag.a0 + 0.12 * Math.tanh(dx / (0.9 * L.S));
  const t = performance.now(), dtm = Math.max(1, t - drag.lt);
  drag.v = 0.7 * drag.v + 0.3 * ((st.ang - prev) / (dtm / 1000)); drag.lt = t;
  st.target = st.ang; hot = t + 3000; wake();
});

In the shader each grain is drawn as the nearest jittered centre among its 3 × 3 neighbours: round grains with crevices between them, never a lattice.

scene.js — grains, not pixels
// grains as jittered round cells: the nearest occupied grain centre among the 3×3 neighbours
float best=9.0, second=9.0, bv=0.0; vec2 bo=vec2(0.0); ivec2 bc=ic0;
for(int dy=-1;dy<=1;dy++) for(int dx=-1;dx<=1;dx++){
  ivec2 c=clamp(ic0+ivec2(dx,dy), ivec2(0), gmax);
  float vv=texelFetch(uSand, c, 0).r*255.0;
  if(vv<127.5) continue;
  vec2 hc=vec2(c);
  vec2 ctr=hc+0.5+(vec2(hash12(hc), hash12(hc+7.31))-0.5)*0.62;
  vec2 dd=cf-ctr; float d2=dot(dd,dd);
  if(d2<best){ second=best; best=d2; bv=vv; bo=dd; bc=c; } else if(d2<second) second=d2;
}
float sandM=0.0; vec3 sc=vec3(0.0);

The canvas keeps its pixels, so a frame only re-shades what changed: the glass while sand flows, the shadow twice a second, everything while it turns. When the sand has run out, the loop sleeps.

app.js — draw only what changed
if (need.full || moving) { scene.draw(u, null); need.full = moving; need.glass = false; shadowT = t; sand.dirty = false; }
else if (sand.dirty || st.stream > 0 || need.glass) {
  const sh = sand.dirty && t - shadowT > 450;
  scene.draw(u, sh ? shadowRect() : glassRect()); if (sh) shadowT = t;
  sand.dirty = false; need.glass = false;
}
Stage 2 · real sandOpen ↗
The hourglass in WebGL halfway through a turn: tilted about 60 degrees, its walnut caps and brass posts catching the light, the sand sliding along the inside of the upper bulb; the six code boxes are empty and the line says the time is up.

3 · The finish

Now the code is checked. Verify, or Enter, sends it; the demo waits 650 ms. The right code turns the rings green, stops the sand and runs a light across the glass. A wrong one shakes the boxes, jolts the hourglass and says how many tries are left; three wrong ones lock the boxes until the sand runs out.

app.js — the check
function submit() {
  if (code().length !== 6 || st.busy || st.locked || st.phase === 'success') return;
  st.busy = true; verify.classList.add('busy'); vl.textContent = 'Checking'; verify.disabled = true;
  inputs.forEach((i) => { i.readOnly = true; });
  setTimeout(() => {
    st.busy = false; verify.classList.remove('busy'); vl.textContent = 'Verify';
    inputs.forEach((i) => { i.readOnly = false; });
    if (st.phase === 'expired') fail('That code has expired. Turn the hourglass for a new one.');
    else if (code() === st.code) success();
    else {
      st.tries++;
      const left = MAX_TRIES - st.tries;
      if (left <= 0) lock(); else fail(`That code doesn’t match. ${left} ${left === 1 ? 'try' : 'tries'} left.`);
    }
    updateVerify();
  }, 650);
}
scene.js — the light sweep on the glass
if(uSweep>-1.5){
  float b=q.x*0.55+q.y-uSweep;
  g+=vec3(1.0,0.92,0.75)*0.55*exp(-b*b/0.006)*(1.0-0.6*abs(nx));
}

The new code arrives as a simulated text message with a Fill code button, which types it in box by box.

app.js — Fill code
smsFill.addEventListener('click', () => {
  hideSms(); const c = st.code;
  inputs.forEach((i) => { i.value = ''; i.classList.remove('filled'); });
  if (RM.matches) { fillFrom(0, c); verify.focus(); return; }
  c.split('').forEach((ch, k) => setTimeout(() => { inputs[k].value = ch; inputs[k].classList.add('filled'); pop(inputs[k]); if (k === 5) { clearError(); updateVerify(); verify.focus(); requestAnimationFrame(() => { if (!verify.disabled) verify.focus(); }); } }, 70 * k));

The cycle is stored, so a reload keeps the code and the sand where they were.

app.js — a reload keeps the cycle
const stored = load();
if (Q.has('left')) { st.sentAt = now() - (DUR - Math.min(DUR, Math.max(0, +Q.get('left') || 0))) * 1000; st.first = !stored; }
else if (stored && now() - stored.sentAt < 3600e3 && stored.sentAt <= now()) { st.sentAt = stored.sentAt; st.code = stored.code; }
else { st.sentAt = now() - (DUR - SHOWCASE_LEFT) * 1000; st.first = true; }
save();

If the GPU context is lost and comes back, the page rebuilds its texture and redraws. With reduced motion the turn is a cut and the sand settles at once.

app.js — context restored
if (fx) scene.G.onrestored = () => { scene.init(); scene.upload(true); need.full = true; wake(); };
Stage 3 · the finished stepOpen ↗
The finished step after the right code: six green-ringed boxes 4 8 2 9 1 3, a green Verified button and Code accepted, the line Verified. Signing you in, and the hourglass on the right with the sand stopped.
Stage 3 · on a phoneOpen ↗
The finished step on a phone: a text message banner with the new code and a Fill code button over the top of the hourglass, which has just been turned and pours from its full upper bulb; below it the headline, six empty boxes and Resend code in 0:59

Make it yours

  • Your check goes where the demo compares with the stored code: a fetch to your server, then the same success or error branch.
  • Your timer is DUR in app.js: the metering follows it, so a five-minute code drains five times slower with the same sand.
  • Accessibility is already there: real labelled inputs with autocomplete="one-time-code" and a numeric keyboard, errors in a role=alert line, the countdown announced at ten seconds, the turn as a real button with keys, 44 px targets, prefers-reduced-motion honoured, the loop asleep at rest, and a CSS hourglass without WebGL.

Sable is a made-up brand. Everything on the page was made in code for this project: the studio, the glass, the wood, the brass and the sand are one shader.

The one idea worth keeping

Meter the simulation to the clock, not the clock to the simulation. The grains are free to tumble however they like; only how many have passed the neck is set by the time, so the picture can never disagree with the timer.