Component library
Back to catalog

Silk wave

Soft bands of colour folding across each other like light on water, drawn by one fragment shader on a single full-screen triangle.

backgroundwebglshaderanimationhero

Open preview in a new tab

Use this component

Three ways to get Silk wave into one of your own projects. The first two carry the component's files with them, so nothing has to fetch this page.

Start here

Placement prompt

The whole thing: what to do, where it goes, what must not change, and every one of the component's 1 file appended underneath with the path each belongs at. Paste it into Claude Code inside the destination project — it fetches nothing and needs nothing else.

Read it first — the files are added underneath when you copy
Add Silk wave from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/silk-wave/

Place it in:
[ENTER PAGE, FILE, OR ROUTE]

Position:
[ENTER WHERE IT SHOULD APPEAR]

Every file you need is appended below, each fenced with the path it belongs at. Write them there, relative to the project root. Do not import anything from the deployed library at runtime, and do not fetch the library page — the files below are the source of truth.

Adapt its demo content, links, colors, typography, and images to this project through documented props where possible. Preserve its responsive behavior, accessibility, interaction logic, reduced-motion behavior, and support for multiple instances. Avoid changing unrelated project code.

Test it at desktop, tablet, and mobile widths. Run the project's available check and build commands. Report the files changed and any differences from the library version. Do not commit or deploy.

Install kit

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.

Component reference

A fragment for a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving branding and content open. It does not carry the code.

Read it first
“Silk wave” from my Astro Component Library:
https://astro.baysixmedia.com/components/silk-wave/

Use the existing library component as the implementation source. Preserve its design structure, layout, proportions, spacing system, responsive behavior, visual effects, animations, transitions, hover states, interactions, and accessibility.

The copy, colors, typography, images, and other content may be adapted to match the destination website. Do not redesign, simplify, or reinterpret the component’s underlying design build or effects.

Component readiness

Judged on recorded evidence, not on how it looks in the frame above. A description of a component is not a result.

Interactivity
Ships script
Profile
core only
Installation files
1 required + 1 supporting
Verified
2026-09-20
Client navigation
End-to-end passed against a real ClientRouter
Source fingerprint
Matches the verified source
Production ready
Yes

Production-ready component

Every required check passed against the code below, and the recorded fingerprint still matches it.

Verified 2026-09-20 at 1920, 1440, 390, 320px

  • Full-page contextpass
  • Keyboard — declared to contain nothing focusablenot-applicable
  • No JavaScriptpass
  • Reduced motionpass
  • Two instancespass
  • No overflowpass
  • Declarations corroboratedpass
  • Lifecyclepass
32 verification notes
  • Every check below was run against the production build served by `astro preview`, not the dev server.
  • NOT rebuilt from the reference, and that is the first thing to say. The component this was reviewed against is the Stripe hero gradient engine — identified from its bundle by 17 of 20 signature tokens, among them MiniGl, u_vertDeform, u_waveLayers, u_darken_top, u_shadow_power, noiseFreq, noiseFlow, noiseSeed and incline, plus a `varying vec3 v_color` that gives away a colour interpolated per vertex across a subdivided plane. That is a third-party library carried inside a component, and there is no measuring a 20KB engine into existence the way a gradient or a flex ratio can be measured. So this is a different technique aimed at the same look: everything per pixel, no mesh, no engine, and none of the hard crease the reference shows where its displaced surface folds.
  • What the shader does: up to six horizons, each a sum of three sines at incommensurate wavelengths. Every pixel measures its signed distance from each line and turns it into light — a short exponential fade above the crest, a long one below — and the layers composite back to front over a vertical ground wash. No noise sampling anywhere in the field, which is why it stays laminar. One full-screen triangle rather than two, so there is no diagonal seam where the fragment shader would run twice over the same pixels.
  • THE MOTION WAS WRONG TWICE, and the second time it was wrong in a way that looked right. The first build advanced each wave's phase linearly in time, which is a travelling wave: the field slides sideways forever. The second replaced that with phase sway and amplitude modulation across modes of DIFFERENT wavelengths — which still slides, because changing the balance between wavelengths moves the composite peaks along x. Both were caught by block-matching rather than by watching.
  • The measurement that settled it. Comparing frames half a second apart and searching a shift window of plus or minus 14 by 6 pixels on a 256-wide sample, the reference's best-fitting shift NEVER improves on no shift at all: the ratio of best-shift residual to zero-shift residual is 1.0000 across the whole ten seconds. No translation of any kind explains its motion. The first build scored 0.79 on that ratio, the second 0.90.
  • So the shape of each layer is now fixed once and never moves along x. Only two things change with time: the height the whole line sits at, and a strictly positive gain on its profile. 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 moment, and no single shift fits that. Measured after the change: 0.9755 against the reference's 1.0000, with the best shift at (0,0) or within a pixel of it at every step.
  • The uniform vertical bob is deliberately small, and the reason is the aperture problem: moving a sloped line vertically is indistinguishable from moving it sideways, so a large bob measures as diagonal drift even though nothing travels. At 1.15 of the amplitude it cost a fifth of the non-translational score; at 0.22 it still read as a pixel of drift per step; it now sits at 0.10.
  • THE EDGES WERE WRONG, and the cause was the evidence rather than the code. Earlier drafts faded both sides of a crest smoothly and measured a peak luminance gradient of 1.6 to 2.2 levels per pixel. The reference measures 18 to 20, with 10-90% transitions of 0 to 4 pixels: genuinely hard lines. That had been missed because every frame examined up to then had been extracted at 640 to 900 pixels wide from a 2550-wide recording, which resamples the edges away before anyone looks at them. The sharpness was never absent from the reference; it was absent from the frames.
  • Read at full resolution, a crest has three parts in order: a hard step, a flat core, then a long fade. At x=700 the luminance goes 193 to 254 across about eight pixels of a 1348-tall frame, holds 254 flat for a band, then falls away over hundreds of pixels. The shader now does the same - a narrow smoothstep for the step, and light computed brighter than white and clipped so it saturates for a band before the exponential takes over. The plateau is not a third term; it is the clipping.
  • Measured after the change: peak gradients of 11.9 to 12.5 levels per pixel on a 900-tall canvas, which is 18 per pixel scaled to the reference's 1348 - inside its own 18 to 20 range. Saturated-white coverage 5.8% of the frame against the reference's 7.7%, with the title text's rows excluded from both.
  • The pace is measured too. The reference's normalised rate of change — mean absolute frame difference over mean image contrast, with the title text's rows excluded so its static black does not inflate the denominator — is 0.3701 per half-second. This component was tuned to it and measures 0.3589, within 3%. It was re-scaled three times along the way: once when the motion moved onto gain, which changes less per unit time than the phase advance it replaced, and again in both directions when sharpening the edges raised the image contrast and so moved the denominator.
  • The first build of this component was the WRONG SHAPE, and it was shipped as production-ready before that was caught. It drew soft diagonal bands: a defensible reading of a single still frame, and the wrong reading of the recording. Watching the frames in sequence rather than sampling four of them shows something else entirely — horizons travelling and morphing across the width, each with a defined top and a long underside. A band model cannot produce that at any setting, so it was thrown away rather than tuned, and the shader was rebuilt around horizons. The lesson is recorded here rather than quietly fixed: four frames sampled 2.5 seconds apart are a still life, not a motion study.
  • The horizon model then took three tuning passes, each failure visible rather than theoretical. First: the band count was multiplied into the coordinate feeding the noise, so every octave was high-frequency and the field rendered as smoke. Second, after separating them: `frequency` multiplied a raw projection, which at 3.2 came to 0.93 of a period across a 16:9 box — one boundary, not a field. It is now a COUNT of crests, normalised by how far the box reaches along the crest direction, so it means the same thing at any angle and any aspect ratio. Third: a short smoothstep along the crest gave it a defined edge and the field read as cut paper — the reference has no such line anywhere in it. Both sides of a crest now fade, and `crispness` is the RATIO between the reach above and below rather than the width of an edge.
  • Each layer gets its own wavelength, amplitude and reach rather than only 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.
  • `angle` is the direction the crests RUN, not the direction the light climbs, because that is the one a person setting it can picture. It defaults to 0 — level — which is what the reference is.
  • A real defect in teardown, found by the lifecycle check rather than by reading the code. `destroy()` called `loseContext()`, on the reasoning that handing the context back was the polite thing to do. It is not: a force-lost context never returns on its own, and `getContext` on that canvas answers null from then on. Teardown looked clean; the re-boot immediately after fell straight through to the no-WebGL fallback with the canvas permanently dead — which is what would have happened to any element surviving a transition. It now deletes its own program and buffer and leaves the context alone. Verified: teardown clears the ready flag and unsizes the canvas, `astro:page-load` boots it again and the field animates, and two further page-load dispatches leave exactly one loop running.
  • A second real defect, and this one was invisible in the computed styles. The context is created with `alpha: false`, which means the canvas composites as OPAQUE BLACK whenever a context exists but nothing has been drawn on it — after teardown, during a lost context, and for the frame between creating the context and the first draw. `getComputedStyle` reported the fallback gradient present the whole time; a screenshot showed a black rectangle. The canvas is now hidden until the ready flag is set, the flag is set only after the first frame is painted, and the context-lost handler drops it again. Verified by taking WebGL away and looking at the result: the CSS gradient renders, in the right palette, with the slot text readable on it.
  • A third defect, and the same specificity trap this library has hit before. The rule that reveals the canvas — `.silk-wave[data-sw-ready] .sw-canvas` — is three selectors' worth of specificity and quietly beat the bare `.sw-canvas` hide inside the forced-colours block, so the bright cyan field went on painting for exactly the readers who had asked for a forced palette. Both preference blocks now name both selectors. Caught under emulation, not by reading the cascade.
  • A fourth defect, small and cosmetic but still a lie in a documented attribute: `data-sw-fallback` was set when a boot failed and never cleared when a later boot succeeded, so a root that had recovered went on claiming it had no WebGL. Verified after the fix by taking WebGL away, confirming both roots report the fallback, restoring it and re-booting: both clear the flag and carry the ready flag instead.
  • Forced colours: canvas hidden, background-image none, background-color Canvas, colour CanvasText — observed on both instances under emulation. Print does the same and returns the slot colour to the document's inherited value, or the words print white on white.
  • Reduced motion draws one frame and leaves it standing. Measured under emulation: zero `requestAnimationFrame` callbacks scheduled across the page in 700ms, the canvas visible, the field painted, and two readbacks 600ms apart identical. Not blank and not hidden — the picture with the motion taken out.
  • Off-screen fields stop. Measured by reading the canvas back inside a frame callback, which is the only moment the drawing buffer is valid with `preserveDrawingBuffer: false`: the visible field changes between two reads 500ms apart and the off-screen one does not. An earlier attempt counted rAF callbacks by wrapping `window.requestAnimationFrame` and gave contradictory answers — screenshots proved a field was animating while the counter read zero — so the counter was abandoned for the readback rather than reported.
  • The backing store follows the device pixel ratio up to a cap, which the reference does not do at all: it renders at CSS pixels and is visibly soft on any retina display. Verified by faking a ratio of 3 and re-triggering the observer — the instance capped at 2 allocated a 2850px buffer for a 1425px box, the instance capped at 1 stayed at 1425, and both returned to 1x when the real ratio came back.
  • Without JavaScript the component degrades rather than disappearing, which is the other thing the reference has none of. The root carries a CSS gradient in the same palette going the same way, the canvas is hidden until it has a frame, and a boot that cannot get a context sets `data-sw-fallback` on the root instead of failing silently. Verified through the component's own boot path with `getContext` returning null for every WebGL variant.
  • Client navigation is recorded end-to-end-passed on a real ClientRouter round trip rather than on dispatched events. A link was followed to the destination route and the browser navigated back: both instances returned, both carried the ready flag, both canvases were sized, the field was painted and moving, there were zero duplicate ids, and nothing was left on `document.body`.
  • Two instances on one page are independent and differ in state rather than in identity: a different palette, and every shape control moved away from its default — crest count 2.6 against 0.9, amplitude 0.26 against 0.14, six layers against four, the light reaching twice as far below a crest and almost symmetrically around it, tilted 34 degrees off level — plus half the height and a pixel-ratio cap of 1 against the other's 2, so the fixture covers a field that does not scale to the display alongside one that does. Each holds its own WebGL context and its own loop.
  • Responsive at 1920x1080, 1440x900, 390x844 and 320x640, re-run after the shader was rebuilt. `scrollWidth === clientWidth` at every one, the slot content stays visible, and each canvas's backing store matches its own box times its own capped ratio at every size — the component has no breakpoint and no media query except the two preference blocks.
  • Keyboard is recorded not-applicable and the declaration licenses it: the component contributes nothing focusable. Measured on the fixture, both instances contain zero tab stops; the canvas is aria-hidden and takes no pointer events, and in layer and page modes the root is aria-hidden as well. The reference leaves its canvas `pointer-events: auto`, which is a hit target over a hero for no reason.
  • Install kit confirmed from the component's own page: two blocks, the component with its destination path and the README as supporting, both carried inline. No assets — no image, no webfont, no icon, no graphics library — so the component issues no network request of any kind.
  • Zero console messages and zero page errors across the preview route, the fixture route, both ClientRouter routes and the component page.
  • The demo copy is original to this library. The reference's canvas carries no text to reproduce, and its palette is a stock Tailwind sky colour on white.
  • Chromium only, and for this component that gap is wider than usual. WebGL is where engines differ most — precision qualifiers, `experimental-webgl` fallbacks, context-loss behaviour and the exact banding of a smooth ramp. No WebKit or Gecko build was available in this session and no claim is made about either. The shader targets WebGL 1.0 and GLSL ES 1.0 deliberately, which is the widest target available, and it guards `highp` behind `GL_FRAGMENT_PRECISION_HIGH`.

Open the verification fixtureOpen the ClientRouter fixture

src/components/library/SilkWave.astro

one fileNo imports, no companion files, no packages. Copy SilkWave.astro into any Astro project's components folder and render it.

---
/**
 * 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>