Library / Tutorial

Lava lamp loader,
step by step.

An export progress bar that is a lava lamp. Warm wax buds from the heated pool, rises, merges and melts as the percentage climbs; past the bright heat front the liquid is cold and the wax lies flat. Plain HTML, CSS and JavaScript with WebGL2. Three stages, each a live page you can run.

3Stages
22Blobs of wax
0Libraries

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.

index.html — the lamp is a progressbar
<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.

lava.css — the warm fill follows --p
.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 %.

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

app.js — progress from an anchor and 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; }
app.js — reaching 100 %, or the fault
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.

app.js — time left
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' : '—';
app.js — the CSS lamp
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.

app.js — one place for the states
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.

app.js — one button, four jobs
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.

app.js — announce
function say(t) { el.live.textContent = ''; setTimeout(() => (el.live.textContent = t), 40); }
app.js — a slow timer
// 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);
Stage 1 · the render queue and the CSS lampOpen ↗
The Ember Cut render queue: the Lisbon export card with a frosted CSS tube whose warm orange fill reaches 68 %, the percentage, written, time left and speed, Pause and Restart, and the queue with two more jobs.

2 · The wax

A canvas joins the lamp, a little bigger than it on every side, so the glow can fall on the card.

index.html — the canvas
<canvas id="cv" aria-hidden="true"></canvas>
lava.css — room for the glow
#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.

lamp.js — a blob buds in the pool
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.

lamp.js — rise
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.

lamp.js — melt in place
// 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.

lamp.js — merge
// 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';
    }
  }
}
lamp.js — part
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.

lamp.js — the pool
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;
lamp.js — smooth-min metaballs
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.

lamp.js — translucent wax
// 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));
lamp.js — cold wax
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.

lamp.js — the seam
// 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.

app.js — state drives heat and motion
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.

app.js — hover and stir
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(); });
lamp.js — warmth under the arrow
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.

lamp.js — draw only what changed
// 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.

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

app.js — sleep when nothing moves
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.

index.html — WebGL2, or the CSS lamp
// 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');
})();
Stage 2 · the waxOpen ↗
The same card with the WebGL2 lava lamp: translucent orange wax rising from a glowing pool in a glass tube between two knurled steel collars, a bright seam at 68 % and cold dark liquid past it, the warm glow falling on the card.

3 · The finish

The Notify switch is remembered under one versioned key, read and written safely.

app.js — storage that never throws
const store = { get(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }, set(k, v) { try { localStorage.setItem(k, v); } catch (e) {} } };
app.js — the switch
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.

index.html — wipe old keys
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) {}
index.html — ?nofx
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.

app.js — a lost context
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(); };
app.js — the test hook
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,
Stage 3 · the finished lampOpen ↗
The finished lava lamp progress bar: warm wax budding and rising behind the bright heat-front seam, cold liquid past it, the export card and the queue around it.
Stage 3 · on a phoneOpen ↗
The lava lamp loader on a phone: the lamp stands upright beside the percentage, written, time left and speed, warm wax rising to the heat front, Pause and Restart and the queue below.

Make it yours

  • Your jobs are the JOBS list in app.js: a title, a meta line, the size and the speed. Drive S.prog from your real export instead of the simulated clock.
  • Your wax is in lamp.js: NB caps the blobs, sp sets 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 progressbar with a spoken value, one polite live region for 25 / 50 / 75 %, paused, resumed, complete and failed, the error is role=alert, the switch is role=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.

The one idea worth keeping

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.