Catalog / Recipe
Shader wash
A soft, noise-edged band in one of the page's own colours crosses the page once and is gone.
- Family
- page-load shader
- Status
- lab
- Used for
- Page loadHeroSection intro
- Size
- 3,506 of 3,600 bytes, gzipped
mount(element)The simplest load effect, and the recipe that carries the one-shot harness. Most of this file is the harness; the effect itself is the twelve lines of shader at the top.
Use
import { mount } from './shader-wash';
mount(document.body);
Mounted on the page it covers the viewport. Mounted on an element it covers that element.
Options
| Option | Default | Meaning |
|---|---|---|
duration |
900 | How long the band takes to cross, in ms |
strength |
0.5 | Peak opacity of the band |
width |
0.35 | Width of the band, as a share of the diagonal |
angle |
20 | Direction of travel in degrees. 0 is left to right |
rough |
0.12 | How far noise bends the edge |
tint |
0 | Which token colours it: 0 accent, 1 ink, 2 ground |
arrivalOnly |
true |
Play only when the reader arrived from outside the site |
maxPixelRatio |
1.5 | Ceiling on canvas pixels per CSS pixel |
maxPixels |
2,500,000 | Ceiling on canvas pixels in total |
duration falls back to the element’s --load-duration and the easing
comes from --load-ease. Colours come from --accent, --ink and
--ground.
Versions
PRESETS holds sets of options that belong together. One goes where the
options go: mount(document.body, PRESETS.Broad).
| Version | What it is |
|---|---|
| Wash | The defaults: an accent band at a slight angle |
| Ink sweep | A narrow, clean-edged band of the ink colour, straight across |
| Broad | A wide, ragged band, weaker, that is on the page for longer |
When it does nothing
With reduced motion or forced colours, without WebGL2, on a software renderer, on a reload, and on a visit from another page of the same site. In each case the page is simply the page: this recipe never covers or hides anything, so there is no state to fall back from.
Learned while building it
- A load effect plays over the page, never in front of it. A cover that dissolves needs either a flash of content or a cover in the markup before the script runs, which is a loader.
- “Once” needs no storage. A navigation of type
navigatewith no referrer, or one from another origin, is an arrival. That also keeps the effect out of same-site page transitions. - Never ask a shader whether it has compiled. Asking early moved first paint from 28 ms to 128 ms for a mid-weight shader. The parallel compile extension is polled instead.
- Remove the canvas, then release the context. The other order leaves a broken-image mark on screen for a frame.
- The strength has a ceiling that is about reading, not taste. At 0.85 the words under the band could not be read.
- Read a style attribute before removing it. In Chrome, a style set
through
.styleand then removed withremoveAttributeleavesstyle=""behind unless the attribute has been read in between. The harness reads it first, so a host is left exactly as it was found. Found by the browser check, with a baredivas the proof.
/**
* shader-wash
*
* A soft, noise-edged band in one of the page's own colours crosses the
* page once and is gone. The canvas is transparent before it and after
* it, so nothing is hidden when the script is late, slow, or never runs.
*
* Copy this file. It imports nothing.
*
* import { mount } from './shader-wash';
* mount(document.body);
*
* By default it plays only when the reader arrives from outside the
* site: not on a reload, and not from another page of the same site.
*/
export interface Options {
/** How long the band takes to cross, in ms. Falls back to --load-duration. */
duration: number;
/** Ceiling on canvas pixels per CSS pixel. */
maxPixelRatio: number;
/** Ceiling on canvas pixels in total. */
maxPixels: number;
/** Play only when the reader arrived from outside the site. */
arrivalOnly: boolean;
/** Direction of travel, in degrees. 0 is left to right. */
angle: number;
/** Width of the band, as a share of the diagonal. */
width: number;
/** How far noise bends the edge, as a share of the diagonal. */
rough: number;
/** Peak opacity of the band, 0 to 1. Above about 0.6 the words under it cannot be read. */
strength: number;
/** Which token colours the band: 0 accent, 1 ink, 2 ground. */
tint: number;
}
export const DEFAULTS: Options = {
duration: 900,
maxPixelRatio: 1.5,
maxPixels: 2_500_000,
arrivalOnly: true,
angle: 20,
width: 0.35,
rough: 0.12,
strength: 0.5,
tint: 0,
};
export interface Handle {
update(options: Partial<Options>): void;
destroy(): void;
}
// ---- the effect -------------------------------------------------------------
// p is centred and divided by the diagonal, so the band is the same shape
// on any box. `c` is where the band's leading edge is; it starts one
// width before the box and ends one width after, which is what makes the
// first and last frames empty. The last line fades the whole thing in
// and out over the first and last 8% as a second guarantee of that.
const FRAGMENT = `#version 300 es
precision highp float;
uniform vec2 uResolution;
uniform float uProgress,uSeed;
uniform vec3 uGround,uInk,uAccent;
uniform vec4 uDial;
uniform float uTint;
out vec4 o;
float h(vec2 p){p=fract(p*vec2(.1031,.1030)+uSeed);p+=dot(p,p.yx+33.33);return fract((p.x+p.y)*p.x);}
float n(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);
return mix(mix(h(i),h(i+vec2(1,0)),f.x),mix(h(i+vec2(0,1)),h(i+1.),f.x),f.y);}
void main(){
vec2 r=uResolution,p=(gl_FragCoord.xy-.5*r)/length(r);
float w=uDial.y,e=uDial.z*(n(p*6.)*.6+n(p*17.)*.4-.5);
float x=dot(p,vec2(cos(uDial.x),sin(uDial.x)))+.5+e;
float c=mix(-w,1.+w,uProgress);
float a=smoothstep(c-w,c,x)*(1.-smoothstep(c,c+w*.15,x));
a*=uDial.w*smoothstep(0.,.08,uProgress)*(1.-smoothstep(.92,1.,uProgress));
vec3 k=uTint<.5?uAccent:uTint<1.5?uInk:uGround;
o=vec4(k*a,a);
}`;
/** CSS tokens the shader gets as vec3 uniforms, with what stands in for a missing one. */
const TOKENS: readonly (readonly [uniform: string, property: string, fallback: string])[] = [
['uGround', '--ground', '#fff'],
['uInk', '--ink', '#000'],
['uAccent', '--accent', '#00f'],
];
const clamp = (value: number, low: number, high: number): number => Math.min(high, Math.max(low, value));
/** The effect's own uniforms, made safe: the panel can send any number. */
const uploadDials = (gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null, o: Options): void => {
gl.uniform4f(at('uDial'), (o.angle * Math.PI) / 180, clamp(o.width, 0.02, 1), clamp(o.rough, 0, 0.5), clamp(o.strength, 0, 1));
gl.uniform1f(at('uTint'), clamp(Math.round(o.tint), 0, 2));
};
/** The wash leaves nothing: it is a passage, not a place. */
const keepsStill = (_options: Options): boolean => false;
/** It starts empty, so there is no first frame to soften. */
const fadeInMs = (_options: Options): number => 0;
/** It passes over the content. */
const layerUnder = (_options: Options): boolean => false;
/** Nothing on the canvas beyond what the harness sets. */
const canvasStyle = (_options: Options): string => '';
/** One triangle that covers the canvas; the fragment shader does the rest. */
const VERTEX = `#version 300 es
void main(){gl_Position=vec4(vec2(gl_VertexID<<1&2,gl_VertexID&2)*2.-1.,0,1);}`;
const geometry = (gl: WebGL2RenderingContext, _options: Options): [mode: number, count: number] => [gl.TRIANGLES, 3];
/** No depth buffer: one pass, nothing in front of anything. */
const DEPTH = false;
/** Nothing to set up before the first frame. */
const prepare: Prepare = () => {};
/** Starts after the first paint, wherever the element is. */
const startsOnView = (_options: Options): boolean => false;
/**
* Named versions of the effect: sets of options that belong together.
* The first is the defaults. `mount(element, PRESETS.Broad)`.
*/
export const PRESETS: Record<string, Partial<Options>> = {
Wash: {},
'Ink sweep': { tint: 1, strength: 0.35, angle: 0, width: 0.2, rough: 0.05 },
Broad: { width: 0.6, rough: 0.25, strength: 0.4 },
};
// ---- HARNESS one-shot: begin ------------------------------------------------
//
// Identical in every one-shot shader recipe. `npm run check` fails if a
// copy differs, so change it in one recipe and copy it to the others.
//
// Above this line a recipe declares, by these names: Options (with
// duration, maxPixelRatio, maxPixels and arrivalOnly), DEFAULTS, Handle,
// VERTEX, FRAGMENT, TOKENS, uploadDials, keepsStill, fadeInMs,
// layerUnder, canvasStyle, geometry (the draw mode and vertex count),
// DEPTH (whether the context has a depth buffer and tests with it),
// prepare (the recipe's own setup after the program is in use, which
// may return a promise the start waits on) and startsOnView (whether
// nothing is made until the element enters the viewport).
//
// What it promises: it never throws, never hides or moves content, and
// changes nothing on the page until its first frame is ready. It never
// asks the GPU a blocking question where a non-blocking one exists.
// When the effect ends, or on destroy, nothing of it remains.
/** The recipe's setup, given the context, the uniform locations, the host and the options; a promise delays the start. */
type Prepare = (gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null, element: HTMLElement, options: Options) => void | Promise<void>;
const NOOP: Handle = { update() {}, destroy() {} };
/** One mount to an element. A second replaces the first. */
const mounted = new WeakMap<HTMLElement, Handle>();
/** The buffer size for a box measured in device pixels, under both ceilings. */
export function bufferSize(
deviceWidth: number,
deviceHeight: number,
pixelRatio: number,
maxPixelRatio: number,
maxPixels: number,
): [width: number, height: number] {
const area = deviceWidth * deviceHeight;
if (!(area > 0)) return [1, 1];
const scale = Math.min(1, maxPixelRatio / Math.max(pixelRatio, 0.01), Math.sqrt(maxPixels / area));
return [Math.max(1, Math.round(deviceWidth * scale)), Math.max(1, Math.round(deviceHeight * scale))];
}
/** True when this page load came from outside the site, and is not a reload or a history move. */
export function isArrival(type: string | undefined, referrer: string, origin: string): boolean {
if (type && type !== 'navigate') return false;
if (!referrer) return true;
try {
return new URL(referrer).origin !== origin;
} catch {
return true;
}
}
/** `color(srgb r g b)` as the browser serialises it, to three numbers in 0..1. */
export function parseSrgb(value: string): [number, number, number] | null {
const match = /color\(srgb\s+([-\d.e]+)\s+([-\d.e]+)\s+([-\d.e]+)/.exec(value);
if (!match) return null;
const channel = (text: string | undefined): number => Math.min(1, Math.max(0, Number(text) || 0));
return [channel(match[1]), channel(match[2]), channel(match[3])];
}
/** A CSS time, in ms. `null` for anything that is not one. */
export function parseTime(value: string): number | null {
const match = /^\s*(\d*\.?\d+)(ms|s)\s*$/.exec(value);
if (!match) return null;
return match[2] === 's' ? Number(match[1]) * 1000 : Number(match[1]);
}
/**
* Options over a base, keeping only what is the right type. A control
* panel, a message from another frame and a careless caller can all
* send anything, and none of them may reach the GPU unchecked.
*/
export function mergeOptions(base: Options, next: Partial<Options>): Options {
const merged: Record<string, unknown> = { ...base };
for (const [key, standard] of Object.entries(DEFAULTS)) {
const value: unknown = (next as Record<string, unknown>)[key];
if (typeof value === typeof standard && (typeof value !== 'number' || Number.isFinite(value))) merged[key] = value;
}
return merged as unknown as Options;
}
export function mount(element: HTMLElement | null, options: Partial<Options> = {}): Handle {
try {
return start(element, options);
} catch {
return NOOP;
}
}
function start(element: HTMLElement | null, explicit: Partial<Options>): Handle {
if (!element || typeof WebGL2RenderingContext === 'undefined' || typeof ResizeObserver === 'undefined') return NOOP;
// The one place motion is decided, so a host can override matchMedia.
const asks = (query: string): boolean => typeof matchMedia === 'function' && matchMedia(query).matches;
if (asks('(prefers-reduced-motion: reduce)') || asks('(forced-colors: active)')) return NOOP;
mounted.get(element)?.destroy();
const fromToken = parseTime(getComputedStyle(element).getPropertyValue('--load-duration'));
let options = mergeOptions(fromToken === null ? DEFAULTS : { ...DEFAULTS, duration: fromToken }, explicit);
if (options.arrivalOnly) {
const entry = performance.getEntriesByType?.('navigation')[0] as PerformanceNavigationTiming | undefined;
if (!isArrival(entry?.type, document.referrer, location.origin)) return NOOP;
}
const page = element === document.body || element === document.documentElement;
const under = layerUnder(options);
const canvas = document.createElement('canvas');
canvas.setAttribute('aria-hidden', 'true');
canvas.style.cssText = `position:${page ? 'fixed' : 'absolute'};inset:0;width:100%;height:100%;pointer-events:none;z-index:${under ? -1 : 2147483647};${canvasStyle(options)}`;
let gl: WebGL2RenderingContext | null = null;
let program: WebGLProgram | null = null;
let observer: ResizeObserver | null = null;
let clock: Animation | null = null;
let still: HTMLCanvasElement | null = null;
let seen: IntersectionObserver | null = null;
let hostStyle: string | null | undefined;
let frame = 0;
let timer = 0;
let slow = 0;
let last = 0;
let seconds = 0;
let done = false;
const locations = new Map<string, WebGLUniformLocation | null>();
const at = (name: string): WebGLUniformLocation | null => {
if (!locations.has(name)) locations.set(name, gl && program ? gl.getUniformLocation(program, name) : null);
return locations.get(name) ?? null;
};
/** Ends the effect. With `keep`, the last frame stays as a plain 2D canvas. */
const finish = (keep: boolean): void => {
if (done) return;
done = true;
cancelAnimationFrame(frame);
clearTimeout(timer);
observer?.disconnect();
seen?.disconnect();
clock?.cancel();
document.removeEventListener('visibilitychange', wake);
removeEventListener('pagehide', stop);
removeEventListener('pageswap', stop);
canvas.removeEventListener('webglcontextlost', stop);
if (keep && gl && canvas.isConnected) {
// Copied in the same task as the last draw: after the next
// composite the drawing buffer is gone.
still = document.createElement('canvas');
still.width = canvas.width;
still.height = canvas.height;
still.setAttribute('aria-hidden', 'true');
still.style.cssText = canvas.style.cssText;
still.dataset.state = 'rest';
still.getContext('2d')?.drawImage(canvas, 0, 0);
canvas.replaceWith(still);
} else {
// Removed first, then released: a lost canvas left in the page
// shows a broken-image mark.
canvas.remove();
}
if (gl) {
gl.deleteProgram(program);
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
canvas.width = canvas.height = 0;
gl = program = null;
if (!still) restoreHost();
};
const stop = (): void => finish(false);
const restoreHost = (): void => {
if (hostStyle === undefined) return;
// Read it before removing it. In Chrome, a style set through
// `.style` and removed before anyone has read the attribute leaves
// `style=""` behind, and "as it was" would be nearly true.
element.getAttribute('style');
if (hostStyle === null) element.removeAttribute('style');
else element.setAttribute('style', hostStyle);
hostStyle = undefined;
};
const tokens = (): void => {
if (!gl) return;
const probe = canvas.style;
for (const [uniform, property, fallback] of TOKENS) {
// Any colour syntax in, one serialisation out: mixing a colour
// with itself in sRGB makes the browser do the conversion.
probe.color = `color-mix(in srgb, var(${property}, ${fallback}) 100%, #000 0%)`;
const rgb = parseSrgb(getComputedStyle(canvas).color) ?? [0, 0, 0];
gl.uniform3f(at(uniform), rgb[0], rgb[1], rgb[2]);
}
probe.color = '';
};
const draw = (now: number): void => {
frame = 0;
if (done || !gl || !clock) return;
const progress = clock.effect?.getComputedTiming().progress ?? 1;
const gap = last ? now - last : 0;
last = now;
seconds += Math.min(gap, 100) / 1000;
// Four frames in a row slower than 15 a second: this is not a GPU worth keeping.
slow = gap > 66 ? slow + 1 : 0;
gl.uniform1f(at('uProgress'), progress);
gl.uniform1f(at('uTime'), seconds);
uploadDials(gl, at, options);
// Cleared first: a fragment that discards, above a horizon, must not
// show the last frame through the hole.
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
const [mode, count] = geometry(gl, options);
gl.drawArrays(mode, 0, count);
if (progress >= 1) finish(keepsStill(options));
else if (slow > 3) finish(false);
else frame = requestAnimationFrame(draw);
};
const resize = (entries: ResizeObserverEntry[]): void => {
const entry = entries[0];
if (done || !gl || !entry) return;
const ratio = devicePixelRatio || 1;
const exact = entry.devicePixelContentBoxSize?.[0];
const box = entry.contentBoxSize?.[0];
const cssWidth = box ? box.inlineSize : entry.contentRect.width;
const cssHeight = box ? box.blockSize : entry.contentRect.height;
// The device-pixel box is for snapping, not for scale: it is trusted
// only where it agrees with devicePixelRatio to within a pixel.
// Under DevTools device emulation it reports CSS pixels.
const snapped = exact && Math.abs(exact.inlineSize - cssWidth * ratio) < 2 && Math.abs(exact.blockSize - cssHeight * ratio) < 2;
const [width, height] = bufferSize(
snapped ? exact.inlineSize : cssWidth * ratio,
snapped ? exact.blockSize : cssHeight * ratio,
ratio,
options.maxPixelRatio,
options.maxPixels,
);
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
gl.viewport(0, 0, width, height);
}
gl.uniform2f(at('uResolution'), width, height);
gl.uniform1f(at('uPixelRatio'), width / Math.max(1, cssWidth));
if (!clock) {
// The clock is a Web Animation with nothing in it: the browser
// keeps the time and applies the easing token.
const easing = getComputedStyle(element).getPropertyValue('--load-ease').trim() || 'linear';
const timing = { duration: Math.max(options.duration, 1), fill: 'forwards' as const };
try {
clock = canvas.animate(null, { ...timing, easing });
} catch {
clock = canvas.animate(null, timing);
}
const fade = fadeInMs(options);
if (fade > 0) canvas.animate({ opacity: [0, 1] }, { duration: fade });
}
// Sizing clears the buffer, so draw before this frame is shown.
cancelAnimationFrame(frame);
draw(performance.now());
};
const attach = (): void => {
if (done || !gl) return;
if (under || !page) {
// The host becomes the canvas's box, and behind the content a
// stacking context too, so the canvas sits above its background
// and below everything in it.
hostStyle = element.getAttribute('style');
if (getComputedStyle(element).position === 'static') element.style.position = 'relative';
}
if (under) {
element.style.isolation = 'isolate';
element.prepend(canvas);
} else {
element.append(canvas);
}
canvas.dataset.state = 'running';
// In the document first: an element outside it has no computed style.
tokens();
observer = new ResizeObserver(resize);
try {
observer.observe(canvas, { box: 'device-pixel-content-box' });
} catch {
observer.observe(canvas);
}
};
const ready = (): void => {
if (done || !gl || !program) return;
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return stop();
gl.useProgram(program);
if (DEPTH) gl.enable(gl.DEPTH_TEST);
gl.uniform1f(at('uSeed'), Math.random());
// The recipe's own setup, which may wait: for a font, for a picture.
// A throw, sync or async, ends the effect with nothing shown.
const context = gl;
Promise.resolve()
.then(() => prepare(context, at, element, options))
.then(attach, stop);
};
const build = (): void => {
if (done || gl) return;
if (document.visibilityState !== 'visible') return;
document.removeEventListener('visibilitychange', wake);
gl = canvas.getContext('webgl2', {
alpha: true,
antialias: false,
depth: DEPTH,
stencil: false,
powerPreference: 'low-power',
// A software renderer would burn a weak machine's cores for decoration.
failIfMajorPerformanceCaveat: true,
});
if (!gl) return stop();
canvas.addEventListener('webglcontextlost', stop);
program = gl.createProgram();
for (const [type, source] of [[gl.VERTEX_SHADER, VERTEX], [gl.FRAGMENT_SHADER, FRAGMENT]] as const) {
const shader = gl.createShader(type);
if (!shader) return stop();
gl.shaderSource(shader, source);
gl.compileShader(shader);
gl.attachShader(program, shader);
gl.deleteShader(shader);
}
gl.linkProgram(program);
const parallel = gl.getExtension('KHR_parallel_shader_compile');
let waited = 0;
const poll = (): void => {
frame = 0;
if (done || !gl || !program) return;
waited += 1;
// With the extension, ask without blocking. Without it, give the
// driver two frames, then ask and take the wait. Asking early
// moved first paint by more than a second in testing.
if (parallel ? gl.getProgramParameter(program, parallel.COMPLETION_STATUS_KHR) : waited > 2) ready();
else frame = requestAnimationFrame(poll);
};
frame = requestAnimationFrame(poll);
};
function wake(): void {
build();
}
const handle: Handle = {
update(next) {
options = mergeOptions(options, next);
},
destroy() {
stop();
still?.remove();
still = null;
restoreHost();
if (mounted.get(element) === handle) mounted.delete(element);
},
};
mounted.set(element, handle);
addEventListener('pagehide', stop);
addEventListener('pageswap', stop);
document.addEventListener('visibilitychange', wake);
// After a paint, never before one: the page is on screen first, and
// creating a context is a blocking call of tens of milliseconds.
const begin = (): void => {
frame = requestAnimationFrame(() => {
frame = 0;
timer = window.setTimeout(build, 0);
});
};
if (startsOnView(options) && typeof IntersectionObserver === 'function') {
// Nothing is made until the element is in view: no context held
// below the fold, and the recipe prepares against the layout and
// fonts of the moment it starts.
seen = new IntersectionObserver((entries) => {
if (done || !entries.some((entry) => entry.isIntersecting)) return;
seen?.disconnect();
seen = null;
begin();
});
seen.observe(element);
} else {
begin();
}
return handle;
}
// ---- HARNESS one-shot: end --------------------------------------------------