Catalog / Recipe
Palette resolve
A colour field anchored on the page's own tokens, in OKLCH, that plays once, ends as the ground colour exactly, and leaves.
- Family
- colour
- Status
- lab
- Used for
- Page loadHeroPage transitionSection intro
- Size
- 5,005 of 5,200 bytes, gzipped
mount(element)Colour as the thing that moves, in the one form that can end: it plays once and the page is its own colours afterwards. The field has no palette of its own. Its lightness is the ground’s, pushed; its hue is the accent’s, spread; so it is right on any skin whose tokens it can read, and wrong on none.
Use
import { mount } from './palette-resolve';
mount(document.body);
The element, or something above it, carries --ground, --accent
and --muted. The last is the weakest text colour on the page, and
it is what the guard reads.
How it moves
One number falls from 1 to 0 and scales lift and chroma together. At 0 the colour is the ground token: it survives the round trip through OKLab to the byte, and a dither of half a step cannot move a value that is already on a step. So the canvas’s removal is invisible, and the canvas is never faded out, because a half-transparent ground over the same ground comes back one step dark.
envelope |
Name | What happens |
|---|---|---|
| 0 | Drain | The whole field thins evenly to the ground. Nothing travels |
| 1 | Front | A warped edge sweeps the element; plain ground behind it |
| 2 | Band | A ribbon of colour crosses and leaves; ground ahead and behind |
| 3 | Converge | The hues close to the accent’s family, then that drains |
The guard
Body text in the ink colour is never at risk. The casualties are the
tokens tuned close to the 4.5 to 1 floor: muted text and accent links
drop under it at a lift of only 0.04. With guard on, the recipe reads
the muted token and caps the lift so the field’s luminance stays on
the right side of (Y_text + 0.05) x 4.5 - 0.05 on a light ground,
or its mirror on a dark one. On the neutral skin that leaves a lift of
about 0.02, which is honest: there is very little room on a near-white
ground. Where the field sits in a band with no small text, turn the
guard off.
Options
| Option | Default | Meaning |
|---|---|---|
envelope |
0 | Which envelope, as above |
chroma |
0.06 | OKLCH chroma. 0.014 is all every hue can reach on near-white; above 0.09 reads as a toy |
lift |
0.05 | How far lightness moves from the ground’s, in OKLab L |
spread |
0.18 | How far the hue strays from the accent’s, in turns either way |
scale |
350 | Size of one blob, in CSS px |
warp |
0.9 | How far the field is folded |
octaves |
2 | Layers of detail, 1 to 3 |
direction |
90 | Which way the front or band travels, in degrees |
softness |
0.12 | The front’s softness, as a share of the element |
band |
0.28 | The band’s width, as a share of the element |
drift |
0 | Cells a second the blobs slide while it plays |
dither |
true |
One step of noise on the output |
guard |
true |
Cap the lift from the muted token |
layer |
0 | 0 behind the content, 1 over with multiply, 2 over with screen |
seed |
3 | Which field |
duration, arrivalOnly, maxPixelRatio, maxPixels |
1100, true, 0.25, 2,500,000 |
As every one-shot shader recipe; the pixel ratio is a quarter on purpose |
Versions
| Version | What it is |
|---|---|
| Resolve | The defaults: a drain, guarded |
| Front | A warped edge sweeps it off, bottom to top |
| Ribbon | A narrow band crosses, at a low lift |
| Converge | A wide spread of hues closes to the accent’s, then drains |
| Unguarded | The guard off, a stronger lift and chroma, for a band with no small text |
None of them has been judged in motion on a real page, and none on a dark ground: a warm accent on a dark ground makes brown, and the dark theme wants its own numbers.
Where it must not go
- Behind small muted text or accent links with the guard off.
- Behind charts, tables or anything read by colour.
- Listings and card grids, where it tints the things being compared.
- As a continuous background. The whole point of the design is that it ends.
- Over the content (
layer1 or 2) on a page where images are evidence: it tints them for the duration.
What it does when things are missing
Without WebGL2, on a software renderer, under reduced motion or forced colours: nothing, and the page is as it was. When it ends, the canvas is removed and the context released. A missing token gets its stand-in: white ground, blue accent, and for the muted token the grey that just passes on white, so a page that has not named its weakest text gets almost no lift. The recipe stays in sRGB; a token outside sRGB is painted clipped, mid-effect only.
Learned while building it
- Convert the tokens once. The conversion to OKLab is the same
answer for every pixel; the vertex shader of the one triangle does
it three times a frame and hands the fragment
flatvalues. - Add to the ground’s Lab, do not replace it. A near-white ground is rarely neutral; the sweep sits on top of its own chroma, so the last frame is the ground and not a grey near it.
- Fit to gamut by sliding to grey. Clamping channels shifts the hue; sliding toward the grey of the same lightness keeps it.
- Margins on the moving edge. A front or band has to start wholly off one side and end wholly off the other, bend included, or the first and last frames are not what they claim.
/**
* palette-resolve
*
* A colour field whose origin is the page's own tokens: lightness
* starts at the ground's and is pushed by a lift, hue is the accent's
* plus a spread, chroma is a dial, all in OKLCH. One number falls from
* 1 to 0 over the run and scales lift and chroma together, so the last
* frame is the ground token to the byte, and then the canvas leaves.
* The page ends in its own colours with nothing left running.
*
* Copy this file. It imports nothing.
*
* import { mount } from './palette-resolve';
* mount(document.body);
*
* It reads `--ground`, `--accent` and `--muted`, the weakest text
* colour on the page, and by default caps its lift so that text keeps
* 4.5 to 1 against the field. It renders at a quarter of the pixels:
* a field this soft has no detail to lose.
*/
export interface Options {
/** How long the run takes, in ms. Falls back to --load-duration. */
duration: number;
/** Canvas pixels per CSS pixel. A quarter: the field has no detail, and the browser's upscale smooths it. */
maxPixelRatio: number;
/** Ceiling on canvas pixels in total. */
maxPixels: number;
/** Play only when the reader arrived from outside the site. */
arrivalOnly: boolean;
/** How it leaves: 0 drains evenly, 1 a warped front sweeps it off, 2 a band crosses and leaves, 3 the hues close to the accent's and then drain. */
envelope: number;
/** Colourfulness, as OKLCH chroma. 0.014 is the most every hue can reach on a near-white ground; above 0.09 reads as a toy. */
chroma: number;
/** How far lightness moves from the ground's, in OKLab L. Darker on a light ground, lighter on a dark one. 0 cannot move text contrast. */
lift: number;
/** How far the hue strays from the accent's, in turns either way. 0 is one hue; 0.5 is the whole wheel. */
spread: number;
/** Size of one blob, in CSS px. Never a share of the element. */
scale: number;
/** How far the field is folded over itself. 0 is round blobs; 1.5 is ribbons. */
warp: number;
/** Layers of detail, 1 to 3. More is wasted on a field this soft. */
octaves: number;
/** Which way the front or band travels, in degrees. 90 is bottom to top. */
direction: number;
/** The front's softness, as a share of the element. */
softness: number;
/** The band's width, as a share of the element. */
band: number;
/** How fast the blobs slide while it plays, in cells a second. 0 holds the shapes still under the moving envelope. */
drift: number;
/** One step of noise on the output, against terraces. Always on a dark ground. */
dither: boolean;
/** Cap the lift so the weakest text (--muted) keeps 4.5 to 1 against the field. */
guard: boolean;
/** Where the canvas sits: 0 behind the content, 1 over it with multiply (light grounds), 2 over it with screen (dark grounds). */
layer: number;
/** Which field. The same number is the same shapes. */
seed: number;
}
export const DEFAULTS: Options = {
duration: 1100,
maxPixelRatio: 0.25,
maxPixels: 2_500_000,
arrivalOnly: true,
envelope: 0,
chroma: 0.06,
lift: 0.05,
spread: 0.18,
scale: 350,
warp: 0.9,
octaves: 2,
direction: 90,
softness: 0.12,
band: 0.28,
drift: 0,
dither: true,
guard: true,
layer: 0,
seed: 3,
};
export interface Handle {
update(options: Partial<Options>): void;
destroy(): void;
}
// ---- the effect -------------------------------------------------------------
// The colour science runs once per frame, not once per pixel: the
// tokens are uniforms, so their conversion is the same answer for every
// pixel, and the full-screen triangle's vertex shader does it three
// times a frame and hands the fragment shader `flat` values. L is the
// sRGB transfer to linear light, B linear to OKLab (the matrices are
// the published ones; a cube root between them), Y relative luminance.
//
// vG is the ground in OKLab. vA holds the accent's hue, the largest
// lift the weakest text allows, and whether the ground is dark. The
// guard: on a light ground the field may not go darker than the
// luminance that keeps 4.5 to 1 against the muted token,
// (Yt + 0.05) * 4.5 - 0.05; on a dark ground not lighter than
// (Yt + 0.05) / 4.5 - 0.05. A grey of OKLab lightness l has luminance
// l cubed, so the cap in L is the cube root of that luminance against
// the ground's L. With the guard off the cap is 1, which is no cap.
const VERTEX = `#version 300 es
precision highp float;
uniform vec3 uGround,uAccent,uMuted;
uniform vec4 uMore;
flat out vec3 vG;flat out vec4 vA;
vec3 L(vec3 c){return mix(c/12.92,pow((c+.055)/1.055,vec3(2.4)),step(.04045,c));}
vec3 B(vec3 c){vec3 l=mat3(.4122215,.2119035,.0883025,.5363325,.6806996,.2817188,.0514460,.1073970,.6299787)*c;
l=sign(l)*pow(abs(l),vec3(1./3.));return mat3(.2104543,1.9779985,.0259040,.7936178,-2.4285922,.7827718,-.0040720,.4505937,-.8086758)*l;}
float Y(vec3 c){return dot(c,vec3(.2126,.7152,.0722));}
void main(){
gl_Position=vec4(vec2(gl_VertexID<<1&2,gl_VertexID&2)*2.-1.,0,1);
vec3 g=L(uGround),a=B(L(uAccent));vG=B(g);
float dk=step(vG.x,.5),yt=Y(L(uMuted));
float lim=dk>.5?pow(max((yt+.05)/4.5-.05,0.),1./3.)-vG.x:vG.x-pow(max((yt+.05)*4.5-.05,0.),1./3.);
vA=vec4(atan(a.z,a.y),uMore.x>.5?max(lim,0.):1.,dk,0.);
}`;
// H is an integer hash, N value noise with the quintic fade (its
// lattice cannot be seen when one cell spans a third of the element),
// F octaves of it normalised. P is OKLab to linear light, E linear to
// sRGB. Q fits a colour to the gamut by sliding it toward the grey of
// its own lightness, in linear light, just far enough that every
// channel fits: a plain clamp would shift the hue.
//
// main: q is the point in the field, drifted and warped. f and s are
// two samples of it, for hue and for lightness. d is the pixel's place
// along the direction of travel. The envelope gives k, 1 to 0, and sp,
// the share of the spread that is still open: drain is the same k
// everywhere; front is a soft step at a bent edge that crosses the
// element with margins wide enough that k is 1 everywhere at the start
// and 0 everywhere at the end; band is a Gaussian on the same edge;
// converge closes the spread first, then drains. The colour is the
// ground's Lab plus the lift and the sweep scaled by k, so at k = 0 it
// is the ground exactly, which survives the round trip to the byte.
// Over the content the output is rebased so its neutral is white
// (multiply) or black (screen). The dither is one step of interleaved
// gradient noise on the encoded value, last, scaled away as k goes to
// zero: a value a hair under a step, pushed down half a step, rounds
// to the step below, and the last frame must be the ground to the byte.
const FRAGMENT = `#version 300 es
precision highp float;precision highp int;
uniform vec2 uResolution;
uniform float uProgress,uTime,uPixelRatio;
uniform vec3 uGround;
uniform vec4 uField,uTone,uEnv,uMore;
uniform int uTerrain;
flat in vec3 vG;flat in vec4 vA;
out vec4 o;
float H(ivec2 c){uint h=uint(c.x)*0x9E3779B1u+uint(c.y)*0x85EBCA6Bu+uint(uTerrain)*0xC2B2AE35u;
h^=h>>15;h*=0x2C1B3C6Du;h^=h>>12;h*=0x297A2D39u;h^=h>>15;return float(h>>8)/16777216.;}
float N(vec2 p){vec2 i=floor(p),f=p-i,u=f*f*f*(f*(f*6.-15.)+10.);ivec2 c=ivec2(i);
return mix(mix(H(c),H(c+ivec2(1,0)),u.x),mix(H(c+ivec2(0,1)),H(c+ivec2(1,1)),u.x),u.y)*2.-1.;}
float F(vec2 p){float a=.5,s=0.,n=0.;for(int i=0;i<3;i++){if(i>=int(uField.y))break;
s+=a*N(p);n+=a;p=mat2(.8,.6,-.6,.8)*p*2.;a*=.5;}return s/n;}
vec3 P(vec3 c){vec3 l=mat3(1.,1.,1.,.3963378,-.1055613,-.0894842,.2158038,-.0638542,-1.2914855)*c;l=l*l*l;
return mat3(4.0767416,-1.2684380,-.0041961,-3.3077115,2.6097574,-.7034186,.2309699,-.3413194,1.7076147)*l;}
vec3 E(vec3 c){c=clamp(c,0.,1.);return mix(c*12.92,1.055*pow(c,vec3(1./2.4))-.055,step(.0031308,c));}
vec3 Q(vec3 l,float L){float g=L*L*L;vec3 d=l-g,k=mix((1.-g)/max(d,vec3(1e-6)),-g/min(d,vec3(-1e-6)),step(d,vec3(0.)));
return g+d*min(1.,min(k.x,min(k.y,k.z)));}
void main(){
vec2 r=uResolution,fc=gl_FragCoord.xy,q=fc/(uPixelRatio*uField.x)+uTime*uField.w;
if(uField.z>0.)q+=uField.z*vec2(F(q+vec2(3.1,7.7)),F(q+vec2(-5.3,1.9)));
float t=uProgress,e=t*t*(3.-2.*t),f=F(q),s=F(q+17.3),k,sp=1.;
float d=dot(fc/r-.5,vec2(cos(uEnv.y),sin(uEnv.y)))+.5;int m=int(uEnv.x+.5);
if(m==0)k=1.-e;
else if(m==1){float w=uEnv.z,g=mix(-3.*w,1.+3.*w,e)+w*f*.8;k=smoothstep(g-w,g+w,d);}
else if(m==2){float w=uEnv.w,g=mix(-3.*w,1.+3.*w,e)+w*f*.8,z=(d-g)/w;k=exp(-4.*z*z);}
else{sp=1.-smoothstep(0.,.5,t);k=1.-smoothstep(.5,1.,t);}
float lf=min(uTone.y,vA.y)*(vA.z>.5?1.:-1.)*k*(.6+.4*s),C=uTone.x*k*(.7+.3*f),h=vA.x+uTone.z*sp*f*6.2831853;
vec3 lab=vG+vec3(lf,C*cos(h),C*sin(h)),c=E(Q(P(lab),lab.x));
if(uMore.y>1.5)c=clamp((c-uGround)/max(1.-uGround,vec3(1e-4)),0.,1.);
else if(uMore.y>.5)c=clamp(c/max(uGround,vec3(1e-4)),0.,1.);
if(uTone.w>.5)c+=(fract(52.9829189*fract(dot(fc,vec2(.06711056,.00583715))))-.5)/255.*min(1.,k*4.);
o=vec4(c,1.);
}`;
/**
* CSS tokens the shader gets as vec3 uniforms, with what stands in for
* a missing one. The muted fallback is the grey that just passes 4.5
* to 1 on white, so a page that has not named its weakest text gets
* almost no lift rather than an unreadable one.
*/
export const TOKENS: readonly (readonly [uniform: string, property: string, fallback: string])[] = [
['uGround', '--ground', '#fff'],
['uAccent', '--accent', '#00f'],
['uMuted', '--muted', '#767676'],
];
type Quad = [number, number, number, number];
const clamp = (value: number, low: number, high: number): number => Math.min(high, Math.max(low, value));
/** The options as the shader reads them, made safe. The panel can send any number. */
export function dials(o: Options): { field: Quad; tone: Quad; env: Quad; more: Quad; terrain: number } {
return {
field: [clamp(o.scale, 60, 2000), clamp(Math.round(o.octaves), 1, 3), clamp(o.warp, 0, 2), clamp(o.drift, 0, 0.5)],
tone: [clamp(o.chroma, 0, 0.2), clamp(o.lift, 0, 0.2), clamp(o.spread, 0, 0.5), o.dither ? 1 : 0],
env: [clamp(Math.round(o.envelope), 0, 3), ((Number.isFinite(o.direction) ? o.direction : 90) * Math.PI) / 180, clamp(o.softness, 0.01, 0.5), clamp(o.band, 0.05, 0.8)],
more: [o.guard ? 1 : 0, clamp(Math.round(o.layer), 0, 2), 0, 0],
terrain: Math.abs(Math.round(o.seed)) % 65536,
};
}
const uploadDials = (gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null, o: Options): void => {
const d = dials(o);
gl.uniform4f(at('uField'), ...d.field);
gl.uniform4f(at('uTone'), ...d.tone);
gl.uniform4f(at('uEnv'), ...d.env);
gl.uniform4f(at('uMore'), ...d.more);
gl.uniform1i(at('uTerrain'), d.terrain);
};
/** Its end is the page in its own tokens; a frozen tint would be a background with a contrast obligation that never ends. */
export const keepsStill = (_options: Options): boolean => false;
/** Drain, front and converge start from a full tint, which must not appear in a single frame. A band starts on plain ground. */
export const fadeInMs = (options: Options): number => (Math.round(options.envelope) === 2 ? 0 : 100);
/** Behind the content unless laid over it with a blend mode. */
export const layerUnder = (options: Options): boolean => Math.round(options.layer) === 0;
/** Over the content it multiplies on a light ground or screens on a dark one; the shader rebases its neutral to match. */
export const canvasStyle = (options: Options): string => {
const layer = Math.round(options.layer);
return layer === 1 ? 'mix-blend-mode:multiply' : layer === 2 ? 'mix-blend-mode:screen' : '';
};
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.Front)`.
*/
export const PRESETS: Record<string, Partial<Options>> = {
Resolve: {},
Front: { envelope: 1 },
Ribbon: { envelope: 2, lift: 0.03, band: 0.22 },
Converge: { envelope: 3, spread: 0.4 },
Unguarded: { guard: false, lift: 0.08, chroma: 0.08 },
};
// ---- 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 --------------------------------------------------