DIL.DEV
LOADING0%
DIL.DEV
← ALL NOTES

Smoke page transitions: domain-warped fbm from the click point, checked in real Chrome

A raw WebGL 1 shader blooms smoke from the click, swaps the page behind it without a reload, then drains toward the pointer. Plus the Playwright timing bug that made my checks sample 1.4s late.

WebGLGLSLPlaywrighttesting

A real frame of the smoke transition on dil.dev: pale domain-warped smoke draining away to reveal the Langley Lycans case study behind it.
A real frame captured in Chrome during the drain: the case study has already swapped in behind the smoke.

Every project link on this site plays a smoke transition. Smoke blooms from the exact point you clicked, fills the screen, the next page is swapped in behind it without a reload, and then the smoke drains away toward your pointer while the new content fades in. It is raw WebGL 1 in about 165 lines, no library, and it falls back to a plain navigation if any part of it fails.

This note covers the shader, the router that sequences it, and how I verified timing in a real browser. The most useful part is probably the last section, because my first verification run was measuring the wrong thing by 1.4 seconds.

Why the first version looked like blobs

The version before this one moved eleven round noise puffs in from the edges over 640ms and set location.href at 470ms, so the next page loaded cold after the effect had finished. The feedback was blunt: it looked nothing like smoke. Eleven discrete shapes will always read as shapes. Smoke is a continuous field with structure at every scale, so the rewrite drew one field across the whole screen and shaped where it was visible with masks.

The field: domain-warped value-noise fbm

The noise is value noise (a fract(sin(dot())) hash with a smoothstep fade, bilinearly interpolated), summed as fbm and then warped with itself. It is adapted from Omar Shehata's domain-warped fbm smoke:

glsl
float hash(vec2 n){return fract(sin(dot(n,vec2(12.9898,78.233)))*43758.5453);}
float noise(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);
  return mix(mix(hash(i),hash(i+vec2(1.,0.)),f.x),mix(hash(i+vec2(0.,1.)),hash(i+vec2(1.,1.)),f.x),f.y);}
float fbm(vec2 p){float v=0.,a=.5;for(int i=0;i<${octaves};i++){v+=a*noise(p);p=p*2.02+vec2(17.3,9.1);a*=.5;}return v;}

The warp is what turns noise into smoke. One fbm (q) offsets the coordinates of two more (w), and the density is fbm sampled at the warped position:

glsl
p+=(pointer-uv)*vec2(r.x/r.y,1.)*3.*pull;
float q=fbm(p-t*.08);
vec2 w=vec2(fbm(p+q+t*.12-p.x-p.y),fbm(p+q-t*.10));
float d=fbm(p+w);

The first line matters during the clear: pull rises to 0.6, dragging the whole domain toward the pointer, so the smoke looks sucked toward the cursor rather than just fading. The octave count is interpolated into the shader source at compile time: 6 octaves, or 4 on screens narrower than 700px.

Two masks: bloom and drain

The field is always there. Two soft-edged radii decide where you see it:

glsl
float edge=fbm(uv*6.+t*.2)*.35;
float R=fill*1.7;
float bloom=1.-smoothstep(R-.25-edge,R+edge*.2,dist(uv,origin));
float K=(1.-clear)*1.9;
float drain=1.-smoothstep(K-.3-edge,K+edge*.2,dist(uv,pointer));
float mask=bloom*drain;

bloom grows from the click origin as fill goes 0 to 1. drain shrinks around the pointer as clear goes 0 to 1. The edge term erodes both boundaries with noise so neither is ever a clean circle. dist corrects for aspect ratio so the bloom is round on a phone and a monitor alike.

One more line guarantees coverage. At full fill the density field still has thin gaps, and the page swap must never show through them:

glsl
alpha=max(alpha,mask*smoothstep(.6,1.,fill)*(1.-smoothstep(0.,.5,clear)));

That floor raises alpha to fully opaque inside the mask near the end of the fill and releases it early in the clear.

The JavaScript side

fill(origin) and clear(pointer) are promises over a requestAnimationFrame loop. Fill is 550ms with ease-out cubic, so smoke is visibly on screen within the first few frames. Clear is 1100ms with ease-in-out cubic, and it listens to pointermove so the drain point follows the mouse while it runs:

js
const follow = event => { state.pointer = toUv({ x: event.clientX, y: event.clientY }, viewport()); };
target?.addEventListener('pointermove', follow, { passive: true });
state.fill = 1;
return run(clearMs, easeInOutCubic, p => { state.clear = p; state.pull = p * 0.6; })

It degrades itself: device pixel ratio is capped at 1.5, a run below 20fps drops to 4 octaves, and a second slow run turns the effect off. A shader compile or link failure marks the smoke unavailable.

The router: fetch while filling, two watchdogs

The navigation is a pure function with every side effect injected, so it is unit-tested with fakes:

js
let watchdog = setTimer(() => { bailed = true; hardNavigate(url); }, watchdogMs);
try {
  const [page] = await Promise.all([fetchPage(url), smoke.fill(origin)]);
  clearTimer(watchdog);
  if (bailed) return 'hard';
  if (push) pushState(url, page.title);
  swap(page, url);
  track(url);
  watchdog = setTimer(() => { bailed = true; hardNavigate(url); }, watchdogMs);
  const clearing = smoke.clear(origin);
  setTimer(onReveal, revealDelay);
  await clearing;

Three decisions in there came from bugs:

  1. The fetch runs in parallel with the fill. The next page is usually parsed before the screen is covered.
  2. Two watchdogs, not one. The first version had a single 3000ms watchdog armed across the whole sequence. A slow fetch followed by a normal clear could fire it after the page had already swapped, reloading a page the visitor was already on. Now stage one covers fetch and fill and is cleared the moment both resolve; stage two covers the clear.
  3. pushState before swap. The case-study view reads location.search to decide which project to render. Swapping first rendered the previous project.

Content arrives by opacity only, staggered 60ms apart, starting 50ms after the clear begins. Anything else that fails (reduced motion, no WebGL, a fetch error, a throw in the swap, either watchdog) falls back to location.href.

Verifying it in real Chrome

The verification script drives headed Chrome through Playwright at 1440 x 900 and 390 x 844, clicks a project card, and snapshots the DOM and a screenshot at 300, 700, 1000 and 1900ms after the click: smoke canvas active at 300ms, page swapped behind full smoke at 700ms, content arriving at 1000ms, smoke gone and URL updated at 1900ms. Then it checks back navigation, console errors, and that reduced motion never activates the canvas.

Headed matters. A background or hidden tab throttles requestAnimationFrame, and a transition measured there is a different animation.

The first real run passed 2 of 13 checks, and the transition was fine. The clock was wrong:

  • locator.click() blocked for about 1.37s on Playwright's actionability wait. The clock started before the call, so every checkpoint sampled about 1.4s late.
  • The click landed near the bottom edge of the card. The smoke bloomed from the corner of the screen and only covered part of it.
  • boundingBox() returns document coordinates; page.mouse works in viewport coordinates. Combined with smooth scrolling still in flight, the next run clicked empty space.

The fix reads the rectangle in viewport space after scrolling settles, clamps it to the screen, and clicks with the raw mouse:

js
await page.mouse.move(cx, cy);
const start = Date.now();
await page.mouse.click(cx, cy);

What I'd do differently

  • Assert pixels, not classes. The timed checks read DOM state: whether the canvas has its active class, whether the new title is in the DOM. A final review found that with the original ease-in fill, the 300ms frame showed no smoke at all while the check still passed, because the class was on. I found it by opening the PNG. The fix changed the easing; the check still does not sample canvas pixels. It should read back coverage at each checkpoint and fail below a threshold.
  • Keep the smoke moving during a slow fetch. The render loop stops when the fill reaches 1. If the fetch takes longer than 550ms, the screen holds a frozen frame until it lands, or until the 3000ms watchdog navigates. A hold phase that keeps time advancing would cost a few lines.
  • Test under throttling after every timing change. The CPU-throttled run was measured once, before later rounds changed the easing and durations, and not repeated, so I cannot say how the current version behaves at 6x CPU throttle.
  • Kill smooth scrolling before the swap. html { scroll-behavior: smooth } animated the post-swap scroll to the top from thousands of pixels down, under the smoke. Scrolling to the target now uses behavior: 'instant'; it should have been the default from the first commit.

NOTES BY EMAIL

Get the next note in your inbox.

One email per note. No spam. Reply “stop” any time and you are off the list.