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.