This is the whole build of the ember countdown, in the order it was written: the sale timer of a made-up cookware shop, Forge & Hearth. Every digit is cast iron. When a digit changes, liquid metal pours up into it white-hot, and from then on it cools with its real age, through orange to a dull red under a cracked scale. The newest digit always burns brightest, the old units of the clock run cooler, embers rise off the heat, and the last ten seconds blaze. 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 room, the flames, the floor and the embers are one WebGL canvas behind the page; the iron of the digits is a second, small canvas clipped by the digits' own SVG text, so every edge stays vector-sharp at any zoom.
1 · The page, in HTML and CSS
Start with the timer: a role=timer whose spoken reading is a sentence, and eight digits in four groups
with three colons. The digits are real text, one fixed-width cell each, so the row never jitters as they change.
<div class="count" id="count" aria-hidden="true">
<span class="grp g0"><span class="dg"><b>0</b></span><span class="dg"><b>2</b></span></span><span class="col c0"><i></i><i></i></span>
<span class="grp g1"><span class="dg"><b>1</b></span><span class="dg"><b>4</b></span></span><span class="col c1"><i></i><i></i></span>
<span class="grp g2"><span class="dg"><b>3</b></span><span class="dg"><b>7</b></span></span><span class="col c2"><i></i><i></i></span>
<span class="grp g3"><span class="dg"><b>0</b></span><span class="dg"><b>9</b></span></span>
</div>
.count, .units { font-size: var(--F); display: flex; width: var(--cells); }
.count { position: relative; height: .8em; }
.grp { display: flex; width: 1.32em; flex: none; justify-content: center; }
.col { width: .32em; flex: none; position: relative; }
.dg { width: .66em; position: relative; }
.dg b { position: absolute; left: 50%; bottom: -.115em; transform: translateX(-50%); font: 400 1em/1 'Abril Fatface', Georgia, serif; visibility: hidden; }
For now the iron is a CSS gradient clipped to the glyphs, white-hot at the top fading to dark red, with two glowing drop shadows; the newest digit gets a hotter gradient. The finished page keeps exactly this as its fallback, for a browser without WebGL.
.no-fx .dg b { visibility: visible; color: transparent; background-image: linear-gradient(180deg, #ffe0a0 8%, #ff9a3c 38%, #e2541a 68%, #8a2a08); -webkit-background-clip: text; background-clip: text;
filter: drop-shadow(0 0 .05em rgba(255, 110, 30, .75)) drop-shadow(0 0 .18em rgba(255, 90, 20, .35)); }
.no-fx .g3 .dg:last-child b { background-image: linear-gradient(180deg, #fff8e6 10%, #ffe2a0 45%, #ffb35a 80%, #ff8a2a); }
A few dots rising on a CSS animation stand in for the embers.
.no-fx .fx-fallback i { position: absolute; bottom: 38%; width: 3px; height: 3px; border-radius: 50%; background: #ffb060; box-shadow: 0 0 6px 2px rgba(255, 120, 40, .6); opacity: 0; animation: rise 5s linear infinite; }
.no-fx .fx-fallback i:nth-child(odd) { animation-duration: 6.5s; width: 2px; height: 2px; }
On a phone only minutes and seconds stay as digits; days and hours move into a small eyebrow line above them.
.g0, .g1, .c0, .c1 { display: none; }

2 · Hot iron
The digits stay in the DOM, and the same text becomes an SVG clipPath over a small WebGL canvas. The
canvas paints the metal; the clip gives it the font's exact edges.
<canvas class="metal" id="metal"></canvas>
<svg class="clipdefs" width="0" height="0" focusable="false"><defs><clipPath id="dg-clip" clipPathUnits="userSpaceOnUse"></clipPath></defs></svg>
Every digit has a heat. It is poured at 1.08 and decays towards a resting heat with its real age; the old units (days, hours) rest cooler than the seconds. In the final ten seconds every digit is pushed hotter, and after the sale the heat falls to cold iron.
function heatOf(i, t) {
const sl = S.slots[i];
if (sl.intro && t < sl.introAt) return 0.04;
const age = t - sl.changedAt;
let H = FLOOR[i] + (1.08 - FLOOR[i]) * Math.exp(-age / TAU);
if (sl.intro) H = Math.max(H, FLOOR[i] + (1.12 - FLOOR[i]) * Math.exp(-Math.max(0, t - sl.introAt) / 0.95));
H = Math.min(1.1, H + S.fin * (i >= 6 ? 0.2 : 0.16));
const cold = S.ended ? smooth(0.25, 4.5, t - S.endedAt) : 0;
return H * (1 - cold) + 0.035 * cold;
}
A freshly opened page has no history, so it asks how long ago each digit really changed: the minutes digit that changed 40 seconds ago is already dull, the seconds digit glows.
function seedAges(t) {
const rem = S.end - nowMs(), sec = Math.max(0, Math.ceil(rem / 1000)), frac = Math.max(0, sec - rem / 1000);
const cur = split(sec).str;
for (let i = 0; i < 8; i++) {
let j = 0;
while (j < 600 && split(sec + j + 1).str[i] === cur[i]) j++;
S.slots[i].changedAt = t - (j + frac);
}
}
When a digit changes, the old glyph stays in the clip while the new metal rises from the bottom.
if (!first && S.str) {
sl.old = +S.str[i]; sl.oldHeat = heatOf(i, t); sl.changedAt = t;
const live = !reduced.matches && !S.sleeping;
sl.pourAt = live ? t : -99;
if (live && clipNodes[i] && S.geo && S.geo.slots[i] !== null) { clipNodes[i].old.textContent = String(sl.old); clipNodes[i].old.setAttribute('display', 'inline'); }
}
float pour=X.z;
if(pour<1.0){
float pe=1.0-pow(1.0-pour,2.2);
float fy=-(CAP+0.03)*pe+0.010*sin(l.x*38.0+uTime*34.0)*(1.0-pe)+0.004*sin(l.x*91.0-uTime*51.0);
if(l.y>fy){ // already filled with the new, liquid metal
heat=1.22;
alpha=smoothstep(0.30,0.62, glyph(dig,l).r);
surf=exp(-pow((l.y-fy)/0.018,2.0));
} else { // the old digit still stands above the rising metal
dig=X.w; heat=uOldHeat[k];
alpha=dig<0.0? 0.0 : smoothstep(0.30,0.62, glyph(dig,l).r);
}
}
The surface is blackbody light. As the iron cools a dark scale forms over it, and the cracks in that scale, warped Worley edges, stay hotter than the crust, so they glow.
vec2 w=worley(sp*1.9+vec2(n1*1.2, -n1)+seed*0.37);
float crack=1.0-smoothstep(0.01,0.06,w.y-w.x);
float skinAmt=clamp((1.10-heat)*1.35,0.0,1.0);
float thr=0.80-0.26*skinAmt;
float skin=smoothstep(thr-0.08, thr+0.08, n1+0.06*(1.0-core))*smoothstep(0.0,0.2,skinAmt);
float T=heat*(0.80+0.28*core);
float mott=fbm3(sp*2.6+seed*1.7);
float Ts=T*(1.0-0.36*skin)*(0.95+0.10*mott)+crack*skin*T*0.32;
The last ten seconds ramp the whole room up: flames on every digit, a pulse of light on each tick, twice the embers. At zero the iron flares once and cools.
function tickTimer(t, first = false) {
const rem = S.end - nowMs();
const sec = Math.max(0, Math.ceil(rem / 1000));
if (sec !== S.left || first) onSecond(sec, t, first);
if (rem <= 0 && !S.ended) { setEnded(true, t); onSecond(0, t, false); }
const target = (!S.ended && sec <= 10 && sec > 0) ? 1 : 0;
S.fin += (target - S.fin) * (reduced.matches ? 1 : 0.06);
if (Math.abs(target - S.fin) < 0.002) S.fin = target;
}
The pointer is a bellows. Moving fast across the digits heats the one under it, and the embers swirl away from it.
function onPointer(e) {
if (reduced.matches || !S.geo) return;
const t = clock(), x = e.pageX, y = e.pageY;
const p = S.ptr, dtp = Math.max(0.008, t - p.pt);
const sp = Math.hypot(x - p.px, y - p.py) / dtp;
p.px = x; p.py = y; p.pt = t;
const g = S.geo;
const near = y > g.band[0] - 0.8 * g.F && y < g.band[1] + 0.4 * g.F && x > g.span0 - g.F && x < g.span1 + g.F;
if (!near) return;
p.x = x; p.y = y; p.last = t; S.lastInput = t;
p.str = Math.min(1, p.str + Math.min(0.35, sp * 0.00045 + 0.02));
if (y > g.band[0] && y < g.band[1]) {
let k = -1, best = 1e9;
g.slots.forEach((cx, i) => { if (cx === null) return; const d = Math.abs(x - cx); if (d < best) { best = d; k = i; } });
if (k >= 0 && best < 0.45 * g.F) S.slots[k].boost = Math.min(0.55, S.slots[k].boost + Math.min(0.08, sp * 0.00008));
}
wake();
}
vec2 d=pos-uPtr.xy; float dl=length(d)+1.0;
float f=uPtr.z*exp(-dot(d,d)/(130.0*130.0));
pos+=(d/dl*64.0+vec2(-d.y,d.x)/dl*44.0)*f;
The loop runs at full rate only while something moves fast (a pour, the bellows, the final seconds), at 60 or 30 frames otherwise, and sleeps when the sale is over or the page is hidden.
const busy = ptrActive || S.fin > 0.01 || (S.ended && t - S.endedAt < 7) || S.slots.some((sl) => sl.intro);
const quiet = t - S.lastInput > 20;
S.rate = busy ? 'full' : quiet ? '30' : '60';
const minDt = busy ? 0 : quiet ? 1 / 30 - 0.004 : 1 / 60 - 0.004;

3 · The finish
Remind me opens an inline email form. An empty or incomplete address gets an error in a role=alert
line; a valid one shows a busy state and then a confirmation. Only a flag is stored, never the address.
function submit(e) {
e.preventDefault();
const v = el.email.value.trim();
el.form.classList.remove('err'); void el.form.offsetWidth;
if (!v || !EMAIL.test(v)) {
el.email.setAttribute('aria-invalid', 'true'); el.form.classList.add('err');
setMsg(!v ? 'Enter your email and we’ll remind you before it ends.' : 'That email looks incomplete. Check the part after the @.', 'err');
el.email.focus(); return;
}
el.email.removeAttribute('aria-invalid');
el.set.disabled = true; el.set.textContent = 'Setting…'; el.form.setAttribute('aria-busy', 'true');
setTimeout(() => {
el.set.disabled = false; el.set.textContent = 'Remind me'; el.form.removeAttribute('aria-busy');
S.reminded = true; store.set(KEY_REMIND, '1'); // the address itself is never stored
remindLabel(); openForm(false);
setMsg(S.ended ? 'Done. We’ll tell you first when the next sale opens.' : 'Done. We’ll email you an hour before it ends.', 'ok');
}, qs.has('fast') ? 60 : 650);
}
The end time is stored, so a reload keeps counting instead of starting over.
const stored = +store.get(KEY_END);
S.liveEnd = stored > 0 ? stored : Date.now() + SHOWCASE;
if (!(stored > 0)) store.set(KEY_END, String(S.liveEnd));
Screen readers hear the full reading on demand, and a polite announcement at one minute, at ten seconds and at the end.
if (sec === 60) announce('One minute left in the Ember Sale.');
if (sec === 10) announce('Ten seconds left in the Ember Sale.');
If the GPU context is lost, the CSS digits take over; when it comes back, the page rebuilds its programs and the iron returns.
forge.fx.onrestored = forge.mt.onrestored = () => {
if (forge.fx.lost || forge.mt.lost) return;
try { forge.setup(); document.documentElement.classList.remove('no-fx'); clearTimeout(rmTimer); layout(); } catch (e) {}
};


Make it yours
- Your end time goes into
initEnd(): replace the first-visit showcase with the real end of your sale, from your server or a data attribute. - Your reminder goes where the demo waits 650 ms: a
fetchto your mailing service, then the same success or error line. - Accessibility is already there: a
role=timerwith a spoken sentence, polite announcements, the form errors inrole=alert, 44 px targets, visible focus,prefers-reduced-motionhonoured (one still frame a second), the loop asleep at rest, and CSS digits without WebGL.
Forge & Hearth is a made-up brand. Everything on the page was made in code for this project: the room, the iron, the flames and the embers are shaders.
Let the material keep time. Each digit's heat is nothing but its age, so the picture is a second clock: you can read how long ago every digit changed from how brightly it still glows.