Library / Tutorial

Smoke reveal,
step by step.

A hero headline behind real-time incense smoke. Move the cursor through it and the smoke parts, click and it blows a hole that rolls back, Tab to a button and a breeze clears the line. Every word stays DOM. Three stages, each a live page you can run.

3Stages
1Canvas
0Libraries

This is the whole build of the smoke reveal hero, in the order it was written: the landing page of a made-up incense house, Ashwood. Three incense sticks burn under the headline "Out of thin air." and their smoke banks up over it. Move the cursor through the smoke and it parts; click the empty hero and the smoke is blown away from that spot, then rolls back in. 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 ES modules. The smoke is one transparent WebGL 2 canvas laid between the headline and the buttons. Every word stays real DOM, so the page reads, selects, translates and works with a screen reader like any other.

1 · The hero, in HTML and CSS

Start with the copy: an eyebrow, a two-part headline, a standfirst and two links. The headline is split into two spans because the phone layout puts "thin air." on its own line.

index.html — the copy
<div class="copy">
  <p class="eyebrow">New · Cedar &amp; Ember collection</p>
  <h1 class="headline"><span class="l1">Out of</span> <em class="l2">thin air.</em></h1>
  <p class="lede">Hand-rolled incense,<span class="long"> slow-burning and</span> made in small batches.</p>
  <div class="ctas">
    <a class="btn primary" href="#main">Discover the collection</a>
    <a class="btn ghost" href="#main">Find a ritual</a>
  </div>
</div>

The layout is the concept's 1600 × 1000 frame scaled to the window. A tiny script in the head picks the layout and sets the scale before the first paint, so nothing jumps.

index.html — the frame scale, before the first paint
var d = document.documentElement;
var W = d.clientWidth || innerWidth, H = innerHeight, wide = W / H >= 1.15 && W >= 640;
d.classList.toggle('wide', wide);
d.style.setProperty('--s', Math.min(W / 1600, H / 1000).toFixed(4));
smoke.css — the wide layout, in frame units
.wide .copy { top: calc(var(--oy) + 243px * var(--s)); }
.wide .eyebrow { font-size: max(10px, calc(12px * var(--s))); letter-spacing: max(2.5px, calc(4px * var(--s))); }
.wide .headline { font-size: calc(200px * var(--s)); margin-top: calc(29px * var(--s)); --hs: var(--s); }
.wide .lede { font-size: max(13px, calc(19px * var(--s))); margin-top: calc(39px * var(--s)); }
.wide .ctas { margin-top: calc(33px * var(--s)); }

The trick that lets smoke pass in front of the headline but behind the buttons is the stacking. The copy block makes no stacking context of its own; each child gets its own z-index, so the smoke layer (z-index 2) can sit between the words (1) and the buttons and incense (3).

smoke.css — copy under the smoke
.copy { position: absolute; left: 0; right: 0; display: flex; flex-direction: column; align-items: center; text-align: center; }
.copy > * { position: relative; z-index: 1; }
smoke.css — the glowing headline
.headline { font-family: 'Instrument Serif', Georgia, serif; font-weight: 400; color: var(--head); letter-spacing: 0; line-height: .95;
  text-shadow: 0 0 calc(50px * var(--hs, 1)) rgba(255, 214, 170, .45); }

On a phone the headline breaks into two lines and the type scales with the shorter side of the screen.

smoke.css — the phone headline
html:not(.wide) .headline { font-size: var(--fs); margin-top: calc(var(--fs) * .49); line-height: .87; }
html:not(.wide) .headline span, html:not(.wide) .headline em { display: block; }

The sticks and the brass dish are inline SVG. The embers are soft CSS glows that flicker on their own timings.

smoke.css — an ember
.glow { position: absolute; width: 18.75%; height: 18.75%; margin: -9.375% 0 0 -9.375%; border-radius: 50%;
  background: radial-gradient(closest-side, rgba(255, 196, 120, .95), rgba(255, 110, 30, .62) 22%, rgba(255, 70, 10, .18) 60%, rgba(255, 60, 0, 0));
  will-change: opacity, transform; animation: ember 2.6s ease-in-out infinite; }

For now the smoke is CSS: five blurred clouds drifting slowly over the headline. The finished page keeps exactly this as its fallback, for a browser without WebGL 2 or a lost GPU context. The mask cuts a soft hole that a script can move under the pointer later.

smoke.css — the CSS smoke and its hole
.no-fx .smoke-css {
  display: block; position: absolute; inset: -10% -10% 0; z-index: 2; pointer-events: none;
  --mx: -999px; --my: -999px; --hole: 150px;
  -webkit-mask-image: radial-gradient(circle at var(--mx) var(--my), transparent 0, transparent calc(var(--hole) * .45), #000 var(--hole));
          mask-image: radial-gradient(circle at var(--mx) var(--my), transparent 0, transparent calc(var(--hole) * .45), #000 var(--hole));
}
smoke.css — one cloud
.smoke-css i { position: absolute; border-radius: 50%; filter: blur(34px); opacity: .9; animation: drift 22s ease-in-out infinite alternate; }
Stage 1 · HTML and CSSOpen ↗
The Ashwood hero in plain HTML and CSS: soft frosted clouds over the headline Out of thin air., the standfirst, two buttons and three incense sticks with glowing embers in a brass dish.

2 · The real smoke

Now the simulation, in fluid.js: stable fluids on the GPU, in half-float textures. Every step moves the velocity and the smoke along the velocity (semi-Lagrangian advection), adds back the small swirls that the grid smears away (vorticity confinement), lets dense smoke rise (buoyancy) and keeps the flow free of divergence with a Jacobi pressure solve. Advection also thins the smoke; it thins faster low down and at the sides, so the cloud banks at the top and the lower page stays dark around the plume.

fluid.js — advection, shaped by the composition
advect: H + `uniform sampler2D vel, src; uniform float dt, diss, dissK;
  void main(){ vec2 c = vUv - dt * texture(vel, vUv).xy * tx;
    float low = smoothstep(.62, .28, vUv.y), side = smoothstep(.14, .42, abs(vUv.x - .5));
    float k = diss + dissK * low * (.3 + 1.7 * side);
    o = texture(src, c) / (1. + k*dt); }`,
fluid.js — swirls and buoyancy in one pass
vort: H + `uniform sampler2D v, c, d; uniform float k, dt, buoy; uniform vec2 g;
  void main(){
  float L = texture(c, vL).x, R = texture(c, vR).x, T = texture(c, vT).x, B = texture(c, vB).x, C = texture(c, vUv).x;
  vec2 f = .5*vec2(abs(T) - abs(B), abs(R) - abs(L)); f /= length(f) + 1e-4; f *= k*C; f.y *= -1.;
  vec2 vel = texture(v, vUv).xy + f*dt + g*buoy*texture(d, vUv).r*dt; o = vec4(clamp(vel, -1000., 1000.), 0., 1.); }`,

The incense feeds it: each stick adds a little smoke and an upward push every step, with a slow sway so the three plumes braid instead of rising in straight lines.

smoke.js — the sticks
_incense(i) {
  const f = this.fluid, e = this.emit;
  if (e <= 0.001) return;
  this.src.forEach(([x, y], k) => {
    const [u, w] = this._uv(x, y);
    const sway = Math.sin(i * .013 + k * 2.2) * 35 + Math.sin(i * .051 + k) * 12;
    f.splatDye(u, w, .9 * e, .0016);
    f.splatVel(u, w + .004, sway * e, (210 + 40 * Math.sin(i * .02 + k)) * e, .0022);
  });
  if (i % 9 === 0) f.noiseVel(7 * e, 3, i * .37);
}

Smoke looks like smoke because of light. A half-resolution pass marches a few samples towards a key light at the top left, so thick smoke shades itself; the edge term brightens the thin rims.

smoke.js — self-shadowing
vec2 Ld = normalize(vec2(-.8, .55)) / uAsp;
float jit = h21(gl_FragCoord.xy + uSeed);
float acc = 0.;
for (int i = 1; i <= 10; i++) { float t = (float(i) - jit)*.0084; acc += D(v + Ld*t, 2.) * .0084; }
float Tl = exp(-acc * 40.);

The final pass composites in linear light, as if the smoke were lit in the room, and writes premultiplied colour and alpha. The page under the canvas is almost black, so the headline keeps exactly as much of itself as the smoke in front of it lets through.

smoke.js — the composite
// composite as the prototype did in linear light over the page: page·(1 − .92a) + lit·.85a, then to sRGB.
// The page under the canvas is near black, so the emitted part is tm(lit·.85a)^(1/2.2) and the page (the
// headline) keeps (1 − .92a)^(1/2.2) of itself: premultiplied output (emitted, 1 − kept).
vec3 col = lit * .8 * a;
col = col / (1. + col*.3);
col = pow(col, vec3(1./2.2));
float A = 1. - pow(1. - a * .92, 1./2.2);

The simulation runs at a fixed 60 steps a second whatever the screen does; on a 120 Hz screen the step and the draw take turns, so each frame stays light.

smoke.js — a fixed 60 Hz step
// fixed 60 Hz sim; at 120 Hz the sim step and the light+final draw land on alternate frames
this.acc = Math.min(this.acc + dt, 2 / 60);
if (this.needLight) { this.render(); }
else if (this.acc >= 1 / 60 - .002) { this.acc -= 1 / 60; this.stepSim(now); if (dt > 1 / 90) this.render(); }

The page tells the smoke where things are: the three embers (the smoke's sources) and the two headline lines (they light the smoke in front of them).

app.js — measure the hero
function measure() {
  const hr = hero.getBoundingClientRect();
  const sources = [...document.querySelectorAll('.src')].map((e) => { const [x, y] = center(e); return [x - hr.left, y - hr.top]; });
  const lines = [...document.querySelectorAll('.headline .l1, .headline .l2')].map((e) => { const r = e.getBoundingClientRect(); return { x: r.left - hr.left, y: r.top - hr.top + r.height * .12, w: r.width, h: r.height * .7 }; });
  return { W: hr.width, H: hr.height, sources, lines, phone: !root.classList.contains('wide') && hr.width < 700 };
}

Then the hand. Every pointer move becomes a short segment; the smoke along it is pushed in the direction of the move and thinned, in steps small enough that a fast flick leaves no gaps. A finger only counts while it is down.

app.js — the pointer
hero.addEventListener('pointermove', (e) => {
  const hr = hero.getBoundingClientRect(), x = e.clientX - hr.left, y = e.clientY - hr.top;
  const p = last.get(e.pointerId); last.set(e.pointerId, [x, y, e.timeStamp]);
  if (down && down.id === e.pointerId && Math.hypot(x - down.x, y - down.y) > 10) down.moved = true;
  if (!p) return;
  if (e.pointerType === 'touch' && !(down && down.id === e.pointerId)) return;
  if (fx) { fx.input(); fx.hand(p[0], p[1], x, y, 1); }
}, { passive: true });
smoke.js — a hand through the smoke
_applyHand(x0, y0, x1, y1, s) {
  const f = this.fluid, ph = this.phone;
  const dx = x1 - x0, dy = y1 - y0, len = Math.hypot(dx, dy);
  const stepPx = ph ? 18 : 26, n = Math.min(8, Math.max(1, Math.ceil(len / stepPx)));
  const rv = .045 * (ph ? .55 : 1), re = .22 * (ph ? .55 : 1);
  // velocity per step: pointer px/step → sim velocity (cells/s scaled by the grid)
  const gain = 9 * s / n;
  for (let k = 1; k <= n; k++) {
    const x = x0 + dx * k / n, y = y0 + dy * k / n; const [u, w] = this._uv(x, y);
    f.splatVel(u, w, dx * gain, -dy * gain, rv);
    f.erase(u, w, re, Math.min(.5, .1 + len * .004) * s);
  }
}

A click or a tap on the empty hero (not on a link or a button) blows the smoke away from that spot: a radial push and a wide erase. The incense keeps feeding the smoke, so the hole slowly rolls closed again.

app.js — click or tap
hero.addEventListener('pointerup', (e) => {
  // a click or tap on the empty hero wafts the smoke away from that spot
  if (down && down.id === e.pointerId && !down.moved && !down.ui && e.timeStamp - down.t < 400 && fx) fx.waft(down.x, down.y);
  down = null; if (e.pointerType !== 'mouse') last.delete(e.pointerId);
}, { passive: true });
smoke.js — the waft
_applyWaft(x, y) {
  const [u, w] = this._uv(x, y), ph = this.phone;
  this.fluid.push(u, w, ph ? .5 : .9, 420);
  this.fluid.erase(u, w, ph ? .3 : .5, .8);
}

Before the first frame the smoke is simulated for five seconds' worth of steps, so the page opens on risen smoke instead of an empty sky.

app.js — start
if (!root.classList.contains('no-fx')) {
  fx = new SmokeFX(canvas);
  layoutFX();
  fx.prime(+(Q.get('pre') || 300));
  fx.render();
  fx.start();
}
Stage 2 · the real smokeOpen ↗
The hero with the WebGL smoke banked over the headline, lit warm by the embers, and a dark channel through the smoke where the cursor has just swept across thin air.

3 · The finish

A ring replaces the system cursor over the hero, so you see the size of the hand that moves the smoke. It is moved once per frame, never per event.

app.js — the ring cursor
function placeRing() {
  ringRaf = 0; if (!ringXY) return;
  ring.style.transform = `translate3d(${ringXY[0]}px, ${ringXY[1]}px, 0)`;
  ring.classList.toggle('fast', speed > 1.4);
}
function moveRing(x, y) { ringXY = [x, y]; if (!ringRaf) ringRaf = requestAnimationFrame(placeRing); }

A first-time visitor might not know the smoke reacts, so a ghost hand sweeps through "thin air." once, a quarter of a second after load, unless a real pointer moves first.

app.js — the ghost hand
function ghost() {
  if (!fx || pointerSeen || reduced.matches || paused || mode !== 'webgl') return;
  ghostOn = true; const hr = hero.getBoundingClientRect();
  fx.gust(sweepPath(), 1700, (p) => {
    if (!ghostOn) return;
    if (!p) { ghostOn = false; ring.classList.remove('ghost'); return; }
    ring.classList.add('ghost'); moveRing(p[0] + hr.left, p[1] + hr.top);
  });
}

The keyboard gets its own version: when a link or button gets keyboard focus, a puff blows the smoke off it, and the first CTA also sends a breeze through the headline.

app.js — the focus breeze
hero.addEventListener('focusin', (e) => {
  const t = e.target; if (!fx || !t.matches || !t.matches(':focus-visible') || !t.closest('a, button')) return;
  fx.input();
  const hr = hero.getBoundingClientRect(), r = t.getBoundingClientRect();
  fx.waft(r.left - hr.left + r.width / 2, r.top - hr.top + r.height / 2);
  if (t.closest('.ctas') && performance.now() - lastBreeze > 4000 && !reduced.matches && !paused) { lastBreeze = performance.now(); fx.gust(sweepPath(), 1100); }
});

Moving content needs a way to stop it (WCAG 2.2.2). Pause smoke stops the loop and is remembered for the next visit.

app.js — Pause smoke
pauseBtn.addEventListener('click', () => {
  paused = !paused; reflectPause();
  try { localStorage.setItem(KEY, paused ? '1' : '0'); } catch (e) {}
  if (paused && ghostOn) stopGhost();
  if (fx && mode === 'webgl' && !reduced.matches) fx.setPaused(paused);
});

Without WebGL 2, or when the GPU context is lost, the page switches to the CSS smoke from stage 1, and the pointer now moves its soft hole.

app.js — the CSS fallback
function toFallback() {
  mode = 'fallback'; root.classList.add('no-fx'); stopGhost();
  fx = new FallbackFX();
}

The loop sleeps when the tab is hidden or the hero is scrolled away. With reduced motion the page draws one settled frame, with a hand already passed through the headline so it reads, and never starts the loop.

app.js — sleep when nobody is looking
document.addEventListener('visibilitychange', () => fx && fx.setHidden(document.hidden));
new IntersectionObserver((es) => { for (const e of es) fx && fx.setVisible(e.isIntersecting); }).observe(hero);
app.js — reduced motion: one settled frame
const still = Q.has('still') || reduced.matches;
if (still) {
  // a settled frame with the headline readable: the smoke has risen and a hand has passed through the headline
  fx.prime(+(Q.get('pre') || 420), Q.get('hand') === '0' ? null : [sweepPath(), +(Q.get('sweep') || 50)]);
  fx.render(); fx.frozen = true;
}
Stage 3 · the finished heroOpen ↗
The finished hero: the ring cursor resting by the headline, the smoke parted around thin air., the embers glowing and Pause smoke in the corner.
Stage 3 · on a phoneOpen ↗
The hero on a phone: Out of / thin air. on two lines inside the smoke, a channel where a finger has dragged through it, the Discover the collection button and the three incense sticks below.

Make it yours

  • Your words are ordinary HTML: change the headline, keep the two spans if you want the phone layout to break it in two.
  • Your smoke is a handful of numbers in smoke.js: curl (swirl), buoy (how fast it rises), diss (how fast it thins), the grid rows. Each can be tried from the URL first, for example ?curl=30&buoy=40.
  • Accessibility is already there: every word is DOM, visible focus, 44 px targets, a pause control that is remembered, prefers-reduced-motion honoured, the loop asleep when nobody is looking, and a CSS fallback without WebGL 2.

Ashwood is a made-up brand. Everything on the page was made in code for this project: the smoke is a simulation, the incense is SVG, the embers are CSS.

The one idea worth keeping

Keep the words in the DOM and put only the smoke in WebGL, on a transparent layer slotted between the headline and the buttons by plain z-index. The canvas never holds a letter, so the page stays readable, selectable and accessible while the smoke does the talking.