This is the whole build of the lava lamp progress bar, in the order it was written: the render queue of a made-up video editor, Ember Cut. The export's progress bar is a glass lava lamp lying on its side. Behind the bright heat front the liquid is warm and the wax buds from the heated pool, rises, merges and melts away under the glass; past the front the liquid is cold and the wax lies flat. 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 three small ES modules.
1 · The render queue and a CSS lava lamp
Start with what a screen reader needs. The lamp is a real progress bar: it carries the percentage and, in words, the time left or the state.
<div class="lamp" id="lamp" role="progressbar" aria-label="Export progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="68" aria-valuetext="68 percent, 0:41 left">
<div class="fb" aria-hidden="true"><div class="fb-glass"><div class="fb-fill"><i></i><i></i><i></i><i></i><i></i></div><div class="fb-cold"></div></div><b class="fb-cap"></b><b class="fb-cap"></b></div>
</div>
Inside it sits a lamp made of CSS alone: a frosted tube whose warm fill follows one custom property. It stays as the fallback when there is no WebGL2.
.fb-fill { position: absolute; left: 0; top: 0; bottom: 0; width: calc(var(--p, .68) * 100%); background: radial-gradient(120% 60% at 50% 100%, #ffd27a, #f08a2c 35%, rgba(90, 60, 50, .6) 75%); transition: width .5s linear; overflow: hidden; }
The queue is three jobs. The third one has a fault planted in it: its source media goes offline at 23 %.
const JOBS = [
{ id: 'lisbon', title: 'Lisbon, golden hour — final cut', meta: 'ProRes 422 HQ · 3840×2160 · 24 fps · 2:14', gb: 3.10, mbps: 24.2, thumb: 'sunset', q: 'c' },
{ id: 'teaser', title: 'Teaser 15 s — vertical 9:16', meta: 'H.264 · 2160×3840 · 24 fps · 0:15', gb: 0.412, mbps: 17.5, thumb: 'teal', q: '' },
{ id: 'bts', title: 'Behind the scenes — 1080p', meta: 'HEVC · 1920×1080 · 25 fps · 6:40', gb: 1.2, mbps: 26.5, thumb: 'violet', q: 'b', failAt: 0.23 },
];
The export runs on the wall clock, not on frames, so it keeps time while the tab is hidden or the lamp is off screen. Progress is an anchor (where and when it last changed) plus a rate.
const rate = () => job().mbps * FAST / (job().gb * 1000); // progress per second
const speedNow = (now) => S.state === 'exporting' ? job().mbps * FAST * (1 + 0.035 * Math.sin(now / 1000 * 0.7) + 0.025 * Math.sin(now / 1000 * 1.9 + 1)) : 0;
function progAt(now) { return S.state === 'exporting' ? Math.min(1, S.p0 + rate() * (now - S.t0) / 1000) : S.p0; }
function anchor(now, p) { S.p0 = p; S.t0 = now; S.prog = p; }
function update(now) {
let p = progAt(now);
const f = S.fails[S.cur];
if (S.state === 'exporting' && f !== null && p >= f) { p = f; anchor(now, p); setState('error'); }
else if (S.state === 'exporting' && p >= 1) { anchor(now, 1); setState('done'); }
S.prog = p;
return p;
}
Time left comes from the bytes still to write at the current speed, so it always agrees with the speed shown.
const sp = speedNow(now), left = (1 - p) * job().gb * 1000 / Math.max(0.001, sp);
const etaTxt = S.state === 'exporting' ? fmtT(left) : S.state === 'done' ? '0:00' : '—';
function syncFallback() { el.lamp.style.setProperty('--p', S.prog.toFixed(4)); document.documentElement.style.setProperty('--heat', S.state === 'exporting' || S.state === 'done' ? 1 : 0.3); }
Every state change goes through one function: it marks the job, announces it and redraws the controls.
function setState(st) {
const prev = S.state; S.state = st;
const now = performance.now();
if (st === 'done') {
S.status[S.cur] = 'done'; S.doneAt = now;
say(`Export complete: ${job().title}, ${sizeTxt(job())}.`);
if (S.notify) showToast(`${job().title} · ${sizeTxt(job())} saved to Exports`);
}
if (st === 'error') say('Export failed: source media offline.');
if (st === 'paused') say(`Paused at ${Math.floor(S.prog * 100)} percent.`);
if (st === 'exporting' && prev === 'paused') say('Export resumed.');
renderControls(); renderNumbers(now, true);
}
The primary button changes its job with the state: Restart while exporting, Retry after the fault, Export next when a job is done, Export again when the queue is finished.
el.primary.addEventListener('click', () => {
const now = performance.now(), kind = el.primary.dataset.kind;
if (kind === 'retry') { S.fails[S.cur] = null; anchor(now, S.prog); setState('exporting'); say('Media reconnected. Export resumed.'); return; }
if (kind === 'next') { const nx = nextPending(); if (nx >= 0) { startJob(nx); say(`Exporting ${job().title}.`); } return; }
// restart: this job from 0, or after the whole queue finished, the queue again
if (S.state === 'done' && nextPending() < 0) { S.status = JOBS.map(() => 'queued'); S.fails = JOBS.map((j) => j.failAt || null); startJob(0); say('Exporting the queue again.'); return; }
startJob(S.cur, 0); say(`Restarted ${job().title}.`);
});
Announcements go through one polite live region, with the time left in words rather than a bare number.
function say(t) { el.live.textContent = ''; setTimeout(() => (el.live.textContent = t), 40); }
// the export runs on the wall clock: a slow timer moves the numbers and the CSS lamp
setInterval(() => { const now = performance.now(); update(now); renderNumbers(now); }, 250);

2 · The wax
A canvas joins the lamp, a little bigger than it on every side, so the glow can fall on the card.
<canvas id="cv" aria-hidden="true"></canvas>
#cv { position: absolute; left: calc(-1 * var(--gm)); top: calc(-1 * var(--gm)); width: calc(100% + 2 * var(--gm)); height: calc(100% + 2 * var(--gm));
--gm: calc(110 * var(--u)); pointer-events: none; z-index: 1; display: block; }
The wax is a small simulation measured in glass radii. A blob buds inside the heated pool, behind the front only, so the pool necks up into a plume before it lets go.
const lo = X0 + 0.3, hi = F - 0.22; if (hi <= lo) return;
w = atW !== undefined ? Math.min(hi, Math.max(lo, atW)) : lo + (hi - lo) * Math.pow(r(), 0.75);
It grows to its size and rises on buoyancy that fades under the glass, then it flattens against the top and melts back.
b.r += (b.rt - b.r) * Math.min(1, md * 0.75);
const k = Math.max(0, Math.min(1, (ceil - b.s) / 0.6));
let vt = b.sp * (0.25 + 0.75 * Math.min(1, b.age / 2.2)) * (k * k * (3 - 2 * k));
Nothing ever moves down: a lava lamp has no drops. When the front falls back (a restart, the next job), the wax past it simply melts where it is.
// beyond the front (restart, or the front fell back): the wax cools and melts in place
if (b.st === 'rise' && (up ? b.s > Math.min(F, X1) + 0.1 : b.w > F + 0.05)) b.st = 'melt';
Blobs that meet rise together and fuse, keeping their area; big ones part in two.
// pairs: rising together when close, fuse when they overlap, big ones part
for (let i = 0; i < B.length; i++) for (let j = i + 1; j < B.length; j++) {
const a = B[i], c = B[j]; if (a.st !== 'rise' || c.st !== 'rise') continue;
const ds = c.s - a.s, dw = c.w - a.w, dist = Math.hypot(ds, dw), rs = a.r + c.r;
if (dist < rs * 1.25) {
const k = md * 0.5 * (1 - dist / (rs * 1.25));
const avs = (a.vs + c.vs) / 2; a.vs += (avs - a.vs) * k * 2; c.vs += (avs - c.vs) * k * 2;
a.vw += dw * k * 0.4; c.vw -= dw * k * 0.4;
if (dist < rs * 0.42 && a.age > 2.5 && c.age > 2.5) {
const [big, sm] = a.rt >= c.rt ? [a, c] : [c, a];
big.rt = Math.min(up ? 0.6 : 0.44, Math.sqrt(big.rt * big.rt + sm.r * sm.r)); sm.st = 'absorb';
}
}
}
const split = up ? 0.48 : 0.34;
for (const b of B.slice()) if (b.st === 'rise' && b.r > split && b.age > 4 && this.rand() < md * 0.18 && B.length < NB) {
const nr = b.r / Math.SQRT2; b.rt = nr;
const dir = this.rand() < 0.5 ? -1 : 1;
B.push({ ...b, r: nr * 0.35, rt: nr, vw: dir * 0.1, vs: b.vs * 1.15, sp: b.sp * 1.1, ph: this.rand() * 6.283, age: 2.6, top: 0 });
b.vw = -dir * 0.1;
}
The picture takes two passes. The first, at half resolution, turns the pool and the blobs into one distance field: the pool is a wavy surface that swells just behind the front, and every blob is melted into it with a smooth minimum, carrying its temperature along.
float h=smoothstep(uFront+0.2*R, uFront-0.35*R, q.x)*uPoolHeat;
float ph=R*(0.17+0.17*h+h*(0.045*sin(q.x/(0.85*R)+t*0.45)+0.03*sin(q.x/(0.33*R)-t*0.7)));
ph+=R*0.09*h*exp(-pow((q.x-uFront+0.3*R)/(0.55*R),2.0)); // the pool swells just behind the front
d=q.y-(-R+ph); T=h;
float di=(length((q-b.xy)/b.zw)-1.0)*min(b.z,b.w);
if(di-d>uK){ continue; } // too far to blend: d unchanged, temperature too
float hh=clamp(0.5+0.5*(di-d)/uK,0.0,1.0);
d=mix(di,d,hh)-uK*hh*(1.0-hh);
T=mix(uT[i],T,hh);
The second pass is full resolution. The wax is translucent: thin rims pass deep red light, the body glows orange and the thick core turns yellow-white, lit by the bulb below. Cold wax is a dull brown.
// translucent wax: thin rims pass deep red light, the body glows orange, the thick core yellow-white
vec3 w=mix(vec3(0.6,0.06,0.01), vec3(1.0,0.27,0.03), smoothstep(0.0,0.3,depth));
w=mix(w, vec3(1.0,0.4,0.06), smoothstep(0.25,0.8,depth));
w=mix(w, vec3(1.0,0.86,0.55), smoothstep(0.65,1.0,depth)*smoothstep(0.5,1.0,temp)*0.22*(0.3+0.7*trans));
vec3 wx=mix(cold, w, smoothstep(0.03,0.45,temp));
wx=mix(wx, wx*vec3(0.75,0.32,0.3), uErr*0.85);
The heat front is a bright seam across the tube.
// the heat front: a bright seam across the tube
float seam=exp(-pow((q.x-front)/(0.03*R),2.0))*step(0.002,uProg)*step(uProg,0.998);
c+=vec3(1.0,0.82,0.55)*0.75*seam*(0.5+0.5*H)*(1.0-0.6*uErr)*(1.0-0.45*vv*vv);
The export's state sets where the lamp is heading: hot and moving while exporting, cooler and still when paused, cold red when it fails.
function lampGoals() {
if (!lamp) return;
const st = S.state;
lamp.heatGoal = st === 'exporting' ? 1 : st === 'paused' ? 0.34 : st === 'error' ? 0.14 : 1;
lamp.errGoal = st === 'error' ? 1 : 0;
lamp.motionGoal = S.reduced ? 0 : st === 'exporting' ? 1 : st === 'done' ? 1 : 0;
if (S.reduced) { lamp.motion = 0; lamp.heat = lamp.heatGoal; lamp.err = lamp.errGoal; }
}
Your hand counts too: the arrow warms the wax under it and a tap stirs it.
el.lamp.addEventListener('pointermove', (e) => { if (!fx() || e.pointerType !== 'mouse' || S.reduced) return; lamp.pointer(e.clientX, e.clientY, true); kick(); });
el.lamp.addEventListener('pointerleave', () => { if (!fx()) return; lamp.pointer(0, 0, false); kick(); });
el.lamp.addEventListener('pointerdown', (e) => { if (!fx() || S.reduced) return; lamp.pointer(e.clientX, e.clientY, e.pointerType === 'mouse'); lamp.stirAt(e.clientX, e.clientY); S.stirs = (S.stirs || 0) + 1; kick(); });
float pd=length(q-uPtr.xy);
c+=vec3(1.0,0.5,0.18)*0.16*exp(-pd*pd/(0.9*R*R))*uPtr.z*hot;
c+=vec3(1.0,0.6,0.25)*0.28*uStir*exp(-pd*pd/(2.2*R*R))*hot;
Most frames only the hot part has changed, so the composite is scissored to it.
// what changed this frame: the hot part (+ its glow) up to the front, or everything
if (this.lastFront !== null && front < this.lastFront - 0.5) this.full = Math.max(this.full, 30);
this.lastFront = front;
const reach = R * 2.6;
let sx = 0, sy = 0, sw = W, sh = Hh;
const fullPass = force || this.full > 0 || this.done > 0.001;
if (!fullPass) {
// past the front the halo reaches 0.45R + 0.9 × its distance from the glass (up to the canvas margin): cover all of it
const fx = front + R * 0.65 + 0.9 * (g.gm + 0.25 * R) + (this.ptr.k > 0 ? R * 0.2 : 0);
if (!this.upright) { sx = 0; sw = Math.min(W, Math.ceil(g.cx + fx + reach * 0.15)); }
else { sy = 0; sh = Math.min(Hh, Math.ceil(g.cy + fx + reach * 0.15)); }
When a job completes, the lamp blooms and then settles warm and still.
// completion: a bloom, then the lamp settles warm and still
if (S.state === 'done') {
const k = (now - S.doneAt) / 1000;
lamp.done = S.reduced ? 0 : k < 0.5 ? k / 0.5 : Math.max(0, 1 - (k - 0.5) / 2.6);
if (k > 5.5) lamp.motionGoal = 0;
lamp.heatGoal = k > 4 ? 0.72 : 1;
The frame loop runs only while the wax moves. Paused, failed and finished lamps ease to a stop and the loop sleeps; the export keeps its time on the slow timer.
function moving() {
if (!fx() || !S.visible || document.hidden) return false;
if (S.hold) return true;
const L = lamp;
if (L.motion > 0 || L.motionGoal > 0) return true;
if (L.heat !== L.heatGoal || Math.abs(L.err - L.errGoal) > 0.002 || L.done > 0.001 || L.stir > 0 || L.ptr.k > 0) return true;
if (L.blobs.some((b) => b.st !== 'rise')) return true;
return false;
}
Before the first paint, a page without WebGL2 keeps the CSS lamp of stage 1.
// before paint: pick the no-WebGL fallback
(function () {
var fx; try { var c = document.createElement('canvas'); fx = !!c.getContext('webgl2'); } catch (e) { fx = false; }
if (!fx) document.documentElement.classList.add('no-fx');
})();

3 · The finish
The Notify switch is remembered under one versioned key, read and written safely.
const store = { get(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }, set(k, v) { try { localStorage.setItem(k, v); } catch (e) {} } };
el.notify.addEventListener('click', () => { S.notify = !S.notify; el.notify.setAttribute('aria-checked', S.notify ? 'true' : 'false'); store.set(KEY, S.notify ? '1' : '0'); });
Older keys of the component are wiped before the first paint, where ?nofx can also ask for the CSS
lamp.
try { for (var i = localStorage.length - 1; i >= 0; i--) { var k = localStorage.key(i); if (k && k.indexOf('dv71') === 0 && k.indexOf('dv71.v1.') !== 0) localStorage.removeItem(k); } } catch (e) {}
var fx = !/[?&]nofx\b/.test(location.search);
A lost WebGL context drops to the CSS lamp instead of an empty space, and the wax comes back with the context. A small test hook reports the state.
lamp.ctx.onlost = () => { S.fx = false; document.documentElement.classList.add('no-fx'); syncFallback(); };
lamp.onrestored = () => { S.fx = true; document.documentElement.classList.remove('no-fx'); lamp.layout(); lamp.render(true); kick(); };
window.__lava = {
ready, S, lamp, JOBS,
state: () => ({ state: S.state, prog: S.prog, cur: S.cur, fx: fx(), sleeping: !raf, raf: !!raf, blobs: lamp && lamp.ok ? lamp.blobs.length : 0,


Make it yours
- Your jobs are the
JOBSlist inapp.js: a title, a meta line, the size and the speed. DriveS.progfrom your real export instead of the simulated clock. - Your wax is in
lamp.js:NBcaps the blobs,spsets how fast they rise, the split size how big they grow before they part. - Your colours are in the composite shader: the wax ramp from deep red to yellow-white, the cold brown, the teal liquid.
- Accessibility is already there: the lamp is a
progressbarwith a spoken value, one polite live region for 25 / 50 / 75 %, paused, resumed, complete and failed, the error isrole=alert, the switch isrole=switch, a visible focus ring, 44 px targets,prefers-reduced-motion(still wax) and the CSS lamp without WebGL2.
Ember Cut and its exports are made up. Everything on the page was made in code for this project: the wax, the glass and the steel collars are a WebGL2 shader, the rest is HTML and CSS.
Let the state you already have drive the material. The lamp does not animate progress; the export's progress, pause, failure and completion simply set its heat and motion, and the wax does the rest.