Library / Tutorial

Ember countdown,
step by step.

A sale countdown in hot cast iron: every digit is poured white-hot when it changes and cools with its real age under a cracked scale, embers rise, the pointer is a bellows and the last ten seconds blaze. Three stages, each a live page you can run.

3Stages
2Canvases
0Libraries

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.

index.html — the digits
<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>
ember.css — one cell per digit
.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.

ember.css — hot metal in CSS
.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.

ember.css — CSS 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.

ember.css — MM:SS on a phone
.g0, .g1, .c0, .c1 { display: none; }
Stage 1 · HTML and CSSOpen ↗
The Forge and Hearth sale page in plain HTML and CSS: the pill The Ember Sale, the italic headline Cast iron, still glowing from the foundry., and the countdown 02:14:37:09 in big serif digits filled with an orange-to-red gradient, the last digit paler; units, the lede, Shop the sale and Remind me below.

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.

index.html — the metal canvas and its clip
<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.

app.js — heat from age
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.

app.js — the heat ladder of the first screen
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.

app.js — a tick starts a pour
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'); }
}
shaders.js — liquid metal fills the digit bottom up
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.

shaders.js — the cracked scale
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.

app.js — the final seconds and the end
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.

app.js — the bellows
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();
}
shaders.js — embers pushed by the pointer
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.

app.js — the scheduler
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;
Stage 2 · hot ironOpen ↗
The countdown in WebGL: 02:14:37:08 in glowing cast iron, the days and hours a dull cracked red, the minutes orange, the last digit white-hot and just poured with a splash of sparks and flames licking off it; embers rise over a dark steel floor that reflects the digits.

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.

app.js — the reminder
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.

app.js — a reload keeps counting
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.

app.js — announcements
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.

app.js — context restored
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) {}
};
Stage 3 · the finished saleOpen ↗
The finished page in the final seconds: the pill Final seconds glowing, 00:00:00:06 with flames rising off every digit and the 6 being poured white-hot, the room lit orange, Shop the sale glowing and Remind me pressed as We’ll remind you, with the line Done. We’ll email you an hour before it ends.
Stage 3 · on a phoneOpen ↗
The finished sale on a phone in the final seconds: the eyebrow 0 days · 0 hours above 00:06 in big glowing iron, flames rising off every digit and the 6 being poured white-hot over the reflecting floor; below it the lede, Shop the sale and Remind me

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 fetch to your mailing service, then the same success or error line.
  • Accessibility is already there: a role=timer with a spoken sentence, polite announcements, the form errors in role=alert, 44 px targets, visible focus, prefers-reduced-motion honoured (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.

The one idea worth keeping

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.