# 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.

- Author: Dil Ahdan (https://dil.dev/)
- Published: 2026-09-22
- Updated: 2026-09-22
- Tags: WebGL, GLSL, Playwright, testing
- Canonical: https://dil.dev/notes/smoke-page-transitions-webgl/

## Key takeaways

- Smoke is one continuous field, not shapes: domain-warped value-noise fbm on a single full-screen quad, in raw WebGL 1.
- Two soft masks do the choreography: a bloom that grows from the click and a drain that shrinks around the pointer.
- Fetch the next page while the smoke fills, push the URL before swapping, and use two watchdogs, not one.
- `locator.click()` waited about 1.4s before clicking, so every timed check sampled late. Use `page.mouse.click()` at viewport coordinates.
- A class name is not proof the smoke is visible. Look at the frames, and assert pixels.

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.

## Quick answers

**How do you make a smoke page transition in WebGL?**

Draw one full-screen quad whose fragment shader computes domain-warped fbm noise, then multiply it by two masks: a radius that grows from the click point to fill the screen, and a radius that shrinks around the pointer to clear it. Swap the page content while the screen is fully covered.

**What is domain-warped fbm?**

Fractal Brownian motion sums several octaves of noise at doubling frequencies. Domain warping feeds the output of one fbm back in as an offset to the coordinates of another, which bends the pattern into the curling, folded shapes that read as smoke rather than blobs.

**How do you swap pages under a transition without a full reload?**

Fetch the next page's HTML while the cover animation plays, parse it with DOMParser, and replace the content container's innerHTML once both the fetch and the cover have finished. Push the new URL before mounting the view so code that reads location sees the new page.

**Why do Playwright timing checks run late after a click?**

locator.click() waits for actionability checks before clicking, which can block for over a second. If your clock starts before that call, every timed sample is late. Read the element's viewport rectangle and use page.mouse.click() at its centre, starting the clock immediately before.

**What should happen if the transition fails?**

Fall back to a normal navigation. Reduced motion, a missing WebGL context, a shader compile failure, a failed fetch or a watchdog timeout should all set location.href to the destination so the visitor always arrives.
