Skip to content
01 / 10Interaction6 min read

Dissolve

Every transition on this site is one shader eating the page from a corner. No slide, no wipe, no straight line anywhere. The whole trick is four lines, and it comes down to where you add the noise.

Used on this site

This is the same shader that runs when you open the menu from the button in the top right. Go on, open it, then come back.

Paused
Press 'Dissolve in'. Then drag turbulence to zero and press it again. That version is a clip-path, and you can feel the difference.

Every transition on this site is the same shader, and it has no edge at all.

A page transition needs a shape, and the usual answers are a slide, a wipe, or a fade. All three announce themselves as software. A slide has a straight leading edge. A wipe is straight everything. A fade has no edge to look at, which is why it reads as nothing happening, slowly.

What the menu does instead is spend one smoothstep on a noise-displaced distance field. Sounds fancy. It's four lines. The boundary creeps instead of sweeping, and the reason it feels like a material giving way rather than a shape being drawn comes down to one decision: the noise gets added to the field, not to the threshold. More on that below, because it's easy to put it in the wrong place and stare at the result wondering why it looks cheap.

A field, not a shape

The mask starts life as a plain distance from the corner the transition opens from. Normalise it by the box's own diagonal so the far corner lands at exactly 1, and correct x by the aspect ratio while you're in there. That one multiply is easy to skip, and skipping it ships broken: the front turns into an ellipse on any wide window, and you'll never notice on the square-ish window you develop in.

dissolve.frag.glsl
// the mask: distance from the corner, normalised so the far corner lands at 1.0.
// the aspect correction keeps the front circular on a wide window
vec2 dd = (uv - vec2(1.0)) * vec2(uAspect, 1.0);
float field = length(dd) / length(vec2(uAspect, 1.0));

// noise goes on the FIELD, not the threshold, so each point on the boundary
// is displaced by its own value and the edge picks up structure at every scale
float warp = (fbm(uv * uScale + uTime * 0.05) - 0.5) * uTurbulence;

// -0.35 → 1.35: the sweep overshoots both ends by the turbulence budget
float front = mix(-0.35, 1.35, uReveal);
float a = smoothstep(front + 0.15, front - 0.15, field + warp);

// a luminous rim rides the front
float rim = smoothstep(0.18, 0.0, abs(field + warp - front));
col += violet * 0.8 * rim;
The whole mask. Sixteen lines, and half of those are comments pointing at the other half.

Why does the threshold sweep from -0.35 to 1.35 instead of 0 to 1? Because the noise needs room. The warp pushes the boundary around by ±0.18 before the front has even started moving, so the sweep has to begin entirely off the near corner and finish entirely past the far one. Run it 0 to 1 and the reveal visibly starts mid-box, then ends with a fringe of pixels in the far corner that never convert. I think of the overshoot as the turbulence budget.

ConstantDefault
front range-0.35 → 1.35the sweep. Narrower and the ragged edge is clipped at both ends
smoothstep width±0.15how soft the boundary is. Below ±0.05 it aliases into stair-steps
turbulence0.36how far noise may push the edge. Above about 0.9 the front breaks into islands
noise scale4.0feature size of the raggedness. Low is big lobes, high is fine lace
rim0.18the luminous band riding the front, which is what sells it as a moving edge

Why the noise goes on the field

There are two places to put the noise, and only one of them works. Perturb the threshold, smoothstep(front + n, front - n, field), and every pixel on the boundary moves by the same amount at the same moment. The edge stays a circle. It just breathes a bit. Perturb the field instead and each point on the boundary is displaced by its own local noise value, so the edge inherits every scale the fbm has: continents from the first octave, foam from the fifth.

The colour underneath is the same trick pointed at a different quantity. Instead of shading by fbm(uv), displace the sample point with another fbm first. Domain warping. One extra evaluation per pixel, and it's the entire reason the plasma curls and folds instead of looking like a cloud texture.

dissolve.frag.glsl
// the plasma: domain-warped fbm (displace the sample with fbm, then shade with fbm)
vec2 q = vec2(fbm(uv * 2.2 + uTime * 0.05), fbm(uv * 2.2 + vec2(3.1, 1.7) - uTime * 0.045));
float n = fbm(uv * 2.4 + q * 1.8 + uTime * 0.025);

vec3 dark = vec3(0.05, 0.05, 0.075);
vec3 violet = vec3(0.42, 0.34, 0.85);
vec3 col = mix(dark, violet, smoothstep(0.22, 0.9, n));
col += violet * 0.5 * pow(max(n, 0.0), 3.0);
Two fbm evaluations to displace the sample, one to shade it.

The hash matters more than the noise

The first version of this shader used the hash everyone uses: fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453). It looked fine on the machine it was written on. On a four-year-old laptop it drew a hard diagonal seam across the entire effect.

Want to know why? That seam is the iso-line of the dot product where sin() falls off its precision cliff. The function is being asked for a precise answer at arguments in the tens of thousands, and a mobile GPU (or anything going through ANGLE on a laptop) doesn't have the mantissa for it. Dave Hoskins' hash-without-sine keeps every intermediate value small, so it renders the same everywhere.

dissolve.frag.glsl
// Dave Hoskins' hash-without-sine (MIT). The classic fract(sin(dot(...)) * 43758.5453)
// loses precision on weak GPUs and draws a diagonal seam across the effect. This doesn't.
float hash(vec2 p) {
  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
  p3 += dot(p3, p3.yzx + 33.33);
  return fract((p3.x + p3.y) * p3.z);
}
Three lines of arithmetic, and one class of hardware-specific bug that stops happening.

What ships

The version in the menu carries two things the demo doesn't. It renders at 75% of CSS resolution and ignores the device pixel ratio entirely, because five octaves of fbm per pixel is the expensive part and a soft dissolving front doesn't need retina sharpness. Nobody has ever noticed. And its turbulence is wired to your scroll velocity at the moment you navigate, so leaving a page at speed tears the edge harder than leaving it at rest.

src/lib/shaders.ts — MENU_PLASMA_FRAG
// ---- organic dissolve mask, creeping from the top-right corner ----
vec2 dd = (uv - vec2(1.0)) * vec2(uAspect, 1.0);
float field = length(dd) / length(vec2(uAspect, 1.0));   // 0 at top-right → 1 far
// organic drifting edge. turbulence scales with scroll energy at navigation
float warp = (fbm(uv*4.0 + uTime*(0.05 + uEnergy*0.14)) - 0.5) * (0.36 + uEnergy*0.5);
float front = mix(-0.35, 1.35, uReveal);
float a = smoothstep(front + 0.15, front - 0.15, field + warp);
// luminous violet rim riding the creeping front
float rim = smoothstep(0.18, 0.0, abs(field + warp - front));
col += violet * 0.8 * rim;

// screen-blended film grain, whisper quiet: louder reads as TV static
float gr = hash(gl_FragCoord.xy + uTime*40.0);
col = 1.0 - (1.0 - col) * (1.0 - gr * 0.022);

gl_FragColor = vec4(col * a, a);   // premultiplied alpha
The shipped mask. The demo above is this, with uEnergy replaced by a slider.

The alpha is premultiplied on the way out, vec4(col * a, a). Get that wrong on an overlay compositing over live page content and you grow a bright halo along exactly the edge this whole effect is about. No amount of tuning removes it, because it's a compositing bug, not a taste problem.

WebGL · Shader ·
← Back to the lab
Creative Web Designer & Developer25.2048°N · 55.2708°E

AlexanderSmith

Locating the studio25.2048°N · 55.2708°E