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.
<div class="copy">
<p class="eyebrow">New · Cedar & 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.
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));
.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).
.copy { position: absolute; left: 0; right: 0; display: flex; flex-direction: column; align-items: center; text-align: center; }
.copy > * { position: relative; z-index: 1; }
.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.
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.
.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.
.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 i { position: absolute; border-radius: 50%; filter: blur(34px); opacity: .9; animation: drift 22s ease-in-out infinite alternate; }

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.
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); }`,
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.
_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.
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.
// 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.
// 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).
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.
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 });
_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.
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 });
_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.
if (!root.classList.contains('no-fx')) {
fx = new SmokeFX(canvas);
layoutFX();
fx.prime(+(Q.get('pre') || 300));
fx.render();
fx.start();
}

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.
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.
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.
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.
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.
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.
document.addEventListener('visibilitychange', () => fx && fx.setHidden(document.hidden));
new IntersectionObserver((es) => { for (const e of es) fx && fx.setVisible(e.isIntersecting); }).observe(hero);
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;
}


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 gridrows. 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-motionhonoured, 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.
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.