Skip to content
01 / 10Interaction5 min read

Dissolve

A page transition with no edge. The menu does not slide in or wipe across — a noise-warped front eats the page from one corner, and the boundary has structure at every scale.

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 is the version with a clip-path.

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

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

What the menu does instead is spend one smoothstep on a noise-displaced distance field. The result is a boundary that creeps, and the reason it feels physical rather than drawn is that the noise is added to the field and not to the threshold.

A field, not a shape

The mask starts as a distance from the corner the transition opens from. Normalising by the box's own diagonal is what keeps the front circular on an ultrawide instead of an ellipse — the aspect correction is one multiply and it is the difference between a shape that belongs to the effect and one that belongs to the window.

dissolve.frag.glsl
// ---- the dissolve mask ----
// A distance FIELD from the corner, normalised so it reaches 1.0 at the far
// corner whatever the box's shape. Correcting x by the aspect ratio is what
// keeps the front circular on an ultrawide instead of an ellipse.
vec2 dd = (uv - vec2(1.0)) * vec2(uAspect, 1.0);
float field = length(dd) / length(vec2(uAspect, 1.0));

// The front is a THRESHOLD on that field, and the noise is added to the field
// rather than to the threshold. That is the entire difference between an
// organic edge and a clip-path: every point on the boundary is displaced by
// its own local noise value, so the edge has structure at every scale fbm has.
float warp = (fbm(uv * uScale + uTime * 0.05) - 0.5) * uTurbulence;

// -0.35 → 1.35 rather than 0 → 1: the front has to start entirely off the near
// corner and finish entirely past the far one, or the reveal visibly begins
// mid-box and ends before the last pixel is covered.
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, which is what sells it as a moving edge
// rather than a fade
float rim = smoothstep(0.18, 0.0, abs(field + warp - front));
col += violet * 0.8 * rim;
The whole mask. Twenty lines, four of which are the comment explaining the fifth.

The threshold sweeps from -0.35 to 1.35 rather than from zero to one, and that is not a magic number: it is the turbulence budget. The front has to begin entirely off the near corner and finish entirely past the far one, because a warp of ±0.18 means the boundary is already ragged by that much before it has moved at all. Sweep 0→1 and the reveal visibly starts mid-box and finishes with a fringe of unconverted pixels in the far corner.

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 ~0.9 the front breaks into islands
rim0.18the luminous band riding the front — 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 is displaced by the same amount at the same moment, so the edge stays a circle and merely breathes. Perturb the field, 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 applied to a different quantity. Rather than shading by fbm(uv), the sample point is displaced by another fbm first — domain warping, one extra evaluation, and it is the entire reason the plasma curls instead of looking like a cloud texture.

dissolve.frag.glsl
// ---- the colour underneath: cold-violet domain-warped fbm ----
// Domain warping is the whole trick: rather than colouring by fbm(uv), the
// sample point itself is DISPLACED by another fbm first. One extra evaluation
// buys the curling, marbled structure that plain fbm never has.
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); // a lifted floor, so the lows are not black
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 was fine on the machine it was written on and produced a hard diagonal seam across the whole effect on a four-year-old laptop.

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 value at arguments in the tens of thousands, and a mobile or ANGLE-translated GPU simply does not have the mantissa for it. Dave Hoskins' hash-without-sine keeps every intermediate value small and renders identically on strong and weak hardware.

dissolve.frag.glsl
/**
 * "Hash without sine" — Dave Hoskins, MIT.
 *
 * The classic fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453) needs sin() to
 * stay precise for arguments in the tens of thousands. Weaker GPUs (older Intel
 * integrated parts, ANGLE translating to D3D on a laptop) lose precision exactly
 * there, and it shows up as a straight diagonal band across the effect — the
 * iso-line of that dot product where sin() falls off its precision cliff. Every
 * intermediate value here stays small, so it renders identically everywhere.
 */
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 does not. It is rendered at half resolution into a target and upscaled, because the five-octave fbm is the expensive part and nothing about a dissolving front needs per-pixel sharpness. And its turbulence is wired to scroll velocity at the moment you navigated, so leaving a page at speed produces a rougher edge 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 (T1)
  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 — lifts the darks a touch, like the old overlay.
  // Kept whisper-quiet: screen blending is loudest in the darks, and at the
  // plasma's reduced internal resolution anything stronger reads as TV static
  // on 1x monitors instead of film texture.
  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). An overlay that composites over live page content and gets this wrong grows a bright halo along exactly the edge the whole effect is about.

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

AlexanderSmith

Locating the studio25.2048°N · 55.2708°E