Component library
Back to catalog

Mesh gradient

Broad fields of colour bleeding into one another over a slowly warping plane, blended by inverse distance from a handful of drifting anchors. A background and nothing else: it ships no copy.

backgroundwebglshadergradientmeshbackdropblue

Open preview in a new tab

Use this component

Three ways to get Mesh gradient 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 Mesh gradient from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/mesh-gradient/

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: "Mesh gradient"
Library page: https://astro.baysixmedia.com/components/mesh-gradient/
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/MeshGradient.astro =====
---
/**
 * Mesh gradient
 *
 * Broad fields of colour bleeding into one another over a slowly warping
 * plane. A background and nothing else: it ships no copy — whatever belongs on
 * top goes in the slot.
 *
 * The blend is inverse-distance weighting over a handful of drifting anchors,
 * one per colour, which is what gives a mesh gradient its soft curved seams
 * and its bright cores. On top of that the sampling plane is swirled around
 * the centre and pushed about by a noise field, so the seams never sit still.
 *
 * Self-contained: no imports, no packages, no global stylesheet, no webfont,
 * no image, and no graphics library — raw WebGL 1.0 and about seventy lines of
 * GLSL written for this component. Every colour and measurement is a `--mgr-*`
 * custom property on the root.
 */

interface Props {
	/**
	 * How the background sits in the page.
	 *
	 * `"section"` is a block in the flow that holds its slot content.
	 * `"layer"` is `position: absolute; inset: 0` — it fills the nearest
	 * positioned ancestor and ignores the slot.
	 * `"page"` is `position: fixed; inset: 0; z-index: -1` — the whole-page
	 * background, and it ignores the slot too.
	 */
	mode?: "section" | "layer" | "page";
	/** The colours blended across the plane. Two to eight. */
	colors?: string[];
	/** How hard the noise field pushes the sampling plane about, 0 to 2. */
	distortion?: number;
	/** How much the plane twists around its centre, 0 to 2. */
	swirl?: number;
	/** Zoom. Above 1 pulls the field in, below 1 pushes it out. */
	scale?: number;
	/** Turns the whole field, in degrees. */
	rotation?: number;
	/** Slides the field sideways, in units of half the short side. */
	offsetX?: number;
	/** Slides the field up and down. */
	offsetY?: number;
	/** Drift rate. `1` is the default pace, `0` is a still field. */
	speed?: number;
	/**
	 * How sharply one colour gives way to the next. Low is a soft wash, high
	 * makes each anchor hold its own territory with a tight seam.
	 */
	falloff?: number;
	/** Height floor in `"section"` mode. */
	minHeight?: string;
	/** Text colour for slot content. `"section"` mode only. */
	ink?: string;
	/** Cap on the backing-store scale. */
	maxPixelRatio?: number;
	/** Added to the root element. */
	class?: string;
	/** Root element id. */
	id?: string;
}

const DEFAULT_COLORS = ["#073883", "#241d9a", "#50b1f7", "#4e8fd4"];

const {
	mode = "section",
	colors = DEFAULT_COLORS,
	distortion = 0.8,
	swirl = 0.1,
	scale = 1,
	rotation = 0,
	offsetX = 0,
	offsetY = 0,
	speed = 1,
	falloff = 2.6,
	minHeight = "100svh",
	ink = "#ffffff",
	maxPixelRatio = 2,
	class: className,
	id,
} = Astro.props;

/* An unrecognised mode falls back to the one that is safe anywhere. */
const layout = mode === "layer" || mode === "page" ? mode : "section";

const num = (value: unknown, fallback: number) =>
	typeof value === "number" && Number.isFinite(value) ? value : fallback;

const clamp = (value: number, min: number, max: number) =>
	Math.min(Math.max(value, min), max);

/*
 * Colours are validated here rather than in the controller. They are resolved
 * through the cascade and handed to the shader as floats; an unparseable one
 * would resolve to the inherited text colour instead of failing, which puts a
 * stray colour into the blend with nothing to show why.
 */
const COLOR = /^(?:#[0-9a-f]{3,8}|rgba?\([^()]*\)|hsla?\([^()]*\)|[a-z]{3,20})$/i;
const palette = (Array.isArray(colors) ? colors : [])
	.filter((c) => typeof c === "string" && COLOR.test(c.trim()))
	.map((c) => c.trim())
	/* The shader carries eight anchor slots. More than that is a silent
	   truncation either way, so it happens here where it can be explained. */
	.slice(0, 8);
const meshColors = palette.length >= 2 ? palette : DEFAULT_COLORS;

/* Clamped rather than trusted: these reach the shader, and a NaN uniform does
   not throw — it silently blanks the canvas. */
const config = {
	colors: meshColors,
	distortion: clamp(num(distortion, 0.8), 0, 2),
	swirl: clamp(num(swirl, 0.1), 0, 2),
	scale: clamp(num(scale, 1), 0.1, 10),
	rotation: num(rotation, 0),
	offsetX: clamp(num(offsetX, 0), -4, 4),
	offsetY: clamp(num(offsetY, 0), -4, 4),
	speed: clamp(num(speed, 1), 0, 10),
	falloff: clamp(num(falloff, 2.6), 0.5, 8),
	maxPixelRatio: clamp(num(maxPixelRatio, 2), 1, 4),
};

const style = [
	`--mgr-min-height:${minHeight}`,
	`--mgr-ink:${ink}`,
	/* The first three drive the no-script fallback, so it stands in the same
	   palette as the field it replaces. */
	`--mgr-c1:${meshColors[0]}`,
	`--mgr-c2:${meshColors[1 % meshColors.length]}`,
	`--mgr-c3:${meshColors[2 % meshColors.length]}`,
	`--mgr-c4:${meshColors[3 % meshColors.length]}`,
].join(";");
---

<div
	class:list={["mesh-gradient", `mgr-${layout}`, className]}
	id={id}
	style={style}
	data-mgr-root
	data-mgr-mode={layout}
	data-mgr-config={JSON.stringify(config)}
	aria-hidden={layout === "section" ? undefined : "true"}
>
	{/*
	   Two layers, only one visible at a time. The wash is a CSS gradient that
	   needs nothing; the canvas covers it once there is a frame on it. So the
	   background is never a blank rectangle while the context is being made,
	   and never blank if it cannot be.
	*/}
	<div class="mgr-wash" aria-hidden="true"></div>
	<canvas class="mgr-canvas" aria-hidden="true" data-mgr-canvas></canvas>

	{layout === "section" && (
		<div class="mgr-content">
			<slot />
		</div>
	)}
</div>

<style>
	.mesh-gradient {
		display: block;
		box-sizing: border-box;
		position: relative;
		overflow: hidden;
		/* The first colour, so the element is never a white flash before the
		   wash paints. */
		background: var(--mgr-c1);
		color: var(--mgr-ink);
		isolation: isolate;
	}

	.mesh-gradient *,
	.mesh-gradient *::before,
	.mesh-gradient *::after {
		box-sizing: border-box;
	}

	.mgr-section {
		display: grid;
		place-items: center;
		min-block-size: var(--mgr-min-height);
	}

	.mgr-layer {
		position: absolute;
		inset: 0;
	}

	.mgr-page {
		position: fixed;
		inset: 0;
		z-index: -1;
	}

	/*
	 * The fallback. Four broad radial pools in the same palette, which is a
	 * mesh gradient with the movement and the warping taken out — degraded,
	 * not missing. It shows before boot, after teardown, and where a WebGL
	 * context cannot be had at all.
	 */
	.mgr-wash {
		position: absolute;
		inset: 0;
		background:
			radial-gradient(70% 80% at 18% 22%, var(--mgr-c3) 0%, transparent 62%),
			radial-gradient(75% 85% at 82% 28%, var(--mgr-c2) 0%, transparent 64%),
			radial-gradient(85% 75% at 30% 88%, var(--mgr-c4) 0%, transparent 66%),
			var(--mgr-c1);
		pointer-events: none;
	}

	.mgr-canvas {
		position: absolute;
		inset: 0;
		inline-size: 100%;
		block-size: 100%;
		pointer-events: none;
		/* Hidden until there is a frame on it. The canvas is opaque — it paints
		   the whole field — so an empty one would be a black rectangle over the
		   wash rather than nothing. */
		visibility: hidden;
	}

	[data-mgr-ready] .mgr-canvas {
		visibility: visible;
	}

	[data-mgr-ready] .mgr-wash {
		opacity: 0;
	}

	.mgr-content {
		position: relative;
		z-index: 1;
		/* The slot decides its own width and padding. A background that imposed
		   a container on whatever is dropped into it would be a layout
		   component wearing a background's name. */
		inline-size: 100%;
	}

	/* ---- preferences ----------------------------------------------------- */

	@media (forced-colors: active) {
		/*
		 * Painted pixels do not answer a forced palette and neither does a CSS
		 * gradient, so both come off and the background becomes flat Canvas
		 * with whatever sits on it in CanvasText.
		 */
		.mgr-canvas,
		[data-mgr-ready] .mgr-canvas,
		.mgr-wash {
			display: none;
		}

		.mesh-gradient {
			background: Canvas;
			color: CanvasText;
		}
	}

	@media print {
		.mgr-canvas,
		[data-mgr-ready] .mgr-canvas,
		.mgr-wash {
			display: none;
		}

		.mesh-gradient {
			background: #fff;
			color: #000;
		}

		/* A fixed background would otherwise print on every page. */
		.mgr-page {
			display: none;
		}
	}
</style>

<script>
	/*
	 * Mesh gradient controller.
	 *
	 * One WebGL context per background. Booted idempotently so it survives a
	 * client-side navigation, and torn down on `astro:before-swap` so no
	 * context, observer or frame request outlives the page.
	 *
	 * NOTE FOR EDITORS: there is no backtick anywhere inside the shader
	 * strings. A backtick inside a template literal in an Astro <script> ends
	 * the literal and breaks the compiler, which has bitten this repository
	 * twice. Keep shader comments free of them.
	 */
	type Config = {
		colors: string[];
		distortion: number;
		swirl: number;
		scale: number;
		rotation: number;
		offsetX: number;
		offsetY: number;
		speed: number;
		falloff: number;
		maxPixelRatio: number;
	};

	type Instance = { destroy: () => void };

	const BOOTED = new WeakMap<HTMLElement, Instance>();

	/* The shader carries a fixed number of anchor slots because GLSL ES 1.0
	   will not size a uniform array at runtime. Unused slots are given zero
	   weight rather than a colour. */
	const MAX_ANCHORS = 8;

	const VERT = `
attribute vec2 a_pos;
void main() {
  gl_Position = vec4(a_pos, 0.0, 1.0);
}
`;

	const FRAG = `
precision highp float;

uniform vec2 u_res;
uniform float u_time;
uniform float u_distortion;
uniform float u_swirl;
uniform float u_scale;
uniform float u_rotation;
uniform vec2 u_offset;
uniform float u_falloff;
uniform int u_count;
uniform vec3 u_colors[8];

float hash(vec2 p) {
  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}

float vnoise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(
    mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),
    mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
    u.y
  );
}

float fbm(vec2 p) {
  float sum = 0.0;
  float amp = 0.5;
  for (int i = 0; i < 3; i++) {
    sum += amp * vnoise(p);
    p *= 2.03;
    amp *= 0.5;
  }
  return sum;
}

void main() {
  /* Normalised on the SHORT side and centred, so the field keeps its
     proportions instead of stretching with the viewport. */
  vec2 uv = (gl_FragCoord.xy - 0.5 * u_res) / min(u_res.x, u_res.y);

  uv = uv / u_scale - u_offset;

  float ca = cos(u_rotation);
  float sa = sin(u_rotation);
  uv = mat2(ca, -sa, sa, ca) * uv;

  /* Swirl: a rotation whose angle grows with the radius, so the middle turns
     further than the rim and the seams wind up into a spiral. */
  float r = length(uv);
  float twist = u_swirl * 3.0 / (1.0 + r * 2.5);
  float cs = cos(twist);
  float ss = sin(twist);
  uv = mat2(cs, -ss, ss, cs) * uv;

  /* Distortion: the sampling plane is pushed about by a slow noise field.
     This is what keeps the seams moving without the anchors themselves
     having to travel far. */
  vec2 warp = vec2(
    fbm(uv * 1.6 + vec2(0.0, u_time * 0.10)),
    fbm(uv * 1.6 + vec2(4.7, 2.3) - vec2(0.0, u_time * 0.08))
  ) - 0.5;
  uv += warp * u_distortion * 0.9;

  /*
   * Inverse-distance weighting over the anchors. Each colour owns a point on
   * a slow elliptical path; a pixel takes a weighted average of them by
   * 1/distance^falloff, which is what gives the soft curved seams and the
   * bright core at each anchor.
   */
  vec3 col = vec3(0.0);
  float total = 0.0;
  for (int i = 0; i < 8; i++) {
    if (i >= u_count) break;
    float fi = float(i);
    float phase = fi * 2.399963;
    vec2 anchor = vec2(
      cos(u_time * 0.21 + phase) * 0.62,
      sin(u_time * 0.17 + phase * 1.31) * 0.52
    );
    float d = distance(uv, anchor);
    float w = 1.0 / (pow(d, u_falloff) + 0.0012);
    col += u_colors[i] * w;
    total += w;
  }
  col /= max(total, 0.0001);

  /*
   * Anti-banding. A broad smooth ramp across near-flat colour crosses only a
   * few dozen 8-bit levels and steps into contour lines, which on a field
   * this large is the first thing anyone notices. Plus or minus half a level
   * dissolves them and cannot be seen as texture.
   */
  float n = hash(gl_FragCoord.xy + fract(u_time)) - 0.5;
  col += n / 255.0;

  gl_FragColor = vec4(col, 1.0);
}
`;

	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 parseColor(value: string): [number, number, number] {
		const probe = document.createElement("span");
		probe.style.color = value;
		document.body.appendChild(probe);
		const resolved = getComputedStyle(probe).color;
		probe.remove();
		const m = resolved.match(/[\d.]+/g);
		if (!m || m.length < 3) return [0, 0, 0];
		return [Number(m[0]) / 255, Number(m[1]) / 255, Number(m[2]) / 255];
	}

	function boot(root: HTMLElement) {
		if (BOOTED.has(root)) return;

		const canvas = root.querySelector<HTMLCanvasElement>("[data-mgr-canvas]");
		if (!canvas) return;

		let config: Config;
		try {
			config = JSON.parse(root.dataset.mgrConfig || "{}");
		} catch {
			return;
		}
		if (!Array.isArray(config.colors) || config.colors.length < 2) return;

		const attrs = {
			alpha: false,
			antialias: false,
			depth: false,
			stencil: false,
			powerPreference: "low-power" as const,
		};
		/* Cast over the whole expression: with `attrs` in a variable TypeScript
		   cannot resolve getContext's literal overload and falls back to the one
		   returning RenderingContext, which has none of the GL methods. */
		const gl = (canvas.getContext("webgl", attrs) ||
			canvas.getContext("experimental-webgl", attrs)) as
			| WebGLRenderingContext
			| null;

		if (!gl) {
			/* No context, ever. The wash stays and the background is complete
			   without the shader, so nothing is reported and nothing hidden. */
			root.dataset.mgrFallback = "";
			return;
		}

		const controller = new AbortController();
		const { signal } = controller;

		let program: WebGLProgram | null = null;
		let buffer: WebGLBuffer | null = null;
		const u: Record<string, WebGLUniformLocation | null> = {};

		let raf = 0;
		let running = false;
		let visible = true;
		let elapsed = 0;
		let last = 0;
		let dpr = 1;

		const motion = window.matchMedia("(prefers-reduced-motion: reduce)");

		function build() {
			if (!gl) return false;
			const vs = compile(gl, gl.VERTEX_SHADER, VERT);
			const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG);
			if (!vs || !fs) return false;

			const p = gl.createProgram();
			if (!p) return false;
			gl.attachShader(p, vs);
			gl.attachShader(p, fs);
			gl.linkProgram(p);
			gl.deleteShader(vs);
			gl.deleteShader(fs);
			if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
				gl.deleteProgram(p);
				return false;
			}
			program = p;
			gl.useProgram(program);

			/* One full-screen triangle, not a quad: three vertices instead of six
			   and no seam down the diagonal. */
			buffer = gl.createBuffer();
			gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
			gl.bufferData(
				gl.ARRAY_BUFFER,
				new Float32Array([-1, -1, 3, -1, -1, 3]),
				gl.STATIC_DRAW,
			);
			const loc = gl.getAttribLocation(program, "a_pos");
			gl.enableVertexAttribArray(loc);
			gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);

			for (const name of [
				"u_res",
				"u_time",
				"u_distortion",
				"u_swirl",
				"u_scale",
				"u_rotation",
				"u_offset",
				"u_falloff",
				"u_count",
			]) {
				u[name] = gl.getUniformLocation(program, name);
			}
			for (let i = 0; i < MAX_ANCHORS; i++) {
				u["c" + i] = gl.getUniformLocation(program, "u_colors[" + i + "]");
			}
			return true;
		}

		function resize() {
			if (!gl) return;
			const rect = canvas!.getBoundingClientRect();
			dpr = Math.min(window.devicePixelRatio || 1, config.maxPixelRatio);
			const w = Math.max(1, Math.round(rect.width * dpr));
			const h = Math.max(1, Math.round(rect.height * dpr));
			if (canvas!.width !== w || canvas!.height !== h) {
				canvas!.width = w;
				canvas!.height = h;
			}
			gl.viewport(0, 0, w, h);
		}

		function draw() {
			if (!gl || !program) return;
			resize();
			gl.useProgram(program);

			const count = Math.min(config.colors.length, MAX_ANCHORS);
			gl.uniform2f(u.u_res, canvas!.width, canvas!.height);
			gl.uniform1f(u.u_time, elapsed);
			gl.uniform1f(u.u_distortion, config.distortion);
			gl.uniform1f(u.u_swirl, config.swirl);
			gl.uniform1f(u.u_scale, config.scale);
			gl.uniform1f(u.u_rotation, (config.rotation * Math.PI) / 180);
			gl.uniform2f(u.u_offset, config.offsetX, config.offsetY);
			gl.uniform1f(u.u_falloff, config.falloff);
			gl.uniform1i(u.u_count, count);
			for (let i = 0; i < count; i++) {
				const [r, g, b] = parseColor(config.colors[i]);
				gl.uniform3f(u["c" + i], r, g, b);
			}

			gl.drawArrays(gl.TRIANGLES, 0, 3);

			delete root.dataset.mgrFallback;
			root.dataset.mgrReady = "";
		}

		function frame(now: number) {
			if (!running) return;
			/* Clamped, so a tab restored after a minute does not jump a minute
			   of drift in one step. */
			const delta = last ? Math.min((now - last) / 1000, 1 / 16) : 0;
			last = now;
			elapsed += delta * config.speed;
			draw();
			raf = requestAnimationFrame(frame);
		}

		function start() {
			if (running || motion.matches || !visible) return;
			running = true;
			last = 0;
			raf = requestAnimationFrame(frame);
		}

		function stop() {
			running = false;
			if (raf) cancelAnimationFrame(raf);
			raf = 0;
		}

		if (!build()) {
			root.dataset.mgrFallback = "";
			controller.abort();
			return;
		}

		draw();

		/* Off screen is not worth a frame. The margin means it is already
		   running by the time it is actually looked at. */
		const io =
			typeof IntersectionObserver === "function"
				? new IntersectionObserver(
						(entries) => {
							visible = entries.some((e) => e.isIntersecting);
							if (visible) start();
							else stop();
						},
						{ rootMargin: "120px" },
					)
				: null;
		io?.observe(root);

		/* On the canvas, not on the window: a background in a column that
		   changes width while the window does not still has to follow. */
		const ro =
			typeof ResizeObserver === "function"
				? new ResizeObserver(() => {
						if (!running) draw();
					})
				: null;
		ro?.observe(canvas);

		const onMotion = () => {
			if (motion.matches) {
				stop();
				draw();
			} else {
				start();
			}
		};
		motion.addEventListener?.("change", onMotion);

		canvas.addEventListener(
			"webglcontextlost",
			(event) => {
				event.preventDefault();
				stop();
				delete root.dataset.mgrReady;
			},
			{ signal },
		);
		canvas.addEventListener(
			"webglcontextrestored",
			() => {
				if (build()) {
					draw();
					start();
				}
			},
			{ signal },
		);

		if (!motion.matches) start();

		BOOTED.set(root, {
			destroy() {
				stop();
				controller.abort();
				io?.disconnect();
				ro?.disconnect();
				motion.removeEventListener?.("change", onMotion);
				if (gl) {
					if (program) gl.deleteProgram(program);
					if (buffer) gl.deleteBuffer(buffer);
				}
				/*
				 * The context itself is left alone on purpose. Calling
				 * loseContext() here bricks the canvas for any later boot, and a
				 * re-boot then falls through to the fallback for good.
				 */
				program = null;
				buffer = null;
				delete root.dataset.mgrReady;
				delete root.dataset.mgrFallback;
				BOOTED.delete(root);
			},
		});
	}

	function bootAll() {
		document
			.querySelectorAll<HTMLElement>("[data-mgr-root]")
			.forEach((root) => boot(root));
	}

	function teardownAll() {
		document.querySelectorAll<HTMLElement>("[data-mgr-root]").forEach((root) => {
			BOOTED.get(root)?.destroy();
		});
	}

	/* Astro's <script> is a deferred module, so the document is already parsed
	   and there is no DOMContentLoaded to wait for. */
	bootAll();

	document.addEventListener("astro:page-load", bootAll);
	document.addEventListener("astro:before-swap", teardownAll);
</script>

===== END src/components/MeshGradient.astro =====

===== FILE (supporting): src/components/MeshGradient.md =====
# Mesh gradient

Broad fields of colour bleeding into one another over a slowly warping plane.

**A background and nothing else.** It ships no copy. Whatever belongs on top
goes in the slot.

Catalog page: `/components/mesh-gradient`

## Copying it into another Astro project

Copy **`MeshGradient.astro`**. That is the whole component.

| What it needs | Status |
| :--- | :--- |
| Imports from this catalog | none |
| npm packages | **none** — no shader library |
| Global stylesheet | none — every value is a `--mgr-*` property on the root |
| Webfonts / images | none |
| Network requests | **none at all** |
| Host requirement | Astro compiles `.astro` files |

Raw WebGL 1.0 and about seventy lines of GLSL written for this component.

```astro
---
import MeshGradient from "../components/MeshGradient.astro";
---

<MeshGradient>
  <div class="hero"><h1>Whatever you want here</h1></div>
</MeshGradient>
```

No client directive and no wrapper. The controller boots itself.

## 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. The slot is ignored. |
| `"page"` | `position: fixed; inset: 0; z-index: -1` — the whole-page background. The slot is ignored. |

**`"layer"` needs a positioned ancestor.** Without one it climbs 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.

In `"layer"` and `"page"` the root is `aria-hidden` — in those modes it is
decoration with nothing in it.

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `mode` | `"section" \| "layer" \| "page"` | `"section"` | See above. |
| `colors` | `string[]` | four blues | The colours blended across the plane. Two to eight. |
| `distortion` | `number` | `0.8` | How hard the noise field pushes the plane about, 0–2. |
| `swirl` | `number` | `0.1` | How much the plane twists around its centre, 0–2. |
| `scale` | `number` | `1` | Zoom. Above 1 pulls the field in. |
| `rotation` | `number` | `0` | Turns the whole field, in degrees. |
| `offsetX` | `number` | `0` | Slides it sideways, in units of half the short side. |
| `offsetY` | `number` | `0` | Slides it up and down. |
| `speed` | `number` | `1` | Drift rate. `0` is a still field. |
| `falloff` | `number` | `2.6` | How sharply one colour gives way to the next. |
| `minHeight` | `string` | `"100svh"` | Height floor in `"section"` mode. |
| `ink` | `string` | `"#ffffff"` | Text colour for slot content. |
| `maxPixelRatio` | `number` | `2` | Cap on the backing-store scale. |
| `class` / `id` | `string` | — | Added to the root. |

Every numeric prop is clamped and every colour is validated. An unparseable
colour would resolve to the inherited text colour rather than failing, which
puts a stray colour into the blend with nothing to show why — so bad entries
are dropped and the palette falls back if fewer than two survive.

**More than eight colours are truncated.** The shader carries eight anchor
slots because GLSL ES 1.0 will not size a uniform array at runtime. The
truncation happens in the frontmatter where it can be explained, rather than
silently in the shader.

## How it is built

Each colour owns an **anchor** — a point on a slow elliptical path. Every pixel
takes a weighted average of the anchors by `1 / distance^falloff`, which is
inverse-distance (Shepard) interpolation. That is what produces a mesh
gradient's soft curved seams and the bright core at each anchor.

Before the blend, the sampling plane is:

1. scaled, offset and rotated by the props;
2. **swirled** — rotated by an angle that grows towards the centre, so the
   middle turns further than the rim and the seams wind into a spiral;
3. **distorted** — pushed about by a small fbm noise field, which is what keeps
   the seams moving without the anchors themselves having to travel far.

`falloff` is the one to reach for. Low values give a soft wash where every
colour is present everywhere; high values make each anchor hold its own
territory with a tight seam between them.

### The dither is anti-banding, not texture

A broad smooth ramp across near-flat colour crosses only a few dozen 8-bit
levels and steps into visible contour lines — on a field this large it is the
first thing anyone notices. The shader adds a per-pixel hash of **± half a
level**, which dissolves the steps and cannot be seen as texture.

Measured on the rendered output: **2,144 distinct colours**, with the two
commonest covering 1.8%, and the longest run of identical luminance along a
horizontal ray **9 pixels** (6 vertically). A visible band would be tens of
pixels wide.

## What it does when things go wrong

- **No WebGL, or no JavaScript.** The root carries a CSS wash — four broad
  radial pools in the same palette, which is a mesh gradient with the movement
  and the warping taken out. Degraded, not missing. The canvas is hidden until
  there is a frame on it, because it is opaque and an empty one would be a
  black rectangle over the wash. A failed boot sets `data-mgr-fallback`.
- **A lost context.** Drops the ready flag so the wash returns, waits for
  `webglcontextrestored`, rebuilds and carries on.
- **Scrolled out of view.** An IntersectionObserver stops the loop, 120px
  margin.
- **A resized background.** A ResizeObserver on the canvas, not the window.
- **A background tab.** `requestAnimationFrame` stops on its own, and elapsed
  time is clamped to a sixteenth of a second per frame.

## Accessibility

- The canvas and wash are `aria-hidden` and `pointer-events: none`. In
  `"layer"` and `"page"` the whole root is.
- The component contributes **nothing focusable**. Anything focusable on a page
  using it belongs to the slot.
- `prefers-reduced-motion: reduce` **draws one frame and leaves it standing** —
  measured at zero animation frames over 600ms with the field still visible.
- `forced-colors: active` removes the canvas and the wash and leaves flat
  `Canvas` / `CanvasText`. Painted pixels do not answer a forced palette and
  neither does a CSS gradient.
- `@media print` does the same, and drops `"page"` mode entirely so a fixed
  background does not print on every sheet.
- **Check contrast against your own palette.** These are broad bright fields;
  the default blues run from `#073883` to `#50b1f7`, so white slot text is
  comfortable but nothing here can guarantee that for a palette you supply.

## Relationship to the reference

Rebuilt from a published React component that wraps a third-party shader
package. None of it is reproduced and the package is not installed: the shader,
the blend and the controller are this component's own.

The default palette is the reference's four blues, converted from
`hsl(216 90% 27%)`, `hsl(243 68% 36%)`, `hsl(205 91% 64%)` and
`hsl(211 61% 57%)` to `#073883`, `#241d9a`, `#50b1f7` and `#4e8fd4`. Its
`distortion`, `swirl`, `scale`, `rotation`, `offsetX`, `offsetY` and `speed`
are carried across as props with the same names and the same defaults, so a
call written against the reference reads the same here.

It ships without the reference demo's headline, which is the point — this is a
background, and the copy belongs to the host.

**This is not a port.** The reference's shader is closed to me; what is matched
is the family of effect and the parameter surface, not an implementation. The
seams will not land in the same places.

===== END src/components/MeshGradient.md =====

--- VERIFIED IN THE COMPONENT LIBRARY ---
Production-ready in the library.
Verified 2026-09-21 at 1920, 1440, 1280, 1024, 834, 768, 600, 430, 390, 360, 320px.
Passed: full-page context, keyboard, no javascript, reduced motion, two instances, no overflow, declarations corroborated, lifecycle.
Library source fingerprint: sha256:1a4c360921a6a79cf312608f42451aa9797c10daa21355191fc05cfb3957e628 — 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
“Mesh gradient” from my Astro Component Library:
https://astro.baysixmedia.com/components/mesh-gradient/

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-21
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-21 at 1920, 1440, 1280, 1024, 834, 768, 600, 430, 390, 360, 320px

  • Full-page contextpass
  • Keyboard — declared to contain nothing focusablenot-applicable
  • No JavaScriptpass
  • Reduced motionpass
  • Two instancespass
  • No overflowpass
  • Declarations corroboratedpass
  • Lifecyclepass
23 verification notes
  • Rebuilt from a published React component that wraps a third-party shader package. None of it is reproduced and the package is not installed: the shader, the blend and the controller are this component's own. This library is Astro, so the shadcn structure, components/ui folder and npm install the prompt described do not apply to it.
  • This is not a port and does not claim to be. The reference's shader is closed; what is matched is the family of effect and the parameter surface, not an implementation. The seams do not land in the same places and are not expected to.
  • The parameter surface is carried across deliberately: distortion, swirl, scale, rotation, offsetX, offsetY and speed keep the reference's names and its defaults, so a call written against the reference reads the same here. falloff is additional and controls how sharply one colour gives way to the next.
  • The default palette is the reference's four blues, converted from hsl(216 90% 27%), hsl(243 68% 36%), hsl(205 91% 64%) and hsl(211 61% 57%). The conversion was done in a browser rather than by hand and comes out as #073883, #241d9a, #50b1f7 and #4e8fd4.
  • It ships no copy at all. The reference's demo bakes in a headline; this is a background and the copy belongs to the host. Verified against the built markup: no heading, paragraph or button anywhere inside the root.
  • The blend is inverse-distance weighting over one drifting anchor per colour, which is what gives a mesh gradient its soft curved seams and the bright core at each anchor. On top of it the sampling plane is swirled by an angle that grows towards the centre and pushed about by a small fbm field, so the seams move without the anchors having to travel far.
  • Eight anchor slots, and more than eight colours are truncated in the frontmatter rather than silently in the shader. GLSL ES 1.0 will not size a uniform array at runtime, so the limit is real; it is enforced where it can be explained.
  • Colours are validated before use. An unparseable colour resolves to the inherited text colour rather than failing, which would put a stray colour into the blend with nothing to show why. Bad entries are dropped and the palette falls back if fewer than two survive.
  • Banding was the main risk on a field this large and is measured, not assumed. A broad smooth ramp across near-flat colour crosses only a few dozen 8-bit levels and steps into contour lines, so the shader adds a per-pixel hash of plus or minus half a level. Measured on the rendered output: 2144 distinct colours with the two commonest covering 1.8%, and the longest run of identical luminance is 9 pixels along a horizontal ray and 6 along a vertical one. A visible band would be tens of pixels.
  • Reduced motion draws one frame and leaves it standing. Measured: zero animation frames requested over 600ms with the canvas visible and the ready flag set.
  • Off screen costs nothing: an IntersectionObserver with a 120px margin stops the loop and resumes it on return.
  • No WebGL and no JavaScript: the root carries a CSS wash of four broad radial pools in the same palette, which is the field with the movement and warping taken out. The canvas is hidden until there is a frame on it because it is opaque, so an empty one would be a black rectangle over the wash. Verified against the built markup - no ready flag, canvas and wash both present, and zero external requests of any kind.
  • Forced colours removes the canvas and the wash and leaves flat Canvas and CanvasText, since painted pixels do not answer a forced palette and neither does a CSS gradient. Measured under emulation. Print does the same and additionally drops page mode, so a fixed background does not print on every sheet.
  • All three modes verified by computed style: section is relative and grid, layer is absolute, page is fixed with z-index -1. In layer and page the root is aria-hidden, because in those modes it is decoration with nothing in it.
  • Verified at eleven widths from 1920 to 320: no page overflow at any of them, the canvas filling the root at every one, and the backing store matching the canvas box at every one.
  • Two live WebGL contexts on one page stay independent, and this was checked on the rendered pixels rather than inferred. The fixture's second instance declares a warm three-colour palette with a different swirl, scale and distortion and a still field; scanning the fixture page finds exactly two coloured bands with mean colours of 40,84,171 and 224,140,65. They are painting genuinely different fields.
  • Client-side navigation proven end to end against a real ClientRouter. Teardown is observed at astro:before-swap with the outgoing instance dropping its ready flag, the root is a new node afterwards, the controller re-boots with a live backing store, and across five transitions the animation frame count at a fixed scroll position stayed at 99. No loop leaks and none accumulate.
  • The context is deliberately NOT released on teardown. Calling loseContext() there bricks the canvas for any later boot and a re-boot then falls through to the fallback permanently; the program and buffer are deleted instead. Written down because it looks like a missing cleanup and is not.
  • keyboard is recorded as not-applicable rather than as a pass, and the declaration licenses it: the component contributes nothing focusable in any mode. The slot is the only place a tab stop could come from and it belongs to the host. Measured on the fixture: zero focusable controls.
  • Contrast is the host's to check. These are broad bright fields and the default blues run from #073883 to #50b1f7, which white slot text sits comfortably on, but nothing here can guarantee that for a palette a caller supplies. Said in the README as well as here rather than left implied.
  • Declarations corroborated against the rendered fixture: zero focusable controls against hasFocusableContent false, a running loop against hasMotion, the id prop reaching both roots against acceptsIdProp, and the ready flag on both against interactivity scripted.
  • No console errors or warnings, and no external requests of any kind - no webfont, no image, no shader package.
  • Chromium only, on integrated Intel UHD Graphics through ANGLE and D3D11. No WebKit or Gecko build was available in this session and no claim is made about either.

Open the verification fixtureOpen the ClientRouter fixture

src/components/library/MeshGradient.astro

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

---
/**
 * Mesh gradient
 *
 * Broad fields of colour bleeding into one another over a slowly warping
 * plane. A background and nothing else: it ships no copy — whatever belongs on
 * top goes in the slot.
 *
 * The blend is inverse-distance weighting over a handful of drifting anchors,
 * one per colour, which is what gives a mesh gradient its soft curved seams
 * and its bright cores. On top of that the sampling plane is swirled around
 * the centre and pushed about by a noise field, so the seams never sit still.
 *
 * Self-contained: no imports, no packages, no global stylesheet, no webfont,
 * no image, and no graphics library — raw WebGL 1.0 and about seventy lines of
 * GLSL written for this component. Every colour and measurement is a `--mgr-*`
 * custom property on the root.
 */

interface Props {
	/**
	 * How the background sits in the page.
	 *
	 * `"section"` is a block in the flow that holds its slot content.
	 * `"layer"` is `position: absolute; inset: 0` — it fills the nearest
	 * positioned ancestor and ignores the slot.
	 * `"page"` is `position: fixed; inset: 0; z-index: -1` — the whole-page
	 * background, and it ignores the slot too.
	 */
	mode?: "section" | "layer" | "page";
	/** The colours blended across the plane. Two to eight. */
	colors?: string[];
	/** How hard the noise field pushes the sampling plane about, 0 to 2. */
	distortion?: number;
	/** How much the plane twists around its centre, 0 to 2. */
	swirl?: number;
	/** Zoom. Above 1 pulls the field in, below 1 pushes it out. */
	scale?: number;
	/** Turns the whole field, in degrees. */
	rotation?: number;
	/** Slides the field sideways, in units of half the short side. */
	offsetX?: number;
	/** Slides the field up and down. */
	offsetY?: number;
	/** Drift rate. `1` is the default pace, `0` is a still field. */
	speed?: number;
	/**
	 * How sharply one colour gives way to the next. Low is a soft wash, high
	 * makes each anchor hold its own territory with a tight seam.
	 */
	falloff?: number;
	/** Height floor in `"section"` mode. */
	minHeight?: string;
	/** Text colour for slot content. `"section"` mode only. */
	ink?: string;
	/** Cap on the backing-store scale. */
	maxPixelRatio?: number;
	/** Added to the root element. */
	class?: string;
	/** Root element id. */
	id?: string;
}

const DEFAULT_COLORS = ["#073883", "#241d9a", "#50b1f7", "#4e8fd4"];

const {
	mode = "section",
	colors = DEFAULT_COLORS,
	distortion = 0.8,
	swirl = 0.1,
	scale = 1,
	rotation = 0,
	offsetX = 0,
	offsetY = 0,
	speed = 1,
	falloff = 2.6,
	minHeight = "100svh",
	ink = "#ffffff",
	maxPixelRatio = 2,
	class: className,
	id,
} = Astro.props;

/* An unrecognised mode falls back to the one that is safe anywhere. */
const layout = mode === "layer" || mode === "page" ? mode : "section";

const num = (value: unknown, fallback: number) =>
	typeof value === "number" && Number.isFinite(value) ? value : fallback;

const clamp = (value: number, min: number, max: number) =>
	Math.min(Math.max(value, min), max);

/*
 * Colours are validated here rather than in the controller. They are resolved
 * through the cascade and handed to the shader as floats; an unparseable one
 * would resolve to the inherited text colour instead of failing, which puts a
 * stray colour into the blend with nothing to show why.
 */
const COLOR = /^(?:#[0-9a-f]{3,8}|rgba?\([^()]*\)|hsla?\([^()]*\)|[a-z]{3,20})$/i;
const palette = (Array.isArray(colors) ? colors : [])
	.filter((c) => typeof c === "string" && COLOR.test(c.trim()))
	.map((c) => c.trim())
	/* The shader carries eight anchor slots. More than that is a silent
	   truncation either way, so it happens here where it can be explained. */
	.slice(0, 8);
const meshColors = palette.length >= 2 ? palette : DEFAULT_COLORS;

/* Clamped rather than trusted: these reach the shader, and a NaN uniform does
   not throw — it silently blanks the canvas. */
const config = {
	colors: meshColors,
	distortion: clamp(num(distortion, 0.8), 0, 2),
	swirl: clamp(num(swirl, 0.1), 0, 2),
	scale: clamp(num(scale, 1), 0.1, 10),
	rotation: num(rotation, 0),
	offsetX: clamp(num(offsetX, 0), -4, 4),
	offsetY: clamp(num(offsetY, 0), -4, 4),
	speed: clamp(num(speed, 1), 0, 10),
	falloff: clamp(num(falloff, 2.6), 0.5, 8),
	maxPixelRatio: clamp(num(maxPixelRatio, 2), 1, 4),
};

const style = [
	`--mgr-min-height:${minHeight}`,
	`--mgr-ink:${ink}`,
	/* The first three drive the no-script fallback, so it stands in the same
	   palette as the field it replaces. */
	`--mgr-c1:${meshColors[0]}`,
	`--mgr-c2:${meshColors[1 % meshColors.length]}`,
	`--mgr-c3:${meshColors[2 % meshColors.length]}`,
	`--mgr-c4:${meshColors[3 % meshColors.length]}`,
].join(";");
---

<div
	class:list={["mesh-gradient", `mgr-${layout}`, className]}
	id={id}
	style={style}
	data-mgr-root
	data-mgr-mode={layout}
	data-mgr-config={JSON.stringify(config)}
	aria-hidden={layout === "section" ? undefined : "true"}
>
	{/*
	   Two layers, only one visible at a time. The wash is a CSS gradient that
	   needs nothing; the canvas covers it once there is a frame on it. So the
	   background is never a blank rectangle while the context is being made,
	   and never blank if it cannot be.
	*/}
	<div class="mgr-wash" aria-hidden="true"></div>
	<canvas class="mgr-canvas" aria-hidden="true" data-mgr-canvas></canvas>

	{layout === "section" && (
		<div class="mgr-content">
			<slot />
		</div>
	)}
</div>

<style>
	.mesh-gradient {
		display: block;
		box-sizing: border-box;
		position: relative;
		overflow: hidden;
		/* The first colour, so the element is never a white flash before the
		   wash paints. */
		background: var(--mgr-c1);
		color: var(--mgr-ink);
		isolation: isolate;
	}

	.mesh-gradient *,
	.mesh-gradient *::before,
	.mesh-gradient *::after {
		box-sizing: border-box;
	}

	.mgr-section {
		display: grid;
		place-items: center;
		min-block-size: var(--mgr-min-height);
	}

	.mgr-layer {
		position: absolute;
		inset: 0;
	}

	.mgr-page {
		position: fixed;
		inset: 0;
		z-index: -1;
	}

	/*
	 * The fallback. Four broad radial pools in the same palette, which is a
	 * mesh gradient with the movement and the warping taken out — degraded,
	 * not missing. It shows before boot, after teardown, and where a WebGL
	 * context cannot be had at all.
	 */
	.mgr-wash {
		position: absolute;
		inset: 0;
		background:
			radial-gradient(70% 80% at 18% 22%, var(--mgr-c3) 0%, transparent 62%),
			radial-gradient(75% 85% at 82% 28%, var(--mgr-c2) 0%, transparent 64%),
			radial-gradient(85% 75% at 30% 88%, var(--mgr-c4) 0%, transparent 66%),
			var(--mgr-c1);
		pointer-events: none;
	}

	.mgr-canvas {
		position: absolute;
		inset: 0;
		inline-size: 100%;
		block-size: 100%;
		pointer-events: none;
		/* Hidden until there is a frame on it. The canvas is opaque — it paints
		   the whole field — so an empty one would be a black rectangle over the
		   wash rather than nothing. */
		visibility: hidden;
	}

	[data-mgr-ready] .mgr-canvas {
		visibility: visible;
	}

	[data-mgr-ready] .mgr-wash {
		opacity: 0;
	}

	.mgr-content {
		position: relative;
		z-index: 1;
		/* The slot decides its own width and padding. A background that imposed
		   a container on whatever is dropped into it would be a layout
		   component wearing a background's name. */
		inline-size: 100%;
	}

	/* ---- preferences ----------------------------------------------------- */

	@media (forced-colors: active) {
		/*
		 * Painted pixels do not answer a forced palette and neither does a CSS
		 * gradient, so both come off and the background becomes flat Canvas
		 * with whatever sits on it in CanvasText.
		 */
		.mgr-canvas,
		[data-mgr-ready] .mgr-canvas,
		.mgr-wash {
			display: none;
		}

		.mesh-gradient {
			background: Canvas;
			color: CanvasText;
		}
	}

	@media print {
		.mgr-canvas,
		[data-mgr-ready] .mgr-canvas,
		.mgr-wash {
			display: none;
		}

		.mesh-gradient {
			background: #fff;
			color: #000;
		}

		/* A fixed background would otherwise print on every page. */
		.mgr-page {
			display: none;
		}
	}
</style>

<script>
	/*
	 * Mesh gradient controller.
	 *
	 * One WebGL context per background. Booted idempotently so it survives a
	 * client-side navigation, and torn down on `astro:before-swap` so no
	 * context, observer or frame request outlives the page.
	 *
	 * NOTE FOR EDITORS: there is no backtick anywhere inside the shader
	 * strings. A backtick inside a template literal in an Astro <script> ends
	 * the literal and breaks the compiler, which has bitten this repository
	 * twice. Keep shader comments free of them.
	 */
	type Config = {
		colors: string[];
		distortion: number;
		swirl: number;
		scale: number;
		rotation: number;
		offsetX: number;
		offsetY: number;
		speed: number;
		falloff: number;
		maxPixelRatio: number;
	};

	type Instance = { destroy: () => void };

	const BOOTED = new WeakMap<HTMLElement, Instance>();

	/* The shader carries a fixed number of anchor slots because GLSL ES 1.0
	   will not size a uniform array at runtime. Unused slots are given zero
	   weight rather than a colour. */
	const MAX_ANCHORS = 8;

	const VERT = `
attribute vec2 a_pos;
void main() {
  gl_Position = vec4(a_pos, 0.0, 1.0);
}
`;

	const FRAG = `
precision highp float;

uniform vec2 u_res;
uniform float u_time;
uniform float u_distortion;
uniform float u_swirl;
uniform float u_scale;
uniform float u_rotation;
uniform vec2 u_offset;
uniform float u_falloff;
uniform int u_count;
uniform vec3 u_colors[8];

float hash(vec2 p) {
  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}

float vnoise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(
    mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),
    mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
    u.y
  );
}

float fbm(vec2 p) {
  float sum = 0.0;
  float amp = 0.5;
  for (int i = 0; i < 3; i++) {
    sum += amp * vnoise(p);
    p *= 2.03;
    amp *= 0.5;
  }
  return sum;
}

void main() {
  /* Normalised on the SHORT side and centred, so the field keeps its
     proportions instead of stretching with the viewport. */
  vec2 uv = (gl_FragCoord.xy - 0.5 * u_res) / min(u_res.x, u_res.y);

  uv = uv / u_scale - u_offset;

  float ca = cos(u_rotation);
  float sa = sin(u_rotation);
  uv = mat2(ca, -sa, sa, ca) * uv;

  /* Swirl: a rotation whose angle grows with the radius, so the middle turns
     further than the rim and the seams wind up into a spiral. */
  float r = length(uv);
  float twist = u_swirl * 3.0 / (1.0 + r * 2.5);
  float cs = cos(twist);
  float ss = sin(twist);
  uv = mat2(cs, -ss, ss, cs) * uv;

  /* Distortion: the sampling plane is pushed about by a slow noise field.
     This is what keeps the seams moving without the anchors themselves
     having to travel far. */
  vec2 warp = vec2(
    fbm(uv * 1.6 + vec2(0.0, u_time * 0.10)),
    fbm(uv * 1.6 + vec2(4.7, 2.3) - vec2(0.0, u_time * 0.08))
  ) - 0.5;
  uv += warp * u_distortion * 0.9;

  /*
   * Inverse-distance weighting over the anchors. Each colour owns a point on
   * a slow elliptical path; a pixel takes a weighted average of them by
   * 1/distance^falloff, which is what gives the soft curved seams and the
   * bright core at each anchor.
   */
  vec3 col = vec3(0.0);
  float total = 0.0;
  for (int i = 0; i < 8; i++) {
    if (i >= u_count) break;
    float fi = float(i);
    float phase = fi * 2.399963;
    vec2 anchor = vec2(
      cos(u_time * 0.21 + phase) * 0.62,
      sin(u_time * 0.17 + phase * 1.31) * 0.52
    );
    float d = distance(uv, anchor);
    float w = 1.0 / (pow(d, u_falloff) + 0.0012);
    col += u_colors[i] * w;
    total += w;
  }
  col /= max(total, 0.0001);

  /*
   * Anti-banding. A broad smooth ramp across near-flat colour crosses only a
   * few dozen 8-bit levels and steps into contour lines, which on a field
   * this large is the first thing anyone notices. Plus or minus half a level
   * dissolves them and cannot be seen as texture.
   */
  float n = hash(gl_FragCoord.xy + fract(u_time)) - 0.5;
  col += n / 255.0;

  gl_FragColor = vec4(col, 1.0);
}
`;

	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 parseColor(value: string): [number, number, number] {
		const probe = document.createElement("span");
		probe.style.color = value;
		document.body.appendChild(probe);
		const resolved = getComputedStyle(probe).color;
		probe.remove();
		const m = resolved.match(/[\d.]+/g);
		if (!m || m.length < 3) return [0, 0, 0];
		return [Number(m[0]) / 255, Number(m[1]) / 255, Number(m[2]) / 255];
	}

	function boot(root: HTMLElement) {
		if (BOOTED.has(root)) return;

		const canvas = root.querySelector<HTMLCanvasElement>("[data-mgr-canvas]");
		if (!canvas) return;

		let config: Config;
		try {
			config = JSON.parse(root.dataset.mgrConfig || "{}");
		} catch {
			return;
		}
		if (!Array.isArray(config.colors) || config.colors.length < 2) return;

		const attrs = {
			alpha: false,
			antialias: false,
			depth: false,
			stencil: false,
			powerPreference: "low-power" as const,
		};
		/* Cast over the whole expression: with `attrs` in a variable TypeScript
		   cannot resolve getContext's literal overload and falls back to the one
		   returning RenderingContext, which has none of the GL methods. */
		const gl = (canvas.getContext("webgl", attrs) ||
			canvas.getContext("experimental-webgl", attrs)) as
			| WebGLRenderingContext
			| null;

		if (!gl) {
			/* No context, ever. The wash stays and the background is complete
			   without the shader, so nothing is reported and nothing hidden. */
			root.dataset.mgrFallback = "";
			return;
		}

		const controller = new AbortController();
		const { signal } = controller;

		let program: WebGLProgram | null = null;
		let buffer: WebGLBuffer | null = null;
		const u: Record<string, WebGLUniformLocation | null> = {};

		let raf = 0;
		let running = false;
		let visible = true;
		let elapsed = 0;
		let last = 0;
		let dpr = 1;

		const motion = window.matchMedia("(prefers-reduced-motion: reduce)");

		function build() {
			if (!gl) return false;
			const vs = compile(gl, gl.VERTEX_SHADER, VERT);
			const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG);
			if (!vs || !fs) return false;

			const p = gl.createProgram();
			if (!p) return false;
			gl.attachShader(p, vs);
			gl.attachShader(p, fs);
			gl.linkProgram(p);
			gl.deleteShader(vs);
			gl.deleteShader(fs);
			if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
				gl.deleteProgram(p);
				return false;
			}
			program = p;
			gl.useProgram(program);

			/* One full-screen triangle, not a quad: three vertices instead of six
			   and no seam down the diagonal. */
			buffer = gl.createBuffer();
			gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
			gl.bufferData(
				gl.ARRAY_BUFFER,
				new Float32Array([-1, -1, 3, -1, -1, 3]),
				gl.STATIC_DRAW,
			);
			const loc = gl.getAttribLocation(program, "a_pos");
			gl.enableVertexAttribArray(loc);
			gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);

			for (const name of [
				"u_res",
				"u_time",
				"u_distortion",
				"u_swirl",
				"u_scale",
				"u_rotation",
				"u_offset",
				"u_falloff",
				"u_count",
			]) {
				u[name] = gl.getUniformLocation(program, name);
			}
			for (let i = 0; i < MAX_ANCHORS; i++) {
				u["c" + i] = gl.getUniformLocation(program, "u_colors[" + i + "]");
			}
			return true;
		}

		function resize() {
			if (!gl) return;
			const rect = canvas!.getBoundingClientRect();
			dpr = Math.min(window.devicePixelRatio || 1, config.maxPixelRatio);
			const w = Math.max(1, Math.round(rect.width * dpr));
			const h = Math.max(1, Math.round(rect.height * dpr));
			if (canvas!.width !== w || canvas!.height !== h) {
				canvas!.width = w;
				canvas!.height = h;
			}
			gl.viewport(0, 0, w, h);
		}

		function draw() {
			if (!gl || !program) return;
			resize();
			gl.useProgram(program);

			const count = Math.min(config.colors.length, MAX_ANCHORS);
			gl.uniform2f(u.u_res, canvas!.width, canvas!.height);
			gl.uniform1f(u.u_time, elapsed);
			gl.uniform1f(u.u_distortion, config.distortion);
			gl.uniform1f(u.u_swirl, config.swirl);
			gl.uniform1f(u.u_scale, config.scale);
			gl.uniform1f(u.u_rotation, (config.rotation * Math.PI) / 180);
			gl.uniform2f(u.u_offset, config.offsetX, config.offsetY);
			gl.uniform1f(u.u_falloff, config.falloff);
			gl.uniform1i(u.u_count, count);
			for (let i = 0; i < count; i++) {
				const [r, g, b] = parseColor(config.colors[i]);
				gl.uniform3f(u["c" + i], r, g, b);
			}

			gl.drawArrays(gl.TRIANGLES, 0, 3);

			delete root.dataset.mgrFallback;
			root.dataset.mgrReady = "";
		}

		function frame(now: number) {
			if (!running) return;
			/* Clamped, so a tab restored after a minute does not jump a minute
			   of drift in one step. */
			const delta = last ? Math.min((now - last) / 1000, 1 / 16) : 0;
			last = now;
			elapsed += delta * config.speed;
			draw();
			raf = requestAnimationFrame(frame);
		}

		function start() {
			if (running || motion.matches || !visible) return;
			running = true;
			last = 0;
			raf = requestAnimationFrame(frame);
		}

		function stop() {
			running = false;
			if (raf) cancelAnimationFrame(raf);
			raf = 0;
		}

		if (!build()) {
			root.dataset.mgrFallback = "";
			controller.abort();
			return;
		}

		draw();

		/* Off screen is not worth a frame. The margin means it is already
		   running by the time it is actually looked at. */
		const io =
			typeof IntersectionObserver === "function"
				? new IntersectionObserver(
						(entries) => {
							visible = entries.some((e) => e.isIntersecting);
							if (visible) start();
							else stop();
						},
						{ rootMargin: "120px" },
					)
				: null;
		io?.observe(root);

		/* On the canvas, not on the window: a background in a column that
		   changes width while the window does not still has to follow. */
		const ro =
			typeof ResizeObserver === "function"
				? new ResizeObserver(() => {
						if (!running) draw();
					})
				: null;
		ro?.observe(canvas);

		const onMotion = () => {
			if (motion.matches) {
				stop();
				draw();
			} else {
				start();
			}
		};
		motion.addEventListener?.("change", onMotion);

		canvas.addEventListener(
			"webglcontextlost",
			(event) => {
				event.preventDefault();
				stop();
				delete root.dataset.mgrReady;
			},
			{ signal },
		);
		canvas.addEventListener(
			"webglcontextrestored",
			() => {
				if (build()) {
					draw();
					start();
				}
			},
			{ signal },
		);

		if (!motion.matches) start();

		BOOTED.set(root, {
			destroy() {
				stop();
				controller.abort();
				io?.disconnect();
				ro?.disconnect();
				motion.removeEventListener?.("change", onMotion);
				if (gl) {
					if (program) gl.deleteProgram(program);
					if (buffer) gl.deleteBuffer(buffer);
				}
				/*
				 * The context itself is left alone on purpose. Calling
				 * loseContext() here bricks the canvas for any later boot, and a
				 * re-boot then falls through to the fallback for good.
				 */
				program = null;
				buffer = null;
				delete root.dataset.mgrReady;
				delete root.dataset.mgrFallback;
				BOOTED.delete(root);
			},
		});
	}

	function bootAll() {
		document
			.querySelectorAll<HTMLElement>("[data-mgr-root]")
			.forEach((root) => boot(root));
	}

	function teardownAll() {
		document.querySelectorAll<HTMLElement>("[data-mgr-root]").forEach((root) => {
			BOOTED.get(root)?.destroy();
		});
	}

	/* Astro's <script> is a deferred module, so the document is already parsed
	   and there is no DOMContentLoaded to wait for. */
	bootAll();

	document.addEventListener("astro:page-load", bootAll);
	document.addEventListener("astro:before-swap", teardownAll);
</script>