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.