Component library
Back to catalog

Wave backdrop

Soft ribbons of colour drifting across a dark field, drawn on a 2D canvas with a trail and a heavy blur. A backdrop and nothing else: it ships no copy, and whatever belongs on top goes in the slot.

backgroundcanvaswavesnoisebackdropdarkhero

Open preview in a new tab

Use this component

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

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

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: "Wave backdrop"
Library page: https://astro.baysixmedia.com/components/wave-backdrop/
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/WaveBackdrop.astro =====
---
/**
 * Wave backdrop
 *
 * Soft ribbons of colour drifting across a dark field. A backdrop and nothing
 * else: it ships no copy, no heading and no button — whatever belongs on top
 * goes in the slot.
 *
 * Canvas 2D rather than WebGL, because the look is made of thick strokes, a
 * heavy blur and a trail: each frame paints the ground at partial alpha over
 * the last one instead of clearing it, so the ribbons smear into each other.
 * That is cheap and exact in 2D and would be a fight in a shader.
 *
 * Self-contained: no imports, no packages, no global stylesheet, no webfont,
 * no image, and no noise library — the 3D value noise is about twenty lines
 * written for this component. Every colour and measurement is a `--wbd-*`
 * custom property on the root.
 */

interface Props {
	/**
	 * How the backdrop 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
	 * backdrop, and it ignores the slot too.
	 */
	mode?: "section" | "layer" | "page";
	/** The ribbon colours, in order. One stroke per entry, cycled. */
	colors?: string[];
	/** How many ribbons to draw. */
	waves?: number;
	/** Stroke width of one ribbon, in CSS pixels. */
	waveWidth?: number;
	/** The ground the ribbons are painted on. */
	backgroundFill?: string;
	/** Blur applied to the whole field, in CSS pixels. */
	blur?: number;
	/**
	 * Alpha the ground is repainted at each frame. Lower leaves a longer
	 * trail; `1` clears completely and the ribbons read as hard bands.
	 */
	waveOpacity?: number;
	/** Drift rate. `1` is the default pace, `0` is a still field. */
	speed?: number;
	/** How far a ribbon swings from the centre line, in CSS pixels. */
	amplitude?: number;
	/** Distance over which the curve repeats. Larger is lazier. */
	wavelength?: number;
	/**
	 * How far apart the ribbons are sampled in the noise, so they separate.
	 *
	 * `1` puts each ribbon on its own lattice plane of the noise, which makes
	 * their curves independent — that is what fans them out. Fractional values
	 * land ribbons inside the same cell, where they stay correlated and travel
	 * together in pairs. That is a real look, but it is not the default.
	 */
	separation?: number;
	/** Where the ribbons are centred, 0 is the top and 1 the bottom. */
	center?: 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 {
	mode = "section",
	colors = ["#38b6ff", "#6c7cf7", "#a86df0", "#e06cf0", "#3fd6e0"],
	waves = 5,
	waveWidth = 50,
	backgroundFill = "#000000",
	blur = 10,
	waveOpacity = 0.5,
	speed = 1,
	amplitude = 100,
	wavelength = 800,
	separation = 1,
	center = 0.5,
	minHeight = "100svh",
	ink = "#f5f6ff",
	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 written
 * straight into `strokeStyle`, and an unparseable value there does not throw —
 * the canvas keeps the previous colour, so one bad entry silently paints two
 * ribbons the same. Anything that is not a plain colour token is dropped.
 */
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());
const ribbonColors = palette.length
	? palette
	: ["#38b6ff", "#6c7cf7", "#a86df0", "#e06cf0", "#3fd6e0"];

/* Clamped rather than trusted. These drive a render loop, and a NaN here
   turns every coordinate into NaN and blanks the canvas without an error. */
const config = {
	colors: ribbonColors,
	waves: clamp(Math.round(num(waves, 5)), 1, 24),
	waveWidth: clamp(num(waveWidth, 50), 1, 400),
	backgroundFill: COLOR.test(String(backgroundFill).trim())
		? String(backgroundFill).trim()
		: "#000000",
	waveOpacity: clamp(num(waveOpacity, 0.5), 0.02, 1),
	speed: clamp(num(speed, 1), 0, 20),
	amplitude: clamp(num(amplitude, 100), 0, 2000),
	wavelength: clamp(num(wavelength, 800), 20, 20000),
	separation: clamp(num(separation, 1), 0, 20),
	center: clamp(num(center, 0.5), 0, 1),
	maxPixelRatio: clamp(num(maxPixelRatio, 2), 1, 4),
};

const blurPx = clamp(num(blur, 10), 0, 200);

const style = [
	`--wbd-fill:${config.backgroundFill}`,
	`--wbd-blur:${blurPx}px`,
	`--wbd-min-height:${minHeight}`,
	`--wbd-ink:${ink}`,
	/* The first two ribbons drive the no-script fallback, so it is in the same
	   palette as the field it stands in for. */
	`--wbd-c1:${ribbonColors[0]}`,
	`--wbd-c2:${ribbonColors[1 % ribbonColors.length]}`,
	`--wbd-c3:${ribbonColors[2 % ribbonColors.length]}`,
	`--wbd-center:${config.center * 100}%`,
].join(";");
---

<div
	class:list={["wave-backdrop", `wbd-${layout}`, className]}
	id={id}
	style={style}
	data-wbd-root
	data-wbd-mode={layout}
	data-wbd-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
	   backdrop is never a blank rectangle while the context is being made, and
	   never blank if it cannot be.
	*/}
	<div class="wbd-wash" aria-hidden="true"></div>
	<canvas class="wbd-canvas" aria-hidden="true" data-wbd-canvas></canvas>

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

<style>
	.wave-backdrop {
		display: block;
		box-sizing: border-box;
		position: relative;
		/* The blur is applied to the canvas, which then feathers at its own
		   edges — so the canvas is oversized and this clips it back. */
		overflow: hidden;
		background: var(--wbd-fill);
		color: var(--wbd-ink);
		isolation: isolate;
	}

	.wave-backdrop *,
	.wave-backdrop *::before,
	.wave-backdrop *::after {
		box-sizing: border-box;
	}

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

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

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

	/*
	 * The fallback. Three soft bands of the first colours across the centre
	 * line, which is a reasonable reading of the field with the movement and
	 * the turbulence taken out — degraded, not missing. It shows before boot,
	 * after teardown, and where a 2D context cannot be had at all.
	 */
	.wbd-wash {
		position: absolute;
		inset: 0;
		background:
			radial-gradient(
				70% 18% at 22% var(--wbd-center),
				var(--wbd-c1) 0%,
				transparent 70%
			),
			radial-gradient(
				64% 15% at 58% calc(var(--wbd-center) + 4%),
				var(--wbd-c3) 0%,
				transparent 72%
			),
			radial-gradient(
				76% 16% at 84% calc(var(--wbd-center) - 3%),
				var(--wbd-c2) 0%,
				transparent 70%
			);
		opacity: 0.55;
		filter: blur(var(--wbd-blur));
		pointer-events: none;
	}

	.wbd-canvas {
		position: absolute;
		/*
		 * Oversized on every side by twice the blur radius, so the blur samples
		 * real pixels at the container's edges instead of transparency. Without
		 * it the field fades out into a soft rectangle inset from the corners.
		 * The parent clips the overhang.
		 */
		inset: calc(var(--wbd-blur) * -2);
		inline-size: calc(100% + var(--wbd-blur) * 4);
		block-size: calc(100% + var(--wbd-blur) * 4);
		/*
		 * The blur is here, on the element, rather than on the 2D context. The
		 * context form re-blurs on every stroke — five full-canvas CPU blurs a
		 * frame — where this is one composited blur the GPU already knows how
		 * to do. It also means the slot content above is untouched by it.
		 */
		filter: blur(var(--wbd-blur));
		pointer-events: none;
		/* Hidden until there is a frame on it. The canvas paints its own
		   ground, so an empty one would be a rectangle over the wash. */
		visibility: hidden;
	}

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

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

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

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

	@media (prefers-reduced-motion: reduce) {
		/*
		 * The field is drawn once and left 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 controller reads the same preference and
		 * never starts its loop.
		 */
		.wbd-canvas {
			/* Nothing to change here — the controller handles it. This block is
			   kept so the intent is visible next to the other preferences. */
			filter: blur(var(--wbd-blur));
		}
	}

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

		.wave-backdrop {
			background: Canvas;
			color: CanvasText;
		}
	}

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

		.wave-backdrop {
			background: #fff;
			color: #000;
		}

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

<script>
	/*
	 * Wave field controller.
	 *
	 * One 2D context per backdrop. 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.
	 */
	type Config = {
		colors: string[];
		waves: number;
		waveWidth: number;
		backgroundFill: string;
		waveOpacity: number;
		speed: number;
		amplitude: number;
		wavelength: number;
		separation: number;
		center: number;
		maxPixelRatio: number;
	};

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

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

	/*
	 * 3D value noise, written here rather than pulled from a package.
	 *
	 * An integer hash and a trilinear blend with a quintic fade — smooth in all
	 * three axes and continuous in its derivative, which is all this needs: the
	 * field is sampled along a line in x, offset per ribbon in y, and drifted
	 * in z. Simplex noise would be a different lattice and a slightly different
	 * character; at this wavelength, under a 10px blur, it is not a difference
	 * anyone can see, and it is not worth a dependency.
	 */
	function hash3(x: number, y: number, z: number) {
		let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263) ^ Math.imul(z, 1274126177);
		h = Math.imul(h ^ (h >>> 13), 1274126177);
		return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
	}

	const fade = (t: number) => t * t * t * (t * (t * 6 - 15) + 10);
	const lerp = (a: number, b: number, t: number) => a + (b - a) * t;

	/** Returns roughly -1 to 1, so a ribbon swings either side of its line. */
	function noise3(x: number, y: number, z: number) {
		const xi = Math.floor(x);
		const yi = Math.floor(y);
		const zi = Math.floor(z);
		const u = fade(x - xi);
		const v = fade(y - yi);
		const w = fade(z - zi);

		const c000 = hash3(xi, yi, zi);
		const c100 = hash3(xi + 1, yi, zi);
		const c010 = hash3(xi, yi + 1, zi);
		const c110 = hash3(xi + 1, yi + 1, zi);
		const c001 = hash3(xi, yi, zi + 1);
		const c101 = hash3(xi + 1, yi, zi + 1);
		const c011 = hash3(xi, yi + 1, zi + 1);
		const c111 = hash3(xi + 1, yi + 1, zi + 1);

		const x00 = lerp(c000, c100, u);
		const x10 = lerp(c010, c110, u);
		const x01 = lerp(c001, c101, u);
		const x11 = lerp(c011, c111, u);

		return lerp(lerp(x00, x10, v), lerp(x01, x11, v), w) * 2 - 1;
	}

	/* The reference advances its phase by a fixed step per FRAME, which makes
	   it run twice as fast on a 120Hz display as on a 60Hz one. This is per
	   second instead, so the pace is the same everywhere. */
	const DRIFT_PER_SECOND = 0.12;

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

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

		let config: Config;
		try {
			config = JSON.parse(root.dataset.wbdConfig || "{}");
		} catch {
			return;
		}

		const ctx = canvas.getContext("2d", { alpha: false });
		if (!ctx) {
			/* No context, ever. The wash stays and the backdrop is complete
			   without the ribbons, so nothing is reported and nothing hidden. */
			root.dataset.wbdFallback = "";
			return;
		}

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

		let raf = 0;
		let running = false;
		let visible = true;
		let phase = 0;
		let last = 0;
		let w = 0;
		let h = 0;
		let dpr = 1;

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

		function resize() {
			/*
			 * The CANVAS box, not the window. The reference measures
			 * `window.innerWidth` and `window.innerHeight`, which is right only
			 * when the backdrop happens to be full-bleed — in a narrower
			 * container the same curve is squashed into it. Measured on the
			 * reference's own page: a 1440px buffer inside a 709px container.
			 */
			const rect = canvas!.getBoundingClientRect();
			dpr = Math.min(window.devicePixelRatio || 1, config.maxPixelRatio);
			w = Math.max(1, Math.round(rect.width));
			h = Math.max(1, Math.round(rect.height));
			const bw = Math.max(1, Math.round(rect.width * dpr));
			const bh = Math.max(1, Math.round(rect.height * dpr));
			if (canvas!.width !== bw || canvas!.height !== bh) {
				canvas!.width = bw;
				canvas!.height = bh;
				/* Drawing stays in CSS pixels, so waveWidth and amplitude mean
				   the same thing on any display. */
				ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
				return true;
			}
			return false;
		}

		function paintGround(alpha: number) {
			ctx!.globalAlpha = alpha;
			ctx!.fillStyle = config.backgroundFill;
			ctx!.fillRect(0, 0, w, h);
		}

		function drawRibbons() {
			const mid = h * config.center;
			/* One sample every 5px, as coarse as the curve can be before the
			   corners start to show under the blur. */
			const step = 5;
			ctx!.lineCap = "round";
			ctx!.lineJoin = "round";
			ctx!.lineWidth = config.waveWidth;
			for (let i = 0; i < config.waves; i++) {
				ctx!.beginPath();
				ctx!.strokeStyle = config.colors[i % config.colors.length];
				for (let x = 0; x <= w + step; x += step) {
					const y =
						noise3(x / config.wavelength, i * config.separation, phase) *
						config.amplitude;
					if (x === 0) ctx!.moveTo(x, mid + y);
					else ctx!.lineTo(x, mid + y);
				}
				ctx!.stroke();
			}
		}

		/*
		 * `still` is not just "one frame of the animation". The trail is what
		 * the partial-alpha ground gives, and a single partial fill over an
		 * empty canvas is a washed-out one instead. So a still draw lays an
		 * opaque ground and strokes once over it.
		 */
		function draw(still: boolean) {
			resize();
			paintGround(still ? 1 : config.waveOpacity);
			ctx!.globalAlpha = config.waveOpacity;
			drawRibbons();
			ctx!.globalAlpha = 1;
			delete root.dataset.wbdFallback;
			root.dataset.wbdReady = "";
		}

		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;
			phase += delta * DRIFT_PER_SECOND * config.speed;
			draw(false);
			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;
		}

		/* An opaque first frame, so the field does not fade up from a
		   half-transparent ground while the trail builds. */
		draw(true);

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

		/*
		 * A ResizeObserver on the canvas, not `window.onresize`. The reference
		 * assigns `window.onresize` directly, which replaces whatever else on
		 * the page was using it — and two of these on one page would leave only
		 * the second one working.
		 */
		const ro =
			typeof ResizeObserver === "function"
				? new ResizeObserver(() => {
						if (!running) draw(true);
					})
				: null;
		ro?.observe(canvas);

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

		/* A 2D context can be lost too, on a GPU reset or a driver change. */
		canvas.addEventListener(
			"contextlost",
			(event) => {
				event.preventDefault();
				stop();
				delete root.dataset.wbdReady;
			},
			{ signal },
		);
		canvas.addEventListener(
			"contextrestored",
			() => {
				draw(true);
				start();
			},
			{ signal },
		);

		if (!motion.matches) start();

		BOOTED.set(root, {
			destroy() {
				stop();
				controller.abort();
				io?.disconnect();
				ro?.disconnect();
				motion.removeEventListener?.("change", onMotion);
				delete root.dataset.wbdReady;
				delete root.dataset.wbdFallback;
				BOOTED.delete(root);
			},
		});
	}

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

	function teardownAll() {
		document.querySelectorAll<HTMLElement>("[data-wbd-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/WaveBackdrop.astro =====

===== FILE (supporting): src/components/WaveBackdrop.md =====
# Wave backdrop

Soft ribbons of colour drifting across a dark field, drawn on a 2D canvas with
a trail and a heavy blur.

**A backdrop and nothing else.** It ships no heading, no paragraph and no
button. Whatever belongs on top goes in the slot.

Catalog page: `/components/wave-backdrop`

## Copying it into another Astro project

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

| What it needs | Status |
| :--- | :--- |
| Imports from this catalog | none |
| npm packages | **none** — no noise library |
| Astro built-ins (`astro:assets`, …) | none |
| Global stylesheet | none — every value is a `--wbd-*` property on the root |
| Webfonts | none |
| Images | none |
| Network requests | **none at all** |
| Host requirement | Astro compiles `.astro` files |

The 3D value noise is about twenty lines written for this component.

### Exact steps

1. Copy `WaveBackdrop.astro` into your components folder.
2. Put your own content inside it:

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

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

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, 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 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`, because 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[]` | five cool tones | One stroke per entry, cycled. |
| `waves` | `number` | `5` | How many ribbons. |
| `waveWidth` | `number` | `50` | Stroke width in CSS px. |
| `backgroundFill` | `string` | `"#000000"` | The ground. |
| `blur` | `number` | `10` | Blur over the whole field, in CSS px. |
| `waveOpacity` | `number` | `0.5` | Alpha the ground is repainted at. Lower leaves a longer trail. |
| `speed` | `number` | `1` | Drift rate. `0` is a still field. |
| `amplitude` | `number` | `100` | How far a ribbon swings from the centre line. |
| `wavelength` | `number` | `800` | Distance over which the curve repeats. |
| `separation` | `number` | `1` | How far apart ribbons are sampled. See below. |
| `center` | `number` | `0.5` | Where the ribbons sit, 0 top to 1 bottom. |
| `minHeight` | `string` | `"100svh"` | Height floor in `"section"` mode. |
| `ink` | `string` | `"#f5f6ff"` | Text colour for slot content. |
| `maxPixelRatio` | `number` | `2` | Cap on the backing-store scale. |
| `class` | `string` | — | Added to the root. |
| `id` | `string` | — | Root element id. |

Every numeric prop is clamped, and **every colour is validated before it is
used**. An unparseable `strokeStyle` does not throw — the canvas silently keeps
the previous colour — so one bad entry would quietly paint two ribbons the
same. Bad entries are dropped instead.

## `separation` is the one to understand

It is how far apart two ribbons are sampled in the noise, and it behaves in
steps rather than smoothly.

**`1` puts each ribbon on its own lattice plane**, so their curves are
independent and they fan out. Fractional values land ribbons inside the same
cell, where they stay correlated and travel together in pairs.

Measured on the default palette at `amplitude: 100`: at `separation: 0.3`,
ribbons 0 and 1 sit an average of **12px** apart — they read as one thick
ribbon — while ribbons 3 and 4 are 70px away. At `1` the four gaps come out
between 37 and 73px, which is the fan you actually want.

If you want the paired-ribbon look, `0.3` is how to get it. Just know that it
is a choice.

## Tuning it

- **`waveOpacity`** — the trail. `0.5` smears; `1` clears each frame and the
  ribbons become hard bands with no glow at all.
- **`blur`** — under 4 you can see the stroke edges; over 40 it is a wash.
- **`center`** — `0.5` is a band across the middle, `0.9` is a glow along the
  bottom edge.
- **`waves` + `colors`** — they are independent; the palette cycles, so three
  colours over seven ribbons repeats them.

## How it is drawn

Canvas 2D, not WebGL, because the look is thick strokes, a heavy blur and a
trail. Each frame repaints the ground at `waveOpacity` **over** the last frame
instead of clearing it, so the ribbons smear into each other. That is cheap and
exact in 2D and would be a fight in a shader.

Two details worth knowing if you edit it:

**The blur is on the element, not on the context.** `ctx.filter` re-blurs on
every stroke — five full-canvas CPU blurs a frame — where a CSS `filter` on the
canvas is one composited blur the GPU already knows how to do. It also leaves
the slot content above it untouched.

**The canvas is oversized by four times the blur radius** and the root clips the
overhang. A blur samples transparency outside the canvas, so without the
overhang the field fades out into a soft rectangle inset from the corners.

## What it does when things go wrong

- **No JavaScript, or no 2D context.** The root carries a CSS wash — three soft
  bands of the first colours across the centre line — which is the field with
  the movement and turbulence taken out. Degraded, not missing. The canvas is
  hidden until there is a frame on it, because it paints its own ground and an
  empty one would be a rectangle over the wash. A failed boot sets
  `data-wbd-fallback` on the root.
- **Scrolled out of view.** An IntersectionObserver stops the loop, with a
  120px margin. Measured: zero frames requested while off screen.
- **A resized backdrop.** A ResizeObserver on the canvas.
- **A background tab.** `requestAnimationFrame` stops on its own, and elapsed
  time is clamped to a sixteenth of a second per frame so a tab restored after
  a minute does not jump a minute of drift.
- **A lost 2D context** — rarer than WebGL but real on a GPU reset. It drops
  the ready flag, waits for `contextrestored`, redraws and carries on.

## Accessibility

- The canvas and the wash are `aria-hidden` and `pointer-events: none`. In
  `"layer"` and `"page"` the whole root is `aria-hidden`.
- 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 canvas read back to
  confirm the field is actually painted rather than merely visible.
- A still draw is not one frame of the animation. The trail comes from repeated
  partial-alpha grounds, and a single partial fill over an empty canvas is a
  washed-out one — so a still draw lays an opaque ground and strokes once.
- `forced-colors: active` removes the canvas and the wash and leaves flat
  `Canvas` and `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
  backdrop does not print on every sheet.
- `ink` exists so slot content is legible by default.

## Relationship to the reference

Rebuilt from a published React component. None of it is reproduced: no
Tailwind, no `cn`, no `simplex-noise`, no React. The noise, the controller and
the palette are this component's own, and it deliberately ships without the
reference demo's copy.

Matched: the shape of the idea — five 50px strokes at 100px amplitude on an
800px wavelength, centred at half height, painted at 0.5 alpha over an
un-cleared ground with a 10px blur. Measured on the rendered output, the hue
spread is the same span: the reference covers hue buckets 180–300, this covers
192–300.

Four things are deliberately different, and three of them are fixes:

1. **It sizes to its container.** The reference measures `window.innerWidth`
   and `window.innerHeight`, which is right only when the backdrop is
   full-bleed. Measured on its own documentation page: a **1440×900 buffer
   inside a 709px-wide container**, so the curve is squashed into it. That is
   also why the reference's band looks steeper and thicker than this one's at
   the same settings.
2. **It does not take `window.onresize`.** The reference assigns that property
   directly, which replaces whatever else on the page was using it — and two of
   them on one page would leave only the second working.
3. **Its phase advances per second, not per frame.** The reference adds a fixed
   step per frame, so it runs at double speed on a 120Hz display.
4. It respects `prefers-reduced-motion`, which the reference does not.

The reference also carries a Safari branch that moves the blur from the context
to a CSS filter. That is not needed here because the blur is a CSS filter for
everyone — which is the faster path anyway.

===== END src/components/WaveBackdrop.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:596470282346d067e9f3f5871b52224f0cf80de2a9887dbb3dd078e669ad99db — 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
“Wave backdrop” from my Astro Component Library:
https://astro.baysixmedia.com/components/wave-backdrop/

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
22 verification notes
  • Rebuilt from a published React component by reading its behaviour, not by porting it. None of it is reproduced: no React, no Tailwind, no cn helper, no simplex-noise. The 3D value noise, the controller and the palette are this component's own.
  • It ships no copy at all, which was the request. Verified against the built markup: no heading, no paragraph and no button anywhere inside the root. The reference's demo bakes in a headline and a subhead; here the slot is empty and the host fills it.
  • Matched in shape: five 50px strokes at 100px amplitude on an 800px wavelength, centred at half height, painted at 0.5 alpha over an un-cleared ground under a 10px blur. Measured on the rendered output the hue spread is the same span - the reference covers hue buckets 180 to 300, this covers 192 to 300.
  • The reference's own demo squashes its field, and that is why its band looks steeper and thicker than this one's at identical settings. It measures window.innerWidth and window.innerHeight rather than its container: measured on its documentation page, a 1440x900 buffer inside a 709px-wide container. This sizes to the canvas box, so the curve keeps its proportions at any width.
  • Two more defects in the reference are fixed rather than carried over. It assigns window.onresize directly, which replaces whatever else on the page was using that property and would leave only the second of two instances working; this uses a ResizeObserver on the canvas. And it advances its phase by a fixed step per FRAME, so it runs at double speed on a 120Hz display; this advances per second.
  • separation behaves in steps rather than smoothly, and the default is chosen on a measurement. At 0.3 - the reference's value, under its own noise - ribbons 0 and 1 land in the same lattice cell of this noise and sit an average of 12px apart, reading as one thick ribbon while ribbons 3 and 4 are 70px away. At 1 each ribbon gets its own lattice plane, the curves are independent, and the four gaps come out between 37 and 73px. That is the fan the look depends on.
  • The blur is a CSS filter on the canvas element rather than ctx.filter on the context. The context form re-blurs on every stroke - five full-canvas CPU blurs a frame - where this is one composited blur. It also leaves slot content untouched, and it removes the reference's need for a Safari branch, since the CSS path is what every browser gets.
  • The canvas is oversized by four times the blur radius and the root clips the overhang. A blur samples transparency beyond the canvas edge, so without it the field fades into a soft rectangle inset from the corners. Measured at every tested width: the canvas box is exactly 40px wider than the root at a 10px blur, the root clips it, and the document never scrolls.
  • Colours are validated before they reach strokeStyle. An unparseable value there does not throw - the canvas keeps the previous colour - so one bad entry would silently paint two ribbons the same instead of failing. Bad entries are dropped and the palette falls back if nothing survives.
  • Reduced motion draws one frame and leaves it standing, and the still frame is a different draw rather than one frame of the animation: the trail comes from repeated partial-alpha grounds, so a single partial fill over an empty canvas would be washed out. A still draw lays an opaque ground and strokes once. Measured: zero animation frames over 600ms, the canvas visible, and the pixels read back to confirm the field is actually painted - 175 lit samples - rather than merely visible and empty.
  • Off screen costs nothing. Measured: zero animation frames requested over 600ms with the backdrop scrolled out of view, and the loop resumes when it returns.
  • No JavaScript, and no 2D context: the root carries a CSS wash of three soft bands in the same palette, which is the field with the movement and turbulence taken out. Verified against the built markup - no ready flag, the canvas hidden, the wash present. The canvas is hidden until there is a frame on it because it paints its own ground, so an empty one would be a rectangle covering the wash.
  • Forced colours removes both 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 entirely, so a fixed backdrop does not print on every sheet.
  • All three modes verified by computed style rather than by reading the stylesheet: section is relative and grid, layer is absolute at inset 0, page is fixed at inset 0 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 backing store matching the canvas box at every one, and the blur and clipping intact throughout.
  • 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. Recorded as not-applicable rather than passed, because there was nothing to operate.
  • Two live 2D contexts on one page stay independent, and this was checked by reading the canvases back rather than by inferring it from the markup. The fixture's second instance declares a warm three-colour palette, a still field and a different centre line; sampling the painted pixels of each gives a mean lit colour of 110,119,192 for the first and 159,75,70 for the second. 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 36. No loop leaks and none accumulate.
  • 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 noise package.
  • Enrolled with no profile, so it owes the core checks and nothing is invented for it: it is neither a carousel nor navigation. It carries its own driver contract, which the fixture publishes and which was verified to resolve against the component.
  • Chromium only. No WebKit or Gecko build was available in this session and no claim is made about either. The place that gap matters most here is the CSS filter on a canvas that is repainted every frame, which is well supported but was not observed elsewhere.

Open the verification fixtureOpen the ClientRouter fixture

src/components/library/WaveBackdrop.astro

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

---
/**
 * Wave backdrop
 *
 * Soft ribbons of colour drifting across a dark field. A backdrop and nothing
 * else: it ships no copy, no heading and no button — whatever belongs on top
 * goes in the slot.
 *
 * Canvas 2D rather than WebGL, because the look is made of thick strokes, a
 * heavy blur and a trail: each frame paints the ground at partial alpha over
 * the last one instead of clearing it, so the ribbons smear into each other.
 * That is cheap and exact in 2D and would be a fight in a shader.
 *
 * Self-contained: no imports, no packages, no global stylesheet, no webfont,
 * no image, and no noise library — the 3D value noise is about twenty lines
 * written for this component. Every colour and measurement is a `--wbd-*`
 * custom property on the root.
 */

interface Props {
	/**
	 * How the backdrop 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
	 * backdrop, and it ignores the slot too.
	 */
	mode?: "section" | "layer" | "page";
	/** The ribbon colours, in order. One stroke per entry, cycled. */
	colors?: string[];
	/** How many ribbons to draw. */
	waves?: number;
	/** Stroke width of one ribbon, in CSS pixels. */
	waveWidth?: number;
	/** The ground the ribbons are painted on. */
	backgroundFill?: string;
	/** Blur applied to the whole field, in CSS pixels. */
	blur?: number;
	/**
	 * Alpha the ground is repainted at each frame. Lower leaves a longer
	 * trail; `1` clears completely and the ribbons read as hard bands.
	 */
	waveOpacity?: number;
	/** Drift rate. `1` is the default pace, `0` is a still field. */
	speed?: number;
	/** How far a ribbon swings from the centre line, in CSS pixels. */
	amplitude?: number;
	/** Distance over which the curve repeats. Larger is lazier. */
	wavelength?: number;
	/**
	 * How far apart the ribbons are sampled in the noise, so they separate.
	 *
	 * `1` puts each ribbon on its own lattice plane of the noise, which makes
	 * their curves independent — that is what fans them out. Fractional values
	 * land ribbons inside the same cell, where they stay correlated and travel
	 * together in pairs. That is a real look, but it is not the default.
	 */
	separation?: number;
	/** Where the ribbons are centred, 0 is the top and 1 the bottom. */
	center?: 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 {
	mode = "section",
	colors = ["#38b6ff", "#6c7cf7", "#a86df0", "#e06cf0", "#3fd6e0"],
	waves = 5,
	waveWidth = 50,
	backgroundFill = "#000000",
	blur = 10,
	waveOpacity = 0.5,
	speed = 1,
	amplitude = 100,
	wavelength = 800,
	separation = 1,
	center = 0.5,
	minHeight = "100svh",
	ink = "#f5f6ff",
	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 written
 * straight into `strokeStyle`, and an unparseable value there does not throw —
 * the canvas keeps the previous colour, so one bad entry silently paints two
 * ribbons the same. Anything that is not a plain colour token is dropped.
 */
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());
const ribbonColors = palette.length
	? palette
	: ["#38b6ff", "#6c7cf7", "#a86df0", "#e06cf0", "#3fd6e0"];

/* Clamped rather than trusted. These drive a render loop, and a NaN here
   turns every coordinate into NaN and blanks the canvas without an error. */
const config = {
	colors: ribbonColors,
	waves: clamp(Math.round(num(waves, 5)), 1, 24),
	waveWidth: clamp(num(waveWidth, 50), 1, 400),
	backgroundFill: COLOR.test(String(backgroundFill).trim())
		? String(backgroundFill).trim()
		: "#000000",
	waveOpacity: clamp(num(waveOpacity, 0.5), 0.02, 1),
	speed: clamp(num(speed, 1), 0, 20),
	amplitude: clamp(num(amplitude, 100), 0, 2000),
	wavelength: clamp(num(wavelength, 800), 20, 20000),
	separation: clamp(num(separation, 1), 0, 20),
	center: clamp(num(center, 0.5), 0, 1),
	maxPixelRatio: clamp(num(maxPixelRatio, 2), 1, 4),
};

const blurPx = clamp(num(blur, 10), 0, 200);

const style = [
	`--wbd-fill:${config.backgroundFill}`,
	`--wbd-blur:${blurPx}px`,
	`--wbd-min-height:${minHeight}`,
	`--wbd-ink:${ink}`,
	/* The first two ribbons drive the no-script fallback, so it is in the same
	   palette as the field it stands in for. */
	`--wbd-c1:${ribbonColors[0]}`,
	`--wbd-c2:${ribbonColors[1 % ribbonColors.length]}`,
	`--wbd-c3:${ribbonColors[2 % ribbonColors.length]}`,
	`--wbd-center:${config.center * 100}%`,
].join(";");
---

<div
	class:list={["wave-backdrop", `wbd-${layout}`, className]}
	id={id}
	style={style}
	data-wbd-root
	data-wbd-mode={layout}
	data-wbd-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
	   backdrop is never a blank rectangle while the context is being made, and
	   never blank if it cannot be.
	*/}
	<div class="wbd-wash" aria-hidden="true"></div>
	<canvas class="wbd-canvas" aria-hidden="true" data-wbd-canvas></canvas>

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

<style>
	.wave-backdrop {
		display: block;
		box-sizing: border-box;
		position: relative;
		/* The blur is applied to the canvas, which then feathers at its own
		   edges — so the canvas is oversized and this clips it back. */
		overflow: hidden;
		background: var(--wbd-fill);
		color: var(--wbd-ink);
		isolation: isolate;
	}

	.wave-backdrop *,
	.wave-backdrop *::before,
	.wave-backdrop *::after {
		box-sizing: border-box;
	}

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

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

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

	/*
	 * The fallback. Three soft bands of the first colours across the centre
	 * line, which is a reasonable reading of the field with the movement and
	 * the turbulence taken out — degraded, not missing. It shows before boot,
	 * after teardown, and where a 2D context cannot be had at all.
	 */
	.wbd-wash {
		position: absolute;
		inset: 0;
		background:
			radial-gradient(
				70% 18% at 22% var(--wbd-center),
				var(--wbd-c1) 0%,
				transparent 70%
			),
			radial-gradient(
				64% 15% at 58% calc(var(--wbd-center) + 4%),
				var(--wbd-c3) 0%,
				transparent 72%
			),
			radial-gradient(
				76% 16% at 84% calc(var(--wbd-center) - 3%),
				var(--wbd-c2) 0%,
				transparent 70%
			);
		opacity: 0.55;
		filter: blur(var(--wbd-blur));
		pointer-events: none;
	}

	.wbd-canvas {
		position: absolute;
		/*
		 * Oversized on every side by twice the blur radius, so the blur samples
		 * real pixels at the container's edges instead of transparency. Without
		 * it the field fades out into a soft rectangle inset from the corners.
		 * The parent clips the overhang.
		 */
		inset: calc(var(--wbd-blur) * -2);
		inline-size: calc(100% + var(--wbd-blur) * 4);
		block-size: calc(100% + var(--wbd-blur) * 4);
		/*
		 * The blur is here, on the element, rather than on the 2D context. The
		 * context form re-blurs on every stroke — five full-canvas CPU blurs a
		 * frame — where this is one composited blur the GPU already knows how
		 * to do. It also means the slot content above is untouched by it.
		 */
		filter: blur(var(--wbd-blur));
		pointer-events: none;
		/* Hidden until there is a frame on it. The canvas paints its own
		   ground, so an empty one would be a rectangle over the wash. */
		visibility: hidden;
	}

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

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

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

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

	@media (prefers-reduced-motion: reduce) {
		/*
		 * The field is drawn once and left 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 controller reads the same preference and
		 * never starts its loop.
		 */
		.wbd-canvas {
			/* Nothing to change here — the controller handles it. This block is
			   kept so the intent is visible next to the other preferences. */
			filter: blur(var(--wbd-blur));
		}
	}

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

		.wave-backdrop {
			background: Canvas;
			color: CanvasText;
		}
	}

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

		.wave-backdrop {
			background: #fff;
			color: #000;
		}

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

<script>
	/*
	 * Wave field controller.
	 *
	 * One 2D context per backdrop. 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.
	 */
	type Config = {
		colors: string[];
		waves: number;
		waveWidth: number;
		backgroundFill: string;
		waveOpacity: number;
		speed: number;
		amplitude: number;
		wavelength: number;
		separation: number;
		center: number;
		maxPixelRatio: number;
	};

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

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

	/*
	 * 3D value noise, written here rather than pulled from a package.
	 *
	 * An integer hash and a trilinear blend with a quintic fade — smooth in all
	 * three axes and continuous in its derivative, which is all this needs: the
	 * field is sampled along a line in x, offset per ribbon in y, and drifted
	 * in z. Simplex noise would be a different lattice and a slightly different
	 * character; at this wavelength, under a 10px blur, it is not a difference
	 * anyone can see, and it is not worth a dependency.
	 */
	function hash3(x: number, y: number, z: number) {
		let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263) ^ Math.imul(z, 1274126177);
		h = Math.imul(h ^ (h >>> 13), 1274126177);
		return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
	}

	const fade = (t: number) => t * t * t * (t * (t * 6 - 15) + 10);
	const lerp = (a: number, b: number, t: number) => a + (b - a) * t;

	/** Returns roughly -1 to 1, so a ribbon swings either side of its line. */
	function noise3(x: number, y: number, z: number) {
		const xi = Math.floor(x);
		const yi = Math.floor(y);
		const zi = Math.floor(z);
		const u = fade(x - xi);
		const v = fade(y - yi);
		const w = fade(z - zi);

		const c000 = hash3(xi, yi, zi);
		const c100 = hash3(xi + 1, yi, zi);
		const c010 = hash3(xi, yi + 1, zi);
		const c110 = hash3(xi + 1, yi + 1, zi);
		const c001 = hash3(xi, yi, zi + 1);
		const c101 = hash3(xi + 1, yi, zi + 1);
		const c011 = hash3(xi, yi + 1, zi + 1);
		const c111 = hash3(xi + 1, yi + 1, zi + 1);

		const x00 = lerp(c000, c100, u);
		const x10 = lerp(c010, c110, u);
		const x01 = lerp(c001, c101, u);
		const x11 = lerp(c011, c111, u);

		return lerp(lerp(x00, x10, v), lerp(x01, x11, v), w) * 2 - 1;
	}

	/* The reference advances its phase by a fixed step per FRAME, which makes
	   it run twice as fast on a 120Hz display as on a 60Hz one. This is per
	   second instead, so the pace is the same everywhere. */
	const DRIFT_PER_SECOND = 0.12;

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

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

		let config: Config;
		try {
			config = JSON.parse(root.dataset.wbdConfig || "{}");
		} catch {
			return;
		}

		const ctx = canvas.getContext("2d", { alpha: false });
		if (!ctx) {
			/* No context, ever. The wash stays and the backdrop is complete
			   without the ribbons, so nothing is reported and nothing hidden. */
			root.dataset.wbdFallback = "";
			return;
		}

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

		let raf = 0;
		let running = false;
		let visible = true;
		let phase = 0;
		let last = 0;
		let w = 0;
		let h = 0;
		let dpr = 1;

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

		function resize() {
			/*
			 * The CANVAS box, not the window. The reference measures
			 * `window.innerWidth` and `window.innerHeight`, which is right only
			 * when the backdrop happens to be full-bleed — in a narrower
			 * container the same curve is squashed into it. Measured on the
			 * reference's own page: a 1440px buffer inside a 709px container.
			 */
			const rect = canvas!.getBoundingClientRect();
			dpr = Math.min(window.devicePixelRatio || 1, config.maxPixelRatio);
			w = Math.max(1, Math.round(rect.width));
			h = Math.max(1, Math.round(rect.height));
			const bw = Math.max(1, Math.round(rect.width * dpr));
			const bh = Math.max(1, Math.round(rect.height * dpr));
			if (canvas!.width !== bw || canvas!.height !== bh) {
				canvas!.width = bw;
				canvas!.height = bh;
				/* Drawing stays in CSS pixels, so waveWidth and amplitude mean
				   the same thing on any display. */
				ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
				return true;
			}
			return false;
		}

		function paintGround(alpha: number) {
			ctx!.globalAlpha = alpha;
			ctx!.fillStyle = config.backgroundFill;
			ctx!.fillRect(0, 0, w, h);
		}

		function drawRibbons() {
			const mid = h * config.center;
			/* One sample every 5px, as coarse as the curve can be before the
			   corners start to show under the blur. */
			const step = 5;
			ctx!.lineCap = "round";
			ctx!.lineJoin = "round";
			ctx!.lineWidth = config.waveWidth;
			for (let i = 0; i < config.waves; i++) {
				ctx!.beginPath();
				ctx!.strokeStyle = config.colors[i % config.colors.length];
				for (let x = 0; x <= w + step; x += step) {
					const y =
						noise3(x / config.wavelength, i * config.separation, phase) *
						config.amplitude;
					if (x === 0) ctx!.moveTo(x, mid + y);
					else ctx!.lineTo(x, mid + y);
				}
				ctx!.stroke();
			}
		}

		/*
		 * `still` is not just "one frame of the animation". The trail is what
		 * the partial-alpha ground gives, and a single partial fill over an
		 * empty canvas is a washed-out one instead. So a still draw lays an
		 * opaque ground and strokes once over it.
		 */
		function draw(still: boolean) {
			resize();
			paintGround(still ? 1 : config.waveOpacity);
			ctx!.globalAlpha = config.waveOpacity;
			drawRibbons();
			ctx!.globalAlpha = 1;
			delete root.dataset.wbdFallback;
			root.dataset.wbdReady = "";
		}

		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;
			phase += delta * DRIFT_PER_SECOND * config.speed;
			draw(false);
			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;
		}

		/* An opaque first frame, so the field does not fade up from a
		   half-transparent ground while the trail builds. */
		draw(true);

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

		/*
		 * A ResizeObserver on the canvas, not `window.onresize`. The reference
		 * assigns `window.onresize` directly, which replaces whatever else on
		 * the page was using it — and two of these on one page would leave only
		 * the second one working.
		 */
		const ro =
			typeof ResizeObserver === "function"
				? new ResizeObserver(() => {
						if (!running) draw(true);
					})
				: null;
		ro?.observe(canvas);

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

		/* A 2D context can be lost too, on a GPU reset or a driver change. */
		canvas.addEventListener(
			"contextlost",
			(event) => {
				event.preventDefault();
				stop();
				delete root.dataset.wbdReady;
			},
			{ signal },
		);
		canvas.addEventListener(
			"contextrestored",
			() => {
				draw(true);
				start();
			},
			{ signal },
		);

		if (!motion.matches) start();

		BOOTED.set(root, {
			destroy() {
				stop();
				controller.abort();
				io?.disconnect();
				ro?.disconnect();
				motion.removeEventListener?.("change", onMotion);
				delete root.dataset.wbdReady;
				delete root.dataset.wbdFallback;
				BOOTED.delete(root);
			},
		});
	}

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

	function teardownAll() {
		document.querySelectorAll<HTMLElement>("[data-wbd-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>