The 1 file this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.
Read it first
Install kit: "Silk wave"
Library page: https://astro.baysixmedia.com/components/silk-wave/
Files: 1 required, 1 supporting
Write each block below to the path named in its header, relative to the project root. A block marked (required) is one the component does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.
===== FILE (required): src/components/SilkWave.astro =====
---
/**
* Silk wave
*
* A stack of slowly drifting horizons, each one a smooth hill profile with
* light pooling under its crest — drawn by one fragment shader on a single
* full-screen triangle.
*
* Original work, and deliberately so. The reference this was built against
* produces its wave by displacing the vertices of a subdivided plane and
* interpolating a colour per vertex, which needs a small WebGL engine to
* carry it and is somebody else's code. This does the whole thing per pixel:
* each layer is a sum of three sines, the pixel asks how far it sits from
* that line, and the answer becomes light — soft above the crest, softer and
* longer below it. No mesh, no engine.
*
* The asymmetry is the effect. A symmetric band function draws stripes; a
* crest that throws light downward draws something that looks like a fold of
* cloth seen edge-on. Both were built here, and only the second one matches
* the recording.
*
* What it does that the reference does not: scales to the device pixel ratio,
* follows its own box rather than the window, holds a still frame under
* `prefers-reduced-motion`, stops when scrolled out of view, survives losing
* and regaining the WebGL context, and leaves a CSS gradient behind it so a
* browser with no WebGL — or no JavaScript at all — still gets something.
*
* Self-contained: no imports, no packages, no global stylesheet, no webfont,
* no image. Every colour and measurement is a `--sw-*` custom property.
*/
interface Props {
/** The deepest tone in the ramp, at the bottom of the fold. */
shade?: string;
/** The middle tone, and the one the field mostly reads as. */
base?: string;
/** The brightest tone, at the crest of a band. */
highlight?: string;
/**
* Text colour for slot content in `"section"` mode.
*
* The field is bright by default, so this is dark by default — the
* opposite of a dark backdrop's problem and the same mistake if it is
* left out.
*/
ink?: string;
/**
* How fast the field moves, as a multiple of the reference's pace. `1`
* drifts a band across in roughly twenty seconds.
*/
speed?: number;
/**
* How many crests fit across the width. Around `1` gives one long hill
* and part of another, which is what the reference does; `3` gives a
* tighter, more regular swell.
*/
frequency?: number;
/** How far a crest rises and falls, as a share of the box height. */
amplitude?: number;
/**
* How many horizons are stacked, 1 to 6. Each sits lower than the last
* and drifts a little faster, which is what gives the field depth.
*/
layers?: number;
/**
* How far the light reaches below a crest before it is back to the
* ground colour, as a share of the box height. This is the long, soft
* half of the effect.
*/
falloff?: number;
/**
* How wide the crest line itself is, as a share of the box height.
*
* This is a hard edge, not a gradient: the reference crosses a crest in
* about eight pixels of a 1348-tall frame, so the default is a little
* under half a percent. Raise it and the line dissolves into its own
* fade; at `0.05` there is no line left at all.
*/
crispness?: number;
/**
* How far the light saturates before it starts to fade.
*
* The light is computed brighter than white and clipped, so values above
* `1` give a flat band of pure highlight under each crest rather than a
* single bright line. It widens the white; it cannot brighten it.
*/
bloom?: number;
/**
* An optional slow sideways wander, off by default.
*
* Everything else in the motion is strictly vertical — crests rise,
* fall, swell and flatten without ever moving along the width, which is
* what the reference measures. This is the one control that adds lateral
* movement, for a field that should look wind-blown rather than
* breathing. Small values go a long way; `0.15` is already visible.
*/
sway?: number;
/** Which way the crests run, in degrees. `0` is level, as the reference is. */
angle?: number;
/**
* How it sits in the page.
*
* `"section"` is a block in the flow that holds its own slot content.
* `"layer"` fills the nearest positioned ancestor, behind content that
* already exists. `"page"` pins it to the viewport behind everything.
* The slot is ignored in the last two.
*/
mode?: "section" | "layer" | "page";
/** Height floor in `"section"` mode when the slot gives it none. */
minHeight?: string;
/**
* Cap on the backing-store scale. 2 is plenty, and it is a cap rather
* than a multiplier: this is a full-screen shader, and a 3x buffer on a
* phone is nine times the fragments for a difference nobody can see.
*/
maxPixelRatio?: number;
/** Added to the root. */
class?: string;
/** Root element id. */
id?: string;
}
const {
shade = "#0ea5e9",
base = "#38bdf8",
highlight = "#ffffff",
ink = "#0b2a3d",
speed = 1,
frequency = 0.9,
amplitude = 0.14,
layers = 4,
falloff = 0.15,
crispness = 0.006,
bloom = 1.12,
sway = 0,
angle = 0,
mode = "section",
minHeight = "100svh",
maxPixelRatio = 2,
class: className,
id,
} = Astro.props;
const layout = mode === "page" || mode === "layer" ? mode : "section";
/* Clamped rather than trusted: each of these is multiplied into a per-frame
uniform, and a NaN would blank the field rather than misdraw it. */
const num = (value: unknown, fallback: number, low: number, high: number) => {
const n = Number(value);
return Number.isFinite(n) ? Math.min(high, Math.max(low, n)) : fallback;
};
const safe = {
speed: num(speed, 1, 0, 8),
frequency: num(frequency, 0.9, 0.1, 12),
amplitude: num(amplitude, 0.14, 0, 0.6),
/* Whole layers only, and never more than the shader's loop. */
layers: Math.round(num(layers, 4, 1, 6)),
falloff: num(falloff, 0.15, 0.01, 1),
crispness: num(crispness, 0.006, 0.0005, 0.2),
bloom: num(bloom, 1.12, 1, 12),
sway: num(sway, 0, 0, 2),
angle: num(angle, 0, -360, 360),
maxPixelRatio: num(maxPixelRatio, 2, 1, 4),
};
const style = [
`--sw-shade:${shade}`,
`--sw-base:${base}`,
`--sw-highlight:${highlight}`,
`--sw-ink:${ink}`,
`--sw-min-height:${minHeight}`,
].join(";");
---
<div
class:list={["silk-wave", `silk-wave--${layout}`, className]}
id={id}
style={style}
data-silk-wave
data-sw-mode={layout}
data-sw-speed={String(safe.speed)}
data-sw-frequency={String(safe.frequency)}
data-sw-amplitude={String(safe.amplitude)}
data-sw-layers={String(safe.layers)}
data-sw-falloff={String(safe.falloff)}
data-sw-crispness={String(safe.crispness)}
data-sw-bloom={String(safe.bloom)}
data-sw-sway={String(safe.sway)}
data-sw-angle={String(safe.angle)}
data-sw-max-dpr={String(safe.maxPixelRatio)}
{...(layout === "section" ? {} : { "aria-hidden": "true" })}
>
{/*
Scenery, and marked as such twice over: out of the accessibility tree
and out of the way of the pointer. The reference leaves its canvas
clickable, which is a hit target over a hero for no reason.
Served with no width or height on purpose. Until the controller sizes
it, it is a 300x150 default with nothing in it and the root's own CSS
gradient shows through — which is exactly what a browser without WebGL
or without JavaScript is left with.
*/}
<canvas class="sw-canvas" data-sw-canvas aria-hidden="true"></canvas>
{layout === "section" && <div class="sw-content"><slot /></div>}
</div>
<script>
/*
* The field.
*
* One triangle big enough to cover the clip volume, and a fragment shader
* that decides a colour for every pixel in it. There is no geometry to
* subdivide, so there is nothing to crease, and the cost is the same
* whatever the band shape does.
*/
interface Instance {
destroy(): void;
}
/*
* A triangle rather than a quad, and it is not a micro-optimisation for
* its own sake: two triangles meet along a diagonal seam where the GPU
* runs the fragment shader twice on the same pixels, and one triangle
* that overhangs the viewport has no seam to run twice.
*/
const TRIANGLE = new Float32Array([-1, -1, 3, -1, -1, 3]);
const VERTEX_SHADER = `
attribute vec2 a_pos;
varying vec2 v_uv;
void main() {
v_uv = a_pos * 0.5 + 0.5;
gl_Position = vec4(a_pos, 0.0, 1.0);
}
`;
/*
* The whole component, in one function per pixel.
*
* Up to six horizons, each a sum of three sines at incommensurate
* wavelengths drifting at its own pace. For each one the pixel measures
* its signed distance from the line and turns it into light: a short
* exponential fade above the crest, a long one below. The layers are
* composited from the back forward over a vertical ground wash.
*
* `hash` is here for the dither at the end and nothing else — the field
* itself has no noise in it, which is why it stays laminar.
*
* No backticks anywhere in here: this is a template literal, and one in a
* comment ends the shader mid-sentence.
*/
const FRAGMENT_SHADER = `
#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
varying vec2 v_uv;
uniform vec2 u_res;
uniform float u_time;
uniform vec3 u_shade;
uniform vec3 u_base;
uniform vec3 u_highlight;
uniform float u_frequency;
uniform float u_amplitude;
uniform float u_layers;
uniform float u_falloff;
uniform float u_crispness;
uniform float u_bloom;
uniform float u_sway;
uniform float u_angle;
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}
void main() {
float aspect = u_res.x / max(u_res.y, 1.0);
float t = u_time;
/*
* The sampling frame, centred and aspect-corrected, then rotated by
* u_angle so the crests can tilt. x runs along a crest, y across the
* stack. The reference is level, so the default is 0.
*/
vec2 c = vec2(cos(radians(u_angle)), sin(radians(u_angle)));
vec2 rel = vec2((v_uv.x - 0.5) * aspect, v_uv.y - 0.5);
float x = rel.x * c.x + rel.y * c.y;
float y = rel.y * c.x - rel.x * c.y + 0.5;
/*
* The ground the crests sit on: a long vertical fade, deepest at the
* foot. Everything bright is painted over this.
*/
vec3 col = mix(u_shade, u_base, smoothstep(-0.55, 0.85, v_uv.y));
/*
* The whole component, and it is a stack of HORIZONS rather than a
* pattern of bands.
*
* Each layer is a smooth hill profile h(x) running the full width.
* The pixel asks how far below that line it sits, and the answer
* drives the light: a defined edge along the crest, then a long fade
* downward into the ground colour. That asymmetry is the effect —
* sharp on top, soft underneath, like a dune or a fold of cloth seen
* edge-on. A symmetric band function cannot produce it, which is what
* an earlier draft of this file got wrong: it drew even-sided stripes
* and the recording is not stripes.
*
* Three sines per layer at incommensurate frequencies, drifting at
* different rates, so a crest never repeats across the width and the
* stack never lines up with itself.
*/
float TAU = 6.2831853;
float k = u_frequency * TAU;
float spread = 1.05 / max(u_layers - 1.0, 1.0);
for (int i = 0; i < 6; i++) {
float fi = float(i);
/* A mask rather than a break: GLSL ES 1.0 wants a constant loop
bound, and a break on a uniform is not reliably compiled. */
float on = step(fi + 0.5, u_layers);
/*
* Where this layer's horizon sits, and it does not sit still: the
* whole line rises and falls on its own slow cycle.
*
* This is the third source of change and the most useful one, because
* neighbouring layers bob out of phase with each other. A lot of the
* picture changes while the frame-to-frame motion stays balanced —
* one layer rising as another falls reads as zero net drift, which is
* what the reference measures.
*/
float seat = 0.97 - fi * spread;
/* Lower layers change faster, which reads as nearer. */
/*
* Measured, not chosen. The reference's normalised rate of change
* is 0.370 per half-second — mean absolute frame difference over
* mean image contrast, with the title text's rows excluded so its
* static black does not inflate the denominator. These are scaled
* to land on that at speed 1, and were re-scaled once after the
* motion moved onto gain, which changes less per unit time.
*/
float pace = 0.47 + fi * 0.161;
/* An irrational-ish offset so no two layers share a phase. */
float ph = fi * 2.3999632;
/*
* Each layer gets its own wavelength, amplitude and reach, not
* just its own phase. With one shape repeated down the stack the
* crests stay parallel forever and the field reads as corrugated
* iron; with the wavelengths incommensurate they drift in and out
* of step, and where two crests come close their fades merge into
* the broad washes the reference has.
*/
float kf = k * (0.76 + fi * 0.29);
float amp = u_amplitude * (0.78 + fi * 0.17);
float reach = u_falloff * (0.8 + fi * 0.22);
/*
* The motion, and getting this wrong is what the first two builds
* of this file had in common.
*
* Advancing a phase linearly in time — sin(x * k + t * pace) —
* makes a TRAVELLING wave: the whole pattern slides sideways at
* pace / k forever. Block-matching the reference frame by frame
* says it does not do that. The best global shift between frames
* half a second apart is (0, 0) at every step across ten seconds,
* horizontally and vertically. It morphs in place.
*
* So each mode's phase WANDERS instead of advancing — it is itself
* a sine, so it returns — and each mode's amplitude breathes on a
* slower cycle of its own. The crests swell, flatten, lean and
* recover without the field ever going anywhere, which is what the
* recording shows.
*/
/*
* The motion, and the two builds before this one both got it wrong.
*
* Advancing a phase in time makes a TRAVELLING wave and the field
* slides sideways forever. Modulating the amplitudes of modes at
* DIFFERENT wavelengths is subtler and still wrong: it moves the
* composite peaks along x, which reads as sliding too.
*
* Block-matching says the reference does neither. Comparing frames
* half a second apart, the best-fitting shift never improves on no
* shift at all — the ratio is 1.0000 across the whole recording, so
* no translation whatsoever explains its motion.
*
* So each layer's spatial profile is fixed once and never moves.
* Only two things change with time: the height the whole line sits
* at, and how strongly the profile is expressed. Every crest stays
* at its own x forever and rises, falls, swells and flattens in
* place, which is a picture that cannot slide by construction.
*/
float prof =
0.58 * sin(x * kf + ph) +
0.30 * sin(x * kf * 0.53 + ph * 1.7) +
0.12 * sin(x * kf * 1.79 + ph * 2.9);
/*
* Gain carries almost all of the motion, and it is the one kind of
* change that cannot be mistaken for movement: scaling a fixed
* profile lifts every crest while every trough drops, at the same
* moment, in opposite directions. No single shift fits that.
*
* Strictly positive, so the profile is never inverted — a sign flip
* turns crests into troughs, which is a sideways change wearing a
* vertical disguise.
*/
float gain = 0.5 + 0.5 * sin(t * pace * 3.3 + ph * 0.9);
/*
* A little uniform rise and fall on top, and deliberately little.
* Moving a whole line vertically is ambiguous with moving it
* sideways whenever the line is sloped — the aperture problem — so
* a large bob measures as diagonal drift even though nothing is
* travelling. At 1.15 it cost a fifth of the non-translational
* score, and at 0.22 it still read as a pixel of vertical drift
* per step; this is what survived the measuring.
*/
seat += amp * 0.10 * sin(t * pace * 1.9 + fi * 2.05);
/* Optional, and off by default: the one control here that does
translate. It wanders rather than advances, so it still returns. */
float h = seat + amp * gain * prof
+ amp * u_sway * sin(x * kf * 0.31 + t * pace * 2.7 + ph);
/* Positive below the crest. */
float d = h - y;
/*
* Hard edge, flat core, long fade — in that order, and the
* order is the whole look.
*
* Read off the reference at full resolution: crossing a crest
* the luminance jumps 193 to 254 in about eight pixels of a
* 1348-tall frame, holds that near-white flat for a band, then
* falls away slowly over hundreds of pixels. Peak slopes of 18
* to 20 levels per pixel, with 10-90% transitions of 0 to 4px.
*
* A previous draft of this file faded both sides smoothly and
* measured 1.6 to 2.2 levels per pixel — about a tenth of the
* reference, and no defined line anywhere. That draft was
* written from frames downscaled to 640px wide, which is to say
* from images that had the edges resampled out of them before
* anyone looked. The sharpness was never missing from the
* reference; it was missing from the evidence.
*
* The plateau is not a third term. The light is computed
* brighter than white and clamped, so it saturates for a band
* below the crest and only then begins to fall — which is also
* why raising u_bloom widens the white rather than brightening
* it.
*/
float edge = smoothstep(-u_crispness, u_crispness, d);
float fall = exp(-max(d, 0.0) / max(reach, 0.002));
float lit = clamp(edge * fall * u_bloom, 0.0, 1.0);
col = mix(col, u_highlight, lit * on);
}
/*
* A quarter of a level of noise, and it earns its line. A field
* this smooth over this many pixels is exactly the case 8-bit
* output bands on, and the visible result is contour rings through
* the long fades. Dithering below the quantisation step breaks the
* rings without being visible itself.
*/
col += (hash(gl_FragCoord.xy) - 0.5) / 255.0;
gl_FragColor = vec4(col, 1.0);
}
`;
/** "#38bdf8" -> [0.22, 0.74, 0.97]. Returns null for anything else. */
function toRgb(value: string): [number, number, number] | null {
const hex = value.trim();
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(hex);
const long = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
if (short) {
return [
parseInt(short[1] + short[1], 16) / 255,
parseInt(short[2] + short[2], 16) / 255,
parseInt(short[3] + short[3], 16) / 255,
];
}
if (long) {
return [
parseInt(long[1], 16) / 255,
parseInt(long[2], 16) / 255,
parseInt(long[3], 16) / 255,
];
}
return null;
}
/*
* Colours arrive as custom properties so a consumer can restyle the field
* from CSS, but the shader needs numbers. Anything the parser cannot read
* as a hex — a named colour, an `oklch()`, a `var()` that resolved to
* nothing — falls back rather than uploading NaN, which would render
* black.
*/
function readColor(
styles: CSSStyleDeclaration,
name: string,
fallback: [number, number, number],
): [number, number, number] {
return toRgb(styles.getPropertyValue(name)) ?? fallback;
}
function compile(gl: WebGLRenderingContext, type: number, source: string) {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
gl.deleteShader(shader);
return null;
}
return shader;
}
function initSilkWave(root: HTMLElement): Instance | undefined {
/* Idempotent: booting twice is a no-op, not a second loop. */
if (root.dataset.swReady === "") return undefined;
const canvas = root.querySelector<HTMLCanvasElement>("[data-sw-canvas]");
if (!canvas) return undefined;
const read = (name: string, fallback: number) => {
const value = Number(root.dataset[name]);
return Number.isFinite(value) ? value : fallback;
};
const speed = read("swSpeed", 1);
const frequency = read("swFrequency", 0.9);
const amplitude = read("swAmplitude", 0.14);
const layers = read("swLayers", 4);
const falloff = read("swFalloff", 0.15);
const crispness = read("swCrispness", 0.006);
const bloom = read("swBloom", 1.12);
const sway = read("swSway", 0);
const angle = read("swAngle", 0);
const maxDpr = read("swMaxDpr", 2);
const controller = new AbortController();
const { signal } = controller;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
let gl: WebGLRenderingContext | null = null;
let program: WebGLProgram | null = null;
let buffer: WebGLBuffer | null = null;
let uniforms: Record<string, WebGLUniformLocation | null> = {};
let frame = 0;
let visible = true;
let time = 0;
let last = 0;
let width = 0;
let height = 0;
/*
* Everything that can fail, in one place, so a failure is a fallback
* rather than a half-built field. A context this old browser will not
* give, a shader it will not compile, a program it will not link:
* each of them leaves the CSS gradient showing and the canvas empty,
* which is the same thing a browser with no JavaScript gets.
*/
function build(): boolean {
gl = (canvas!.getContext("webgl", {
alpha: false,
antialias: false,
depth: false,
stencil: false,
/* A full-screen shader on a laptop battery. The integrated
GPU is the right one for this. */
powerPreference: "low-power",
}) ||
canvas!.getContext("experimental-webgl", {
alpha: false,
})) as WebGLRenderingContext | null;
if (!gl) return false;
const vs = compile(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
const fs = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
if (!vs || !fs) return false;
program = gl.createProgram();
if (!program) return false;
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
/* The shaders belong to the program once it is linked; keeping
our own references would leak them across a context restore. */
gl.deleteShader(vs);
gl.deleteShader(fs);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return false;
gl.useProgram(program);
buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, TRIANGLE, gl.STATIC_DRAW);
const attribute = gl.getAttribLocation(program, "a_pos");
gl.enableVertexAttribArray(attribute);
gl.vertexAttribPointer(attribute, 2, gl.FLOAT, false, 0, 0);
uniforms = {};
for (const name of [
"u_res",
"u_time",
"u_shade",
"u_base",
"u_highlight",
"u_frequency",
"u_amplitude",
"u_layers",
"u_falloff",
"u_crispness",
"u_bloom",
"u_sway",
"u_angle",
]) {
uniforms[name] = gl.getUniformLocation(program, name);
}
/* Read from the cascade, not from the props, so a consumer who
overrides `--sw-base` in their own stylesheet gets the field
they asked for rather than the one the markup was built with. */
const styles = getComputedStyle(root);
gl.uniform3fv(uniforms.u_shade, readColor(styles, "--sw-shade", [0.055, 0.647, 0.914]));
gl.uniform3fv(uniforms.u_base, readColor(styles, "--sw-base", [0.22, 0.741, 0.973]));
gl.uniform3fv(uniforms.u_highlight, readColor(styles, "--sw-highlight", [1, 1, 1]));
gl.uniform1f(uniforms.u_frequency, frequency);
gl.uniform1f(uniforms.u_amplitude, amplitude);
gl.uniform1f(uniforms.u_layers, layers);
gl.uniform1f(uniforms.u_falloff, falloff);
gl.uniform1f(uniforms.u_crispness, crispness);
gl.uniform1f(uniforms.u_bloom, bloom);
gl.uniform1f(uniforms.u_sway, sway);
gl.uniform1f(uniforms.u_angle, angle);
return true;
}
function resize() {
if (!gl) return;
const rect = root.getBoundingClientRect();
const dpr = Math.min(maxDpr, window.devicePixelRatio || 1);
const w = Math.max(1, Math.round(rect.width * dpr));
const h = Math.max(1, Math.round(rect.height * dpr));
if (w === width && h === height) return;
width = w;
height = h;
canvas!.width = w;
canvas!.height = h;
gl.viewport(0, 0, w, h);
gl.uniform2f(uniforms.u_res, w, h);
/* A resize with the loop stopped — reduced motion, or scrolled
out of view and back at a new width — still owes one frame, or
the field would be stretched or blank until something else
happened to draw it. */
if (!frame) draw();
}
function draw() {
if (!gl) return;
gl.uniform1f(uniforms.u_time, time);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
function tick(now: number) {
/*
* Clamped, and the clamp is the point. A tab restored after a
* minute in the background reports a minute of elapsed time, and
* an unclamped field would jump a minute's worth of drift in one
* frame. A sixteenth of a second is the most it will ever take.
*/
const delta = Math.min(now - last, 1000 / 15) / 1000;
last = now;
time += delta * speed;
draw();
frame = requestAnimationFrame(tick);
}
function stop() {
if (frame) cancelAnimationFrame(frame);
frame = 0;
}
function run() {
stop();
if (!gl) return;
/* Off-screen, or asked for stillness: draw the field once and
leave it standing. Not blank — a still field is the picture
without the motion, which is what the preference asks for. */
if (!visible || reduced.matches) {
draw();
return;
}
last = performance.now();
frame = requestAnimationFrame(tick);
}
if (!build()) {
/* Says so in the markup rather than only in the console, so the
state is visible to a test and to anyone inspecting it. */
root.dataset.swFallback = "";
controller.abort();
return undefined;
}
/*
* A lost context is the normal cost of a GPU reset, a driver update
* or a laptop switching graphics cards — not an exceptional case.
* Without these two handlers the field is blank until the next full
* page load.
*/
canvas.addEventListener(
"webglcontextlost",
(event) => {
event.preventDefault();
stop();
gl = null;
width = height = 0;
/* Drops the flag, which hides the canvas and puts the CSS
gradient back on screen. Without it the reader is left
looking at an opaque black rectangle until the context
comes back — see the note on the stylesheet rule. */
delete root.dataset.swReady;
},
{ signal },
);
canvas.addEventListener(
"webglcontextrestored",
() => {
if (build()) {
resize();
root.dataset.swReady = "";
run();
} else {
root.dataset.swFallback = "";
}
},
{ signal },
);
/* Its own box, not the window. A field in a column that changes width
while the window does not is the case a resize listener misses. */
const sizer = new ResizeObserver(() => resize());
sizer.observe(root);
const observer = new IntersectionObserver(
(entries) => {
visible = entries.some((entry) => entry.isIntersecting);
run();
},
{ rootMargin: "120px" },
);
observer.observe(root);
/* The preference can change while the page is open. */
reduced.addEventListener("change", run, { signal });
/*
* Size it, which also paints the first frame, and only then say it is
* ready. The order matters: the flag is what reveals the canvas, and
* revealing it before anything is on it shows one frame of opaque
* black.
*/
resize();
/* A previous boot on this element may have failed and left the fallback
flag behind. This one did not, so the flag has to go, or the root goes
on claiming it has no WebGL while the field runs on top of it. */
delete root.dataset.swFallback;
root.dataset.swReady = "";
run();
function destroy() {
stop();
observer.disconnect();
sizer.disconnect();
/*
* Delete what this instance made — the program and the buffer —
* and leave the context alone.
*
* An earlier version called `loseContext()` here, reasoning that
* handing the context back was the polite thing to do. It is not:
* a force-lost context never comes back on its own, and
* `getContext` on that canvas returns null from then on. The
* lifecycle check caught it — teardown looked clean, and the
* re-boot afterwards fell straight through to the no-WebGL
* fallback with the canvas permanently dead. That is what would
* have happened to any element surviving a transition.
*
* Nothing accumulates without it. A canvas the router replaces is
* collected with its context, and a canvas that survives hands
* the same context back to the next `getContext` rather than
* opening a second one.
*/
if (gl) {
if (buffer) gl.deleteBuffer(buffer);
if (program) gl.deleteProgram(program);
}
gl = null;
program = null;
buffer = null;
uniforms = {};
width = height = 0;
time = 0;
/* Back to the served state: an unsized canvas with nothing on it,
and the CSS gradient showing through again. */
canvas!.removeAttribute("width");
canvas!.removeAttribute("height");
delete root.dataset.swReady;
controller.abort();
}
/*
* Astro replaces the document on a client-side navigation. Cancel the
* loop, drop both observers, release the context, and clear the ready
* flag so a re-used element can boot again.
*/
document.addEventListener("astro:before-swap", destroy, { once: true, signal });
return { destroy };
}
const boot = () =>
document.querySelectorAll<HTMLElement>("[data-silk-wave]").forEach(initSilkWave);
/* Astro's own <script> is a deferred module, so the DOM is already parsed
by the time this runs. The readyState check is for the case where this
file is copied into a project that inlines it some other way. */
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
/* Fires on every view transition in a project using <ClientRouter />, and
never at all in one that does not. Registered once per module, so it
cannot accumulate. */
document.addEventListener("astro:page-load", boot);
</script>
<style>
.silk-wave {
box-sizing: border-box;
/*
* The fallback, and it is load-bearing rather than decorative.
*
* This is what the component looks like with no JavaScript, with
* JavaScript but no WebGL, with a blocklisted GPU, and for the frame
* or two before the canvas is sized. The reference has none of this
* and renders a blank white box in every one of those cases.
*
* Three soft diagonals over a two-stop ground: not the shader, but
* the same palette going the same way, which is the difference
* between a degraded field and a missing one.
*/
background-color: var(--sw-base);
background-image:
linear-gradient(
108deg,
transparent 12%,
color-mix(in srgb, var(--sw-highlight) 72%, transparent) 26%,
transparent 38%
),
linear-gradient(
108deg,
transparent 46%,
color-mix(in srgb, var(--sw-highlight) 46%, transparent) 60%,
transparent 72%
),
linear-gradient(162deg, var(--sw-shade), var(--sw-base) 58%, var(--sw-highlight));
}
.silk-wave--section {
position: relative;
min-block-size: var(--sw-min-height);
/* An isolated stacking context, so the canvas sits under this
component's own content and not under the host's. */
isolation: isolate;
color: var(--sw-ink);
}
/*
* Behind content that already exists. It fills the nearest positioned
* ancestor, which is the host's job to provide.
*/
.silk-wave--layer {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
}
/*
* The whole-page backdrop. `z-index: -1` puts it behind the page's normal
* flow without the page having to know it is there. The cost is that a
* host which makes `body` a stacking context will trap it, and that a
* `body` with its own opaque background will cover it — both are in the
* README rather than worked around.
*/
.silk-wave--page {
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
}
/*
* Hidden until there is a frame on it, and this is a fix rather than a
* flourish.
*
* The context is created with `alpha: false`, which means the canvas
* composites as OPAQUE BLACK whenever a context exists but nothing has
* been drawn — after teardown, while a context is lost, and for the frame
* between creating the context and the first draw. In every one of those
* the CSS gradient underneath is exactly what should be showing, and a
* black rectangle is what was showing instead. Found by taking WebGL away
* and looking at the result rather than at the computed styles, which
* reported the gradient present the whole time.
*
* Keyed on the ready flag, so the canvas is visible for precisely as long
* as it has something on it.
*/
.sw-canvas {
position: absolute;
inset: 0;
z-index: 0;
inline-size: 100%;
block-size: 100%;
display: none;
/* The field is scenery. It never takes a click meant for the content
above it, and it is out of the accessibility tree besides. */
pointer-events: none;
}
.silk-wave[data-sw-ready] .sw-canvas {
display: block;
}
.sw-content {
position: relative;
z-index: 1;
}
/*
* No reduced-motion rule here, and that is deliberate rather than
* missing. The animation is a render loop, not a transition, so the
* preference is read in script and the field is drawn once and left
* standing. A CSS rule could only hide it, which is the one outcome worth
* avoiding: a still field is the picture, and a hidden one is nothing.
*/
@media (forced-colors: active) {
/*
* Painted pixels do not answer a forced palette, and neither does a
* gradient — `background-image` is not replaced in forced colours. So
* both go, and the field steps aside rather than leaving a bright
* cyan wash under text that has been forced to CanvasText.
*
* The ready-flag selector has to be named here, not just `.sw-canvas`.
* `.silk-wave[data-sw-ready] .sw-canvas` is three selectors' worth of
* specificity and quietly beat the bare class, so the field went on
* painting for exactly the readers who had asked it not to. Measured
* under emulation, not read off the cascade.
*/
.sw-canvas,
.silk-wave[data-sw-ready] .sw-canvas {
display: none;
}
.silk-wave {
background-image: none;
background-color: Canvas;
}
.silk-wave--section {
color: CanvasText;
}
}
@media print {
/* A full-bleed field is a page of ink, and the canvas would not print
reliably anyway. The slot colour comes back with it. Both selectors,
for the same specificity reason as the forced-colours block. */
.sw-canvas,
.silk-wave[data-sw-ready] .sw-canvas {
display: none;
}
.silk-wave {
background-image: none;
background-color: transparent;
}
.silk-wave--section {
color: inherit;
}
}
</style>
===== END src/components/SilkWave.astro =====
===== FILE (supporting): src/components/SilkWave.md =====
# Silk wave
A stack of slowly drifting horizons, each one a smooth hill profile with
light pooling under its crest — drawn by one fragment shader on a single
full-screen triangle.
Catalog page: `/components/silk-wave`
## Purpose
For a hero, a section or a whole page that wants a ground with movement in it
— slow, ambient, nothing to watch.
Use [Dusk glow](./DuskGlow.md) instead if it does not need to move: that one
is a single CSS gradient and costs one paint. Use something else entirely if
the motion is meant to be noticed; this is scenery, and it is deliberately
too slow to hold attention.
## Files required
**`SilkWave.astro`** — one file, the whole component.
- No imports at all, not even a type.
- **No graphics library.** No three.js, no OGL, no PIXI. Raw WebGL 1.0 and
about eighty lines of GLSL, written for this component.
- No npm packages, no Tailwind, no CSS reset.
- No global stylesheet. Every colour and measurement is declared on the
component's own root element as a `--sw-*` custom property.
- No webfont and no image, so the component makes **no network request of any
kind**.
- The only assumption about the host is that Astro compiles `.astro` files.
`SilkWave.md` — this file. Documentation, not required to run.
## Basic usage
```astro
---
import SilkWave from "../components/SilkWave.astro";
---
<SilkWave>
<h1>Everything in one place</h1>
</SilkWave>
```
No client directive, no wrapper, no init call. It fills the screen by default
and your content sits on it.
## The three modes
| `mode` | What it is |
| :--- | :--- |
| `"section"` *(default)* | A block in the flow that **holds** its slot content. |
| `"layer"` | `position: absolute; inset: 0` — fills the nearest positioned ancestor, behind content that already exists. The slot is ignored. |
| `"page"` | `position: fixed; inset: 0; z-index: -1` — the whole-page backdrop. The slot is ignored. |
**`"layer"` needs a positioned ancestor.** Without one it will climb to the
nearest one it finds, or to the viewport.
**`"page"` has two ways to go wrong in a host**, both properties of
`z-index: -1` rather than of this component: an ancestor that makes itself a
stacking context traps it, and a `body` with its own opaque background paints
over it. `"section"` has neither problem.
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `shade` | `string?` | `"#0ea5e9"` | The deepest tone, at the foot of the ground wash. |
| `base` | `string?` | `"#38bdf8"` | The tone the field mostly reads as. |
| `highlight` | `string?` | `"#ffffff"` | The light that pools under a crest. |
| `ink` | `string?` | `"#0b2a3d"` | Text colour for slot content. `"section"` mode only. |
| `speed` | `number?` | `1` | Multiple of the default pace. `0` is a still field. |
| `frequency` | `number?` | `0.9` | How many crests fit across the width. |
| `amplitude` | `number?` | `0.14` | How far a crest rises and falls, as a share of the height. |
| `layers` | `number?` | `4` | How many horizons are stacked, 1 to 6. |
| `falloff` | `number?` | `0.15` | How far the light reaches below a crest. |
| `crispness` | `number?` | `0.006` | Width of the crest line itself, as a share of the box height. This is a hard edge. |
| `bloom` | `number?` | `1.12` | How far the light saturates to flat white before it starts to fade. Widens the white; cannot brighten it. |
| `sway` | `number?` | `0` | Optional sideways wander. Off by default; the motion is otherwise strictly vertical. |
| `angle` | `number?` | `0` | Which way the crests run, in degrees. `0` is level. |
| `mode` | `"section" \| "layer" \| "page"?` | `"section"` | See above. An unrecognised value falls back to `"section"`. |
| `minHeight` | `string?` | `"100svh"` | Height floor in `"section"` mode. |
| `maxPixelRatio` | `number?` | `2` | Cap on the backing-store scale. |
| `class` | `string?` | — | Added to the root element. |
| `id` | `string?` | — | Root element id. |
Colours are read from the **cascade**, not from the props, so overriding
`--sw-base` in your own stylesheet restyles the field without touching the
markup.
## How it is built
Each layer is a **horizon**: a smooth hill profile `h(x)` running the full
width, made of three sines at incommensurate wavelengths drifting at
different rates. Every pixel measures its signed distance from that line and
turns it into light — a short exponential fade above the crest, a long one
below. Layers are composited back to front over a vertical ground wash.
**A crest has three parts, in order: a hard step, a flat core, then a long
fade.** Read off the reference at full resolution, the luminance jumps 193 to
254 in about eight pixels of a 1348-tall frame, holds that white flat for a
band, then falls away over hundreds of pixels.
`crispness` is the width of the step, `bloom` is how far the white saturates
before the fade begins, and `falloff` is the length of the fade. The plateau
is not a separate term — the light is computed brighter than white and
clipped, which is why raising `bloom` widens the white rather than
brightening it.
Extract frames at full resolution when checking this against a recording. A
640px-wide frame from a 2550px source has had these edges resampled out of it
before you look.
### The motion does not travel
Each layer's profile is **fixed along the width and never moves**. Only two
things change with time: the height the whole line sits at, and a strictly
positive gain on its profile.
That is not a stylistic choice, it is what the reference does. Block-matching
its frames half a second apart, the best-fitting shift never improves on no
shift at all — so nothing in it translates, horizontally or vertically. Gain
is the one kind of change that cannot be mistaken for movement: scaling a
fixed profile lifts every crest while every trough drops, in opposite
directions at the same instant.
Advancing a phase in time instead gives a travelling wave that slides forever.
Modulating the amplitudes of modes at *different* wavelengths is subtler and
still slides, because it moves the composite peaks along the width. Both were
built here first and both measured as sliding.
`sway` is the one control that reintroduces lateral movement, and it is `0` by
default.
Each layer also gets its own wavelength, amplitude and reach rather than just
its own phase. With one shape repeated down the stack the crests stay
parallel forever and the field reads as corrugated iron; with the wavelengths
incommensurate they drift in and out of step, and where two crests come close
their fades merge into broad washes.
## Tuning it
In the order they change the most:
- **`falloff`** — how much of the box is lit. Under 0.08 the crests become
thin ribbons; over 0.3 the layers merge into a mostly-white field.
- **`crispness`** — 0.002 is a knife edge, 0.05 dissolves the line entirely.
- **`bloom`** — 1 gives a single bright line at each crest, 3 gives broad flat
bands of white.
- **`amplitude`** — 0.05 is a nearly flat sea, 0.3 is rolling hills.
- **`layers`** — fewer means more visible ground between crests.
- **`frequency`** — under 1 gives one long hill across the width, which is
what the reference does.
A warmer, tighter variant:
```astro
<SilkWave
shade="#7c2d64" base="#f0719a" highlight="#ffe3c7" ink="#3a1024"
speed={0.55} frequency={2.6} amplitude={0.26}
layers={6} falloff={0.3} crispness={0.85} angle={34}
/>
```
## What it does when things go wrong
This is the part the reference has none of, and it is most of why the
component is longer than the shader.
- **No WebGL, or no JavaScript at all.** The root carries a CSS gradient in
the same palette going the same way — three soft diagonals over a two-stop
ground. Degraded, not missing. The canvas is hidden until there is a frame
on it, so the gradient is what shows before boot, after teardown, and if a
context can never be had. A failed boot sets `data-sw-fallback` on the root.
- **A lost WebGL context** — a GPU reset, a driver update, a laptop switching
graphics cards. The component drops its ready flag (so the gradient comes
back), waits for `webglcontextrestored`, rebuilds and carries on.
- **Scrolled out of view.** An IntersectionObserver stops the loop, with a
120px margin so it is already running by the time it is on screen.
- **A tab in the background.** `requestAnimationFrame` stops on its own, and
the elapsed time is clamped to a sixteenth of a second per frame so a tab
restored after a minute does not jump a minute's worth of drift.
- **A resized box.** A ResizeObserver on the root, not a listener on the
window — so a field in a column that changes width while the window does not
still follows.
## Accessibility and preferences
- `prefers-reduced-motion: reduce` **draws one frame and leaves it standing**.
Not blank, and not hidden: a still field is the picture with the motion
taken out, which is what the preference asks for. The loop never starts, and
the preference is re-read if it changes while the page is open.
- The canvas is `aria-hidden` and `pointer-events: none`. In `"layer"` and
`"page"` modes the root is `aria-hidden` too.
- The component contributes nothing focusable. Anything focusable on a page
using it belongs to the slot.
- `ink` exists so the default is legible: the field is bright, so the default
ink is dark.
- `forced-colors: active` hides the canvas and falls back to `Canvas` and
`CanvasText`. Painted pixels do not answer a forced palette, and neither
does a CSS gradient.
- `@media print` does the same and returns the slot colour to inherited.
## Cost
One full-screen fragment shader: at most six layers of three sines each, so
eighteen `sin` calls and two `exp` calls per pixel at the default settings,
and no noise sampling at all. It held the display's full refresh rate at
1440 × 900 with room to spare, and the context is requested with
`powerPreference: "low-power"` so it stays on the integrated GPU.
The backing store is capped at 2× by default rather than following the device
pixel ratio upward — a 3× buffer on a phone is nine times the fragments for a
difference nobody can see.
## Relationship to the reference
Built after reviewing a WebGL background on 21st.dev. That component is the
Stripe hero gradient engine — a small third-party WebGL library that displaces
the vertices of a subdivided plane and interpolates a colour per vertex.
**None of it is reproduced here.** This is a different technique for the same
look: everything happens per pixel and there is no mesh at all. The shader,
the controller and the fallback are all written for this library.
The first build of this component got the *shape* wrong — it drew soft
diagonal bands, which is a reasonable reading of a still frame and the wrong
reading of the recording. Watching the frames in sequence shows horizons
travelling and morphing, with a defined top and a long underside. The band
model was thrown away rather than adjusted.
The palette is the same family — the reference's `#38bdf8` on white is a
Tailwind sky colour, which is where the defaults start from — but every
number that shapes the field is this component's and every one of them is a
prop.
===== END src/components/SilkWave.md =====
--- VERIFIED IN THE COMPONENT LIBRARY ---
Production-ready in the library.
Verified 2026-09-20 at 1920, 1440, 390, 320px.
Passed: full-page context, keyboard, no javascript, reduced motion, two instances, no overflow, declarations corroborated, lifecycle.
Library source fingerprint: sha256:52dfdffb1888989dc926896c38574cd9641166145052c83fdfd01256b2a4be54 — MATCHES the files below.
--- NOT PROVEN ---
- Nothing outstanding in the library's own checks.
--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.
--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.