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.
<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>
.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; }
.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.
.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.
.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); }
.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; }

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.
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.
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.
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.
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.
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];
}
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(); }
}
}
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.
// 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.
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;
}

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.
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);
}
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.
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.
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.
if (fx) scene.G.onrestored = () => { scene.init(); scene.upload(true); need.full = true; wake(); };


Make it yours
- Your check goes where the demo compares with the stored code: a
fetchto your server, then the same success or error branch. - Your timer is
DURin 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 arole=alertline, the countdown announced at ten seconds, the turn as a real button with keys, 44 px targets,prefers-reduced-motionhonoured, 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.
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.