Component library
Back to catalog

Dithered CTA

A large rounded closing card with a slowly marbling field of halftone dots behind the copy, and one button. The field speeds up while the card is hovered or holds focus.

ctaconversionwebglshaderditherhalftonecard

Open preview in a new tab

Use this component

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

Library page, for reference only:
https://astro.baysixmedia.com/components/dithered-cta/

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: "Dithered CTA"
Library page: https://astro.baysixmedia.com/components/dithered-cta/
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/DitheredCta.astro =====
---
/**
 * Dithered CTA
 *
 * A large rounded closing card with a slowly marbling field of halftone dots
 * behind the copy, and one button. The field speeds up while the card is
 * hovered or holds focus.
 *
 * The dots are a real ordered dither, not a texture: a domain-warped fbm field
 * thresholded through a 4x4 Bayer matrix on a fixed screen-pixel grid, so the
 * output is either the accent colour or nothing. That is what gives the hard
 * halftone edge instead of a soft gradient, and it is why this is a shader
 * rather than a stack of CSS gradients.
 *
 * Self-contained: no imports, no packages, no global stylesheet, no webfont,
 * no image, and no graphics library — raw WebGL 1.0 and about fifty lines of
 * GLSL written for this component. Every colour and measurement is a
 * `--dct-*` custom property on the root.
 */

interface Props {
	/** The small pill over the headline. `""` drops it. */
	badge?: string;
	/** First line of the headline. */
	headline?: string;
	/** Second line, set at 80% ink. `""` gives a one-line headline. */
	headlineTail?: string;
	/** Heading level, so it fits the page's outline. */
	headingLevel?: "h2" | "h3" | "h4";
	/** The paragraph under the headline. `""` drops it. */
	description?: string;
	/** The button's text. `""` drops the button. */
	actionLabel?: string;
	/** Where the button goes. Without a usable one the button is left out. */
	actionHref?: string;
	/** The page colour behind the card. */
	background?: string;
	/** The card's own ground, under the dots. */
	card?: string;
	/** Headline and button text on the accent. */
	ink?: string;
	/** The description. */
	mutedInk?: string;
	/** The card's hairline. */
	border?: string;
	/** The dot colour. Everything the shader draws is this one colour. */
	accent?: string;
	/** The button's ground. */
	buttonBg?: string;
	/** The button's text. */
	buttonInk?: string;
	/** Card corner radius. */
	radius?: string;
	/** The card's height floor. */
	minHeight?: string;
	/** Where the card stops widening. */
	maxWidth?: string;
	/** How much of the card the dots cover, 0 to 1. */
	dotOpacity?: number;
	/** Size of one dither cell in CSS pixels. 2 is the reference's. */
	dotSize?: number;
	/** How large the marbling is. Lower is broader. */
	scale?: number;
	/** Drift rate at rest. `0` is a still field. */
	speed?: number;
	/** Drift rate while hovered or focused. */
	hoverSpeed?: number;
	/** How far the field folds through itself. 0 is plain fbm. */
	warp?: number;
	/** Cap on the backing-store scale. */
	maxPixelRatio?: number;
	/** Added to the root. */
	class?: string;
	/** Root element id. */
	id?: string;
}

const {
	/* No sample copy and no placeholder destination: all of it is the caller's. */
	badge = "",
	headline = "",
	headlineTail = "",
	headingLevel = "h2",
	description = "",
	actionLabel = "",
	actionHref = "",
	background = "#ffffff",
	card = "#ffffff",
	ink = "#252525",
	mutedInk = "#737373",
	border = "#e5e5e5",
	accent = "#ec4e02",
	buttonBg = "#343434",
	buttonInk = "#fafafa",
	radius = "48px",
	minHeight = "600px",
	maxWidth = "1280px",
	dotOpacity = 0.4,
	dotSize = 2,
	scale = 1.4,
	speed = 0.2,
	hoverSpeed = 0.6,
	warp = 4,
	maxPixelRatio = 2,
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;

/* Only schemes that navigate. A CTA gets copied into projects where the
   destination comes from a content record rather than from the template.
   No usable destination drops the button rather than pointing it at "#". */
const SAFE_HREF = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const href =
	actionHref && SAFE_HREF.test(actionHref.trim()) ? actionHref.trim() : "";

/* A card with no headline and no description is only a button: render nothing. */
const hasContent = !!headline || !!description;

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

/* Clamped rather than trusted: these reach the shader, and a NaN uniform
   silently blanks the canvas rather than failing loudly. */
const config = {
	dotSize: Math.min(Math.max(num(dotSize, 2), 1), 16),
	scale: Math.min(Math.max(num(scale, 1.4), 0.2), 20),
	speed: Math.min(Math.max(num(speed, 0.2), 0), 5),
	hoverSpeed: Math.min(Math.max(num(hoverSpeed, 0.6), 0), 5),
	warp: Math.min(Math.max(num(warp, 4), 0), 12),
	maxPixelRatio: Math.min(Math.max(num(maxPixelRatio, 2), 1), 4),
};

const style = [
	`--dct-bg:${background}`,
	`--dct-card:${card}`,
	`--dct-ink:${ink}`,
	`--dct-muted:${mutedInk}`,
	`--dct-border:${border}`,
	`--dct-accent:${accent}`,
	`--dct-button-bg:${buttonBg}`,
	`--dct-button-ink:${buttonInk}`,
	`--dct-radius:${radius}`,
	`--dct-min-height:${minHeight}`,
	`--dct-max:${maxWidth}`,
	`--dct-dot-opacity:${Math.min(Math.max(num(dotOpacity, 0.4), 0), 1)}`,
].join(";");
---

{hasContent && (
<section
	class:list={["dithered-cta", className]}
	id={id}
	style={style}
	data-dct-root
	data-dct-config={JSON.stringify(config)}
>
	<div class="dct-inner">
		<div class="dct-card">
			{/*
			   Two layers, and only one of them is ever visible. The wash is a
			   CSS gradient that needs nothing; the canvas covers it once there
			   is a frame on it. So the card is never blank while the context
			   is being set up, and never blank if it cannot be.
			*/}
			<div class="dct-wash" aria-hidden="true"></div>
			<canvas class="dct-canvas" aria-hidden="true" data-dct-canvas></canvas>

			<div class="dct-content">
				{
					badge && (
						<p class="dct-badge">
							<span class="dct-dot" aria-hidden="true">
								<span class="dct-ping" />
								<span class="dct-core" />
							</span>
							{badge}
						</p>
					)
				}

				{
					headline && (
						<Heading class="dct-headline">
							{headline}
							{headlineTail && (
								<>
									<br />
									<span class="dct-headline-tail">{headlineTail}</span>
								</>
							)}
						</Heading>
					)
				}

				{description && <p class="dct-description">{description}</p>}

				{
					actionLabel && href && (
						<a class="dct-action" href={href}>
							<span>{actionLabel}</span>
							<svg
								class="dct-arrow"
								viewBox="0 0 24 24"
								width="20"
								height="20"
								aria-hidden="true"
								focusable="false"
							>
								<path
									fill="none"
									stroke="currentColor"
									stroke-width="1.8"
									stroke-linecap="round"
									stroke-linejoin="round"
									d="M5 12h13M12 5l7 7-7 7"
								/>
							</svg>
						</a>
					)
				}
			</div>
		</div>
	</div>
</section>
)}

<style>
	.dithered-cta {
		--dct-pad: 24px;
		--dct-pad-block: 48px;
		--dct-content-max: 896px;
		--dct-headline-size: 96px;
		--dct-description-size: 20px;

		/* One token, so reduced motion has a single place to reach. */
		--dct-duration: 300ms;
		--dct-ease: cubic-bezier(0.16, 1, 0.3, 1);

		/*
		 * A serif for the headline, and a system stack rather than a webfont:
		 * the component makes no network request, so there is nothing to wait
		 * for and no reflow when it arrives.
		 */
		--dct-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;

		display: block;
		box-sizing: border-box;
		/*
		 * The query container is the ROOT and it carries no padding. An element
		 * is never matched by its own container query, and `container-type:
		 * inline-size` queries the CONTENT box — so padding here would fire
		 * every breakpoint early by twice the padding, and because the padding
		 * itself steps at a breakpoint the box would grow as the window
		 * narrowed. Both traps are live in this repository's history.
		 */
		container-type: inline-size;
		background: var(--dct-bg);
		color: var(--dct-ink);
	}

	.dithered-cta *,
	.dithered-cta *::before,
	.dithered-cta *::after {
		box-sizing: border-box;
	}

	.dct-inner {
		max-inline-size: calc(var(--dct-max) + var(--dct-pad) * 2);
		margin-inline: auto;
		padding: var(--dct-pad-block) var(--dct-pad);
	}

	/* ---- the card -------------------------------------------------------- */

	.dct-card {
		position: relative;
		display: grid;
		place-items: center;
		min-block-size: var(--dct-min-height);
		/* Clips the dot field to the corner radius. */
		overflow: hidden;
		border: 1px solid var(--dct-border);
		border-radius: var(--dct-radius);
		background: var(--dct-card);
		box-shadow:
			0 1px 3px rgb(0 0 0 / 0.1),
			0 1px 2px -1px rgb(0 0 0 / 0.1);
	}

	/*
	 * The fallback. Three soft blooms of the accent, which is a reasonable
	 * reading of the field with the dither taken out — degraded, not missing.
	 * It is what shows before the context is ready, after teardown, and on a
	 * machine that cannot give a WebGL context at all.
	 */
	.dct-wash {
		position: absolute;
		inset: 0;
		opacity: calc(var(--dct-dot-opacity) * 0.55);
		background:
			radial-gradient(
				60% 70% at 18% 28%,
				var(--dct-accent) 0%,
				transparent 62%
			),
			radial-gradient(
				55% 65% at 82% 38%,
				var(--dct-accent) 0%,
				transparent 60%
			),
			radial-gradient(
				70% 60% at 45% 92%,
				var(--dct-accent) 0%,
				transparent 64%
			);
		mix-blend-mode: multiply;
		pointer-events: none;
	}

	.dct-canvas {
		position: absolute;
		inset: 0;
		inline-size: 100%;
		block-size: 100%;
		opacity: var(--dct-dot-opacity);
		/* Multiply is what keeps the dots reading as ink on the card rather
		   than as a film over it. */
		mix-blend-mode: multiply;
		pointer-events: none;
		/* Hidden until there is a frame on it, so the wash is what shows while
		   the context is being made — and stays showing if it never is. */
		visibility: hidden;
	}

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

	/* With the canvas up, the wash underneath would double the ink. */
	[data-dct-ready] .dct-wash {
		opacity: 0;
	}

	/* ---- content --------------------------------------------------------- */

	/*
	 * A calm zone behind the copy: the card colour fading out from the centre, so
	 * the words sit on paper while the halftone keeps the edges. A plain gradient
	 * on the content box, above the canvas and under the text.
	 */
	.dct-content::before {
		content: "";
		position: absolute;
		inset: -18% -14%;
		z-index: -1;
		background: radial-gradient(closest-side, var(--dct-card) 58%, color-mix(in srgb, var(--dct-card) 0%, transparent) 100%);
		pointer-events: none;
	}

	.dct-content {
		position: relative;
		z-index: 1;
		display: flex;
		flex-direction: column;
		align-items: center;
		max-inline-size: var(--dct-content-max);
		padding-inline: 24px;
		text-align: center;
	}

	.dct-badge {
		display: inline-flex;
		align-items: center;
		gap: 8px;
		margin: 0 0 32px;
		padding: 6px 16px;
		border: 1px solid color-mix(in srgb, var(--dct-ink) 10%, transparent);
		border-radius: 999px;
		background: color-mix(in srgb, var(--dct-ink) 5%, transparent);
		color: var(--dct-ink);
		font-size: 14px;
		font-weight: 500;
		line-height: 1.5;
		/* Reads over whatever density of dots happens to be behind it. */
		backdrop-filter: blur(8px);
	}

	.dct-dot {
		position: relative;
		display: inline-flex;
		inline-size: 8px;
		block-size: 8px;
		flex: none;
	}

	.dct-ping,
	.dct-core {
		position: absolute;
		inset: 0;
		border-radius: 50%;
		background: var(--dct-ink);
	}

	.dct-ping {
		opacity: 0.75;
		animation: dct-ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
	}

	@keyframes dct-ping {
		75%,
		100% {
			opacity: 0;
			transform: scale(2);
		}
	}

	.dct-headline {
		margin: 0 0 32px;
		color: var(--dct-ink);
		font-family: var(--dct-serif);
		font-size: var(--dct-headline-size);
		font-weight: 500;
		/* -2.4px on a 96px face. */
		letter-spacing: -0.025em;
		line-height: 1.05;
		text-wrap: balance;
	}

	/* The second line turns on type, not on a paler colour: same ink, set in italic. */
	.dct-headline-tail {
		font-style: italic;
	}

	.dct-description {
		max-inline-size: 672px;
		margin: 0 0 48px;
		color: color-mix(in srgb, var(--dct-muted) 55%, var(--dct-ink));
		font-size: var(--dct-description-size);
		line-height: 1.625;
	}

	.dct-action {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		gap: 12px;
		block-size: 56px;
		padding-inline: 48px;
		border-radius: 999px;
		background: var(--dct-button-bg);
		color: var(--dct-button-ink);
		font-size: 16px;
		font-weight: 500;
		text-decoration: none;
		transition:
			background-color var(--dct-duration) var(--dct-ease),
			transform var(--dct-duration) var(--dct-ease),
			box-shadow var(--dct-duration) var(--dct-ease);
	}

	.dct-action:hover {
		background: color-mix(in srgb, var(--dct-button-bg) 90%, transparent);
		box-shadow: 0 0 0 4px color-mix(in srgb, var(--dct-button-bg) 20%, transparent);
		transform: scale(1.05);
	}

	.dct-action:active {
		transform: scale(0.95);
	}

	.dct-arrow {
		display: block;
		transition: transform var(--dct-duration) var(--dct-ease);
	}

	.dct-action:hover .dct-arrow,
	.dct-action:focus-visible .dct-arrow {
		transform: translateX(4px);
	}

	/*
	 * Named on the class rather than through `:where(a)`. Astro cannot put its
	 * scope attribute inside `:where()`, so it adds a second one beside it and
	 * the selector stops matching — this library has shipped a focus ring that
	 * computed as `medium none currentColor` for exactly that reason.
	 */
	.dct-action:focus-visible {
		outline: 2px solid var(--dct-ink);
		outline-offset: 3px;
	}

	/* ---- responsive ------------------------------------------------------ */

	/* The reference steps the headline at 768 and again at 1024. */
	@container (max-width: 64rem) {
		.dct-inner {
			--dct-headline-size: 72px;
		}
	}

	@container (max-width: 48rem) {
		.dct-inner {
			--dct-pad: 16px;
			--dct-headline-size: 48px;
			--dct-description-size: 18px;
		}
	}

	/*
	 * Below this the reference keeps a 48px radius on a card barely 300px wide,
	 * which eats the corners of the content. This pulls it in instead.
	 */
	@container (max-width: 26rem) {
		.dct-inner {
			--dct-radius: 32px;
			--dct-headline-size: 38px;
		}

		.dct-content {
			padding-inline: 16px;
		}

		.dct-action {
			padding-inline: 32px;
		}
	}

	/* ---- 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.
		 */
		.dct-ping {
			animation: none;
		}

		.dithered-cta {
			--dct-duration: 0.01ms;
		}

		.dct-action:hover,
		.dct-action:active {
			transform: none;
		}

		.dct-action:hover .dct-arrow,
		.dct-action:focus-visible .dct-arrow {
			transform: none;
		}
	}

	@media (forced-colors: active) {
		/*
		 * Painted pixels do not answer a forced palette and neither does a CSS
		 * gradient, so both come off and the card is drawn in border terms.
		 */
		.dct-canvas,
		[data-dct-ready] .dct-canvas,
		.dct-wash,
		.dct-content::before {
			display: none;
		}

		.dct-card {
			border-color: CanvasText;
			background: Canvas;
		}

		.dct-badge {
			border-color: CanvasText;
			background: Canvas;
			backdrop-filter: none;
		}

		.dct-action {
			border: 1px solid CanvasText;
			background: Canvas;
			color: LinkText;
		}

		.dct-action:focus-visible {
			outline-color: Highlight;
		}
	}

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

		.dct-card {
			box-shadow: none;
		}

		.dct-action {
			border: 1px solid #000;
			background: transparent;
			color: #000;
		}
	}
</style>

<script>
	/*
	 * Dither field controller.
	 *
	 * One WebGL context per card. 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 = {
		dotSize: number;
		scale: number;
		speed: number;
		hoverSpeed: number;
		warp: number;
		maxPixelRatio: number;
	};

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

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

	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_scale;
uniform float u_cell;
uniform float u_warp;
uniform vec3 u_color;

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 < 4; i++) {
    sum += amp * vnoise(p);
    p *= 2.02;
    amp *= 0.5;
  }
  return sum;
}

/* The 2x2 Bayer matrix, as arithmetic. GLSL ES 1.0 will not index a matrix
   with a non-constant expression, so the 4x4 is built from the standard
   recursion instead of being looked up. */
float m2(vec2 a) {
  return mod(2.0 * a.x + 3.0 * a.y, 4.0);
}

float bayer4(vec2 p) {
  vec2 fine = mod(p, 2.0);
  vec2 coarse = mod(floor(p * 0.5), 2.0);
  return (4.0 * m2(fine) + m2(coarse)) / 16.0;
}

void main() {
  /* Field coordinates are normalised on the short side, so the marbling keeps
     its shape as the card changes proportion. */
  vec2 p = gl_FragCoord.xy / min(u_res.x, u_res.y) * u_scale;
  float t = u_time;

  vec2 q = vec2(
    fbm(p + vec2(0.0, t * 0.10)),
    fbm(p + vec2(5.2, 1.3) - vec2(0.0, t * 0.08))
  );
  vec2 r = vec2(
    fbm(p + u_warp * q + vec2(1.7, 9.2) + t * 0.06),
    fbm(p + u_warp * q + vec2(8.3, 2.8) - t * 0.05)
  );
  float v = fbm(p + u_warp * r);

  /* Shaped so most of the card stays clear and the ribbons read as ribbons. */
  v = smoothstep(0.24, 0.58, v);

  /* The dither grid is in DEVICE pixels and never scales with the field, which
     is what keeps the dots the same size at every card width. */
  vec2 cell = floor(gl_FragCoord.xy / u_cell);
  float threshold = bayer4(cell) + 0.5 / 16.0;

  float on = step(threshold, v);
  if (on < 0.5) discard;

  gl_FragColor = vec4(u_color, 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.93, 0.31, 0.01];
		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-dct-canvas]");
		const card = root.querySelector<HTMLElement>(".dct-card");
		if (!canvas || !card) return;

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

		const gl =
			canvas.getContext("webgl", {
				alpha: true,
				premultipliedAlpha: false,
				antialias: false,
				depth: false,
				stencil: false,
				powerPreference: "low-power",
			}) ||
			(canvas.getContext("experimental-webgl", {
				alpha: true,
				premultipliedAlpha: false,
			}) as WebGLRenderingContext | null);

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

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

		let program: WebGLProgram | null = null;
		let buffer: WebGLBuffer | null = null;
		let uRes: WebGLUniformLocation | null = null;
		let uTime: WebGLUniformLocation | null = null;
		let uScale: WebGLUniformLocation | null = null;
		let uCell: WebGLUniformLocation | null = null;
		let uWarp: WebGLUniformLocation | null = null;
		let uColor: WebGLUniformLocation | null = null;

		let raf = 0;
		let running = false;
		let visible = true;
		let elapsed = 0;
		let last = 0;
		let current = config.speed;
		let target = config.speed;
		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);

			uRes = gl.getUniformLocation(program, "u_res");
			uTime = gl.getUniformLocation(program, "u_time");
			uScale = gl.getUniformLocation(program, "u_scale");
			uCell = gl.getUniformLocation(program, "u_cell");
			uWarp = gl.getUniformLocation(program, "u_warp");
			uColor = gl.getUniformLocation(program, "u_color");
			return true;
		}

		function resize() {
			if (!gl) return;
			/*
			 * The CANVAS box, not the card's. The canvas sits inside the card's
			 * 1px border, so measuring the card makes the backing store 1.6px
			 * wider than the box it is painted into and the field is squashed
			 * by that much across the whole width.
			 */
			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);
			gl.uniform2f(uRes, canvas!.width, canvas!.height);
			gl.uniform1f(uTime, elapsed);
			gl.uniform1f(uScale, config.scale);
			/* Cell size is given in CSS pixels and multiplied up here, so a dot
			   is the same size on any display. */
			gl.uniform1f(uCell, Math.max(1, config.dotSize * dpr));
			gl.uniform1f(uWarp, config.warp);
			const accent = getComputedStyle(root).getPropertyValue("--dct-accent");
			const [r, g, b] = parseColor(accent.trim() || "#ec4e02");
			gl.uniform3f(uColor, r, g, b);

			gl.clearColor(0, 0, 0, 0);
			gl.clear(gl.COLOR_BUFFER_BIT);
			gl.drawArrays(gl.TRIANGLES, 0, 3);

			delete root.dataset.dctFallback;
			root.dataset.dctReady = "";
		}

		function frame(now: number) {
			if (!running) return;
			const delta = last ? Math.min((now - last) / 1000, 1 / 16) : 0;
			last = now;
			/* Eased towards the target rather than switched, so speeding up on
			   hover is a change of pace and not a jump. */
			current += (target - current) * Math.min(delta * 4, 1);
			elapsed += delta * current;
			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.dctFallback = "";
			controller.abort();
			return;
		}

		draw();

		/* Hover and focus both speed it up. Focus matters: the card holds a
		   link, and a keyboard user reaching it should get the same response a
		   pointer gets. */
		const hot = () => {
			target = config.hoverSpeed;
			start();
		};
		const cool = () => {
			target = config.speed;
		};
		card.addEventListener("pointerenter", hot, { signal });
		card.addEventListener("pointerleave", cool, { signal });
		card.addEventListener("focusin", hot, { signal });
		card.addEventListener("focusout", cool, { signal });

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

		/* On the root, not on the window: a card 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(card);

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

		canvas.addEventListener(
			"webglcontextlost",
			(event) => {
				event.preventDefault();
				stop();
				delete root.dataset.dctReady;
			},
			{ 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.dctReady;
				delete root.dataset.dctFallback;
				BOOTED.delete(root);
			},
		});
	}

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

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

===== FILE (supporting): src/components/DitheredCta.md =====
# Dithered CTA

A large rounded closing card with a slowly marbling field of halftone dots
behind the copy, and one button. The copy sits on a calm zone of card colour
that fades out towards the edges, so the words never sit on dense dots; the
headline's second line turns on italic, not on a paler ink. The field speeds
up while the card is hovered or holds focus.

Catalog page: `/components/dithered-cta`

## Copying it into another Astro project

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

| What it needs | Status |
| :--- | :--- |
| Imports from this catalog | none |
| npm packages | **none** — no shader library, no icon package |
| Astro built-ins (`astro:assets`, …) | none |
| Global stylesheet | none — every value is a `--dct-*` property on the root |
| Webfonts | none — system serif and sans stacks |
| Images | none — the arrow is an inline `<svg>` |
| Network requests | **none at all** |
| Host requirement | Astro compiles `.astro` files |

The dots are drawn with raw **WebGL 1.0** and about fifty lines of GLSL written
for this component. There is no three.js, no OGL, and no shader-component
package.

### Exact steps

1. Copy `DitheredCta.astro` into your components folder.
2. Use it at the end of a page:

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

<DitheredCta
  badge="Built for teams"
  headline="Ship the work,"
  headlineTail="skip the meeting."
  description="Everything your team already agreed on, in one place."
  actionLabel="Start building"
  actionHref="/signup/"
/>
```

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

**Every copy prop is empty by default.** The component carries no sample
product copy and no placeholder link: with no `headline` and no `description`
it renders nothing, and the button appears only when it has both an
`actionLabel` and a usable `actionHref` (`http(s):`, `mailto:`, `tel:`, or a
`/`, `#`, `.` or `?` path). The catalog demo passes its copy — and a `"#"`
href — as props.

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `badge` | `string` | `""` | The pill over the headline. Empty drops it. |
| `headline` | `string` | `""` | First line of the headline. |
| `headlineTail` | `string` | `""` | Second line, set at 80% ink. Empty gives one line. |
| `headingLevel` | `"h2" \| "h3" \| "h4"` | `"h2"` | So it fits your page outline. |
| `description` | `string` | `""` | The paragraph. Empty drops it. |
| `actionLabel` | `string` | `""` | Empty drops the button. |
| `actionHref` | `string` | `""` | Where the button goes. Empty or unsafe drops the button. |
| `background` | `string` | `"#ffffff"` | The page colour behind the card. |
| `card` | `string` | `"#ffffff"` | The card's ground, under the dots. |
| `ink` | `string` | `"#252525"` | Headline and badge. |
| `mutedInk` | `string` | `"#737373"` | The description. |
| `border` | `string` | `"#e5e5e5"` | The card's hairline. |
| `accent` | `string` | `"#ec4e02"` | **The dot colour.** Everything the shader draws is this. |
| `buttonBg` | `string` | `"#343434"` | The button's ground. |
| `buttonInk` | `string` | `"#fafafa"` | The button's text. |
| `radius` | `string` | `"48px"` | Card corner radius. |
| `minHeight` | `string` | `"600px"` | The card's height floor. |
| `maxWidth` | `string` | `"1280px"` | Where the card stops widening. |
| `dotOpacity` | `number` | `0.4` | How strongly the dots read, 0–1. |
| `dotSize` | `number` | `2` | One dither cell in CSS px. The reference's is 2. |
| `scale` | `number` | `1.4` | How large the marbling is. Lower is broader. |
| `speed` | `number` | `0.2` | Drift at rest. `0` is a still field. |
| `hoverSpeed` | `number` | `0.6` | Drift while hovered or focused. |
| `warp` | `number` | `4` | How far the field folds through itself. `0` is plain fbm. |
| `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 before it reaches the shader. A `NaN` uniform
does not throw — it silently blanks the canvas — so out-of-range values are
pulled into range rather than trusted.

## How the dots are made

Not a texture and not a stack of CSS gradients. Per pixel:

1. A **domain-warped fbm** field. Four octaves of value noise, warped twice
   through itself, which is what produces the marbled ribbons.
2. `smoothstep` shapes it so most of the card stays clear.
3. The screen is divided into cells of `dotSize` **device-corrected** pixels.
4. Each cell's value is compared against a **4×4 Bayer matrix** threshold.
5. Above threshold the pixel is the accent; below it, `discard`.

So the output is binary — accent or nothing — and the canvas is composited at
`dotOpacity` with `mix-blend-mode: multiply`. That is what gives the hard
halftone edge rather than a soft gradient.

The Bayer matrix is computed arithmetically from the standard 2×2 recursion
rather than looked up, because GLSL ES 1.0 will not index a matrix with a
non-constant expression.

The dither grid is in device pixels and never scales with the field, so a dot
is the same size at every card width and on every display.

## Tuning it

In the order they change the most:

- **`scale`** — 0.8 gives two or three broad ribbons; 4 gives fine turbulence.
- **`dotOpacity`** — how present the field is. 0.15 is a whisper.
- **`dotSize`** — 1 is nearly continuous tone; 6 is a coarse newsprint screen.
- **`warp`** — 0 is soft blobby fbm; 8 is tight marbling.
- **`speed` / `hoverSpeed`** — set both to `0` for a still field that still
  draws once.

## What it does when things go wrong

- **No WebGL, or no JavaScript at all.** The card carries a CSS wash — three
  soft blooms of the accent — which is a reasonable reading of the field with
  the dither taken out. Degraded, not missing. The canvas stays hidden until
  there is a frame on it, so the wash is what shows before boot, after
  teardown, and if a context can never be had. A failed boot sets
  `data-dct-fallback` on the root.
- **A lost context** — a GPU reset, a driver update, a laptop switching cards.
  The component drops its ready flag so the wash returns, waits for
  `webglcontextrestored`, rebuilds and carries on.
- **Scrolled out of view.** An IntersectionObserver stops the loop, with a
  120px margin so it is already running by the time it is looked at. Measured:
  zero frames requested while off screen.
- **A resized card.** A ResizeObserver on the card, not a listener on the
  window, so a card in a column that changes width while the window does not
  still follows.
- **A background tab.** `requestAnimationFrame` stops on its own, and the
  elapsed time is clamped to a sixteenth of a second per frame so a tab
  restored after a minute does not jump a minute's worth of drift.

## Accessibility

- The canvas is `aria-hidden` and `pointer-events: none`. It contributes
  nothing to the accessible tree.
- The button is a real **anchor**, so it works with JavaScript disabled and can
  be opened in a new tab or copied. The reference uses a `<button>` with no
  handler, which can do none of that.
- The focus ring is 2px at 3px offset, named on the class rather than through
  `:where()` — Astro cannot put its scope attribute inside `:where()`, and this
  library has shipped a focus ring that computed as `medium none currentColor`
  for exactly that reason.
- Hovering **and focusing** both speed the field up. A keyboard user reaching
  the button gets the same response a pointer does; the reference's is
  pointer-only.
- `prefers-reduced-motion: reduce` **draws one frame and leaves it standing** —
  measured at zero animation frames over 600ms, with the field still visible. A
  still field is the picture with the motion taken out, which is what the
  preference asks for. The pulsing badge dot stops and the button transitions
  collapse. The preference is re-read if it changes while the page is open.
- `forced-colors: active` removes the canvas and the wash — painted pixels do
  not answer a forced palette and neither does a CSS gradient — and redraws the
  card in border terms.
- `@media print` does the same and outlines the button.

## Cost

One fragment shader over a single full-screen triangle: four octaves of value
noise evaluated five times per pixel for the warp, and no texture sampling at
all. The context is requested with `powerPreference: "low-power"` so it stays
on the integrated GPU, and the backing store is capped at 2× rather than
following the device pixel ratio upward.

## Relationship to the reference

Rebuilt from a live preview by measurement. It is a React component in the
original, built on Tailwind, `lucide-react` and a third-party shader package;
**none of that is reproduced here** and none of those packages are installed.
The copy, the palette and the shader are this component's own.

Measured against the reference at 1440px and matched: the card at 1280×600 with
a 48px radius and a 600px floor, the headline at 96px on 100.8px with −2.4px of
tracking in a serif at weight 500, the description at 20px capped at 672px, the
56px button with 48px of side padding, and the badge at 14px with 6/16 padding
and an 8px blur. The type steps to 72px at 1024 and 48px at 768, as the
reference's does.

The dot field was matched by measurement rather than by eye. On a mid-density
patch both render **exactly two dominant colours** — white and
`rgb(247,184,154)`, which is `#EC4E02` at 40% multiplied onto white — and both
autocorrelate with a peak at **8px**, the 4×4 matrix at 2px cells. Whole-card
coverage is 29.6% here against the reference's 30.4%.

Three deliberate departures:

1. The action is an anchor, not a scripted button.
2. Focus speeds the field up, not just hover.
3. Reduced motion draws a still field; the reference has no reduced-motion
   handling at all.

The ribbons are not pixel-identical and are not meant to be — the reference's
field comes from a different noise implementation. What matches is the
technique, the halftone geometry and the density.

===== END src/components/DitheredCta.md =====

--- VERIFIED IN THE COMPONENT LIBRARY ---
Production-ready in the library.
Verified 2026-09-20 at 1920, 1440, 1280, 1100, 1024, 1000, 900, 834, 800, 768, 760, 600, 430, 416, 400, 390, 360, 320px.
Passed: full-page context, keyboard, no javascript, reduced motion, two instances, no overflow, declarations corroborated, lifecycle.
Library source fingerprint: sha256:faa2699b743d439cea3397ac7e02204b3fda9946abcea453d429c372c5519626 — 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
“Dithered CTA” from my Astro Component Library:
https://astro.baysixmedia.com/components/dithered-cta/

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

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

Component readiness

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

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

Production-ready component

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

Verified 2026-09-20 at 1920, 1440, 1280, 1100, 1024, 1000, 900, 834, 800, 768, 760, 600, 430, 416, 400, 390, 360, 320px

  • Full-page contextpass
  • Keyboardpass
  • No JavaScriptpass
  • Reduced motionpass
  • Two instancespass
  • No overflowpass
  • Declarations corroboratedpass
  • Lifecyclepass
24 verification notes
  • Fingerprint re-recorded 2026-09-24 (Step 15, library-wide content safety) after a no-behaviour content change: the default badge, headline, description and action became empty and moved into the registry demo props; a missing or unsafe href now drops the button instead of rendering href="#". Style and script blocks are byte-identical to the verified revision, and every preview, integration-test and template page was built before and after and compared — this component's pages are identical apart from per-build random ids. The no-data behaviour is pinned by rendering tests in src/lib/content-safety.test.ts (no props renders nothing; one prop; one nested field). No new full verification run was performed.
  • Rebuilt from a live preview by measurement. The original is a React component built on Tailwind, lucide-react and a third-party shader package; none of that is reproduced and none of those packages are installed. This library is Astro, so the integration steps the prompt carried - shadcn structure, a components/ui folder, an npm install - do not apply to it.
  • Matched at 1440px: the card at 1280x600 with a 48px radius, a 1px hairline and a 600px floor; the headline at 96px on 100.8px with -2.4px of tracking, weight 500, in a serif stack; the description at 20px capped at 672px; the button 56px tall with 48px of side padding and a 12px gap; the badge at 14px with 6/16 padding, an 8px backdrop blur and a 32px gap under it. The type steps to 72px at 1024 and to 48px with 18px description at 768, which is where the reference steps too.
  • The dot field was matched by measurement rather than by eye, and on three independent properties. On a mid-density patch both the reference and this render exactly two dominant colours - white and rgb(247,184,154), which is #EC4E02 at 40% multiplied onto white, so both are binary rather than shaded. Both autocorrelate with a peak at 8px, which is a 4x4 matrix at 2px cells. Whole-card coverage averages 29.6% here over three frames against the reference's 30.4%.
  • The dots are a real ordered dither, not a texture or a stack of gradients: a domain-warped fbm field thresholded through a 4x4 Bayer matrix on a fixed device-pixel grid, with everything below threshold discarded. The matrix is computed from the standard 2x2 recursion rather than looked up, because GLSL ES 1.0 will not index a matrix with a non-constant expression.
  • The ribbons are not pixel-identical to the reference's and are not meant to be - its field comes from a different noise implementation. What was matched is the technique, the halftone geometry and the density, each of them measured. Stated as an approximation rather than claimed as a copy.
  • Hover speeds the field up, and so does focus. Measured by comparing consecutive screenshots: the mean pixel change between frames is 2.28x higher while hovered than at rest, against a configured ratio of 3x - the metric under-reads because the output is binary and saturates. The frame rate does not change, only the pace of the field, which is what the measurement shows.
  • Focus speeding the field up is a departure from the reference, whose response is pointer-only. A keyboard user reaching the button now gets the same response a pointer does.
  • The action is an anchor rather than the reference's scripted button, so it works with JavaScript disabled, can be opened in a new tab and can be copied. The demo href is # and is a placeholder rather than a destination.
  • Reduced motion draws one frame and leaves it standing. Measured: zero animation frames requested over 600ms while the field is still visible and the ready flag is set, the badge dot's pulse is animation: none, and the button transitions collapse to 0.01ms. The reference has no reduced-motion handling at all.
  • Off screen costs nothing. Measured: zero animation frames requested over 600ms with the card scrolled out of view, and the loop resumes when it returns.
  • No JavaScript, and no WebGL: the card carries a CSS wash of three soft accent blooms, which is the field with the dither taken out. The canvas is hidden until there is a frame on it, so the wash is what shows before boot and if a context can never be had. Verified against the built markup - all copy present, the action a focusable anchor, the canvas hidden, the wash at 0.22 opacity, the card still 600px tall and no overflow.
  • Forced colours removes both the canvas and the wash, because painted pixels do not answer a forced palette and neither does a CSS gradient, and redraws the card in border terms. Measured under emulation: canvas and wash both display: none, card and badge on Canvas with CanvasText borders, the badge's blur off, the action LinkText on Canvas, the focus ring Highlight, and no text the colour of the ground it sits on.
  • Responsive by container query on the ROOT, with the padding on .dct-inner rather than on the root. Both are deliberate. An element is never matched by its own container query, and container-type: inline-size queries the CONTENT box - so padding on the container would fire every breakpoint early by twice the padding, and because the padding itself steps at a breakpoint the query box would grow as the window narrowed. Both traps are live in this repository's history and both are avoided here by construction.
  • Verified at eighteen widths from 1920 to 320: no page overflow at any of them, the card holding its 1280px cap and 600px floor throughout, and the backing store matching the canvas box at every width.
  • A sizing defect found and fixed during verification: the backing store was measured from the card's border box while the canvas sits inside the card's 1px border, so the buffer was 1.6px wider than the box it painted into and the field was squashed by that much across the whole width. It now measures the canvas.
  • Two live WebGL contexts on one page stay independent. The fixture's second instance declares a different accent and a speed of zero, and both boot, both mark themselves ready, neither falls back, each keeps its own backing store, and no id is duplicated.
  • Client-side navigation proven end to end against a real ClientRouter. Teardown is observed at astro:before-swap - the outgoing instance drops its ready flag - the root is a new node afterwards, the controller re-boots with a live backing store, and the interaction works. Across five transitions the animation frame count at a fixed scroll position went from 162 to 160, so no loop leaks and none accumulate.
  • The context is deliberately NOT released on teardown. Calling loseContext() there bricks the canvas for any later boot and a re-boot then falls through to the fallback permanently; the program and buffer are deleted instead. This is written down because it looks like a missing cleanup and is not.
  • Declarations corroborated against the rendered fixture: two focusable controls against hasFocusableContent, a running loop and a pulsing badge against hasMotion, the id prop reaching both roots against acceptsIdProp, and the ready flag on both against interactivity: scripted.
  • No console errors or warnings, and no external requests of any kind - no webfont, no image, no shader package. Eleven same-origin requests on the fixture, all of them the page's own assets.
  • 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.
  • The copy, the palette and the shader are original to this library. The reference's wording is not reproduced.
  • Chromium only. No WebKit or Gecko build was available in this session and no claim is made about either. The places that gap matters most here are WebGL 1.0, mix-blend-mode: multiply on a canvas, backdrop-filter on the badge and color-mix - the first three are long settled, but settled is not the same as observed.

Open the verification fixtureOpen the ClientRouter fixture

src/components/library/DitheredCta.astro

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

---
/**
 * Dithered CTA
 *
 * A large rounded closing card with a slowly marbling field of halftone dots
 * behind the copy, and one button. The field speeds up while the card is
 * hovered or holds focus.
 *
 * The dots are a real ordered dither, not a texture: a domain-warped fbm field
 * thresholded through a 4x4 Bayer matrix on a fixed screen-pixel grid, so the
 * output is either the accent colour or nothing. That is what gives the hard
 * halftone edge instead of a soft gradient, and it is why this is a shader
 * rather than a stack of CSS gradients.
 *
 * Self-contained: no imports, no packages, no global stylesheet, no webfont,
 * no image, and no graphics library — raw WebGL 1.0 and about fifty lines of
 * GLSL written for this component. Every colour and measurement is a
 * `--dct-*` custom property on the root.
 */

interface Props {
	/** The small pill over the headline. `""` drops it. */
	badge?: string;
	/** First line of the headline. */
	headline?: string;
	/** Second line, set at 80% ink. `""` gives a one-line headline. */
	headlineTail?: string;
	/** Heading level, so it fits the page's outline. */
	headingLevel?: "h2" | "h3" | "h4";
	/** The paragraph under the headline. `""` drops it. */
	description?: string;
	/** The button's text. `""` drops the button. */
	actionLabel?: string;
	/** Where the button goes. Without a usable one the button is left out. */
	actionHref?: string;
	/** The page colour behind the card. */
	background?: string;
	/** The card's own ground, under the dots. */
	card?: string;
	/** Headline and button text on the accent. */
	ink?: string;
	/** The description. */
	mutedInk?: string;
	/** The card's hairline. */
	border?: string;
	/** The dot colour. Everything the shader draws is this one colour. */
	accent?: string;
	/** The button's ground. */
	buttonBg?: string;
	/** The button's text. */
	buttonInk?: string;
	/** Card corner radius. */
	radius?: string;
	/** The card's height floor. */
	minHeight?: string;
	/** Where the card stops widening. */
	maxWidth?: string;
	/** How much of the card the dots cover, 0 to 1. */
	dotOpacity?: number;
	/** Size of one dither cell in CSS pixels. 2 is the reference's. */
	dotSize?: number;
	/** How large the marbling is. Lower is broader. */
	scale?: number;
	/** Drift rate at rest. `0` is a still field. */
	speed?: number;
	/** Drift rate while hovered or focused. */
	hoverSpeed?: number;
	/** How far the field folds through itself. 0 is plain fbm. */
	warp?: number;
	/** Cap on the backing-store scale. */
	maxPixelRatio?: number;
	/** Added to the root. */
	class?: string;
	/** Root element id. */
	id?: string;
}

const {
	/* No sample copy and no placeholder destination: all of it is the caller's. */
	badge = "",
	headline = "",
	headlineTail = "",
	headingLevel = "h2",
	description = "",
	actionLabel = "",
	actionHref = "",
	background = "#ffffff",
	card = "#ffffff",
	ink = "#252525",
	mutedInk = "#737373",
	border = "#e5e5e5",
	accent = "#ec4e02",
	buttonBg = "#343434",
	buttonInk = "#fafafa",
	radius = "48px",
	minHeight = "600px",
	maxWidth = "1280px",
	dotOpacity = 0.4,
	dotSize = 2,
	scale = 1.4,
	speed = 0.2,
	hoverSpeed = 0.6,
	warp = 4,
	maxPixelRatio = 2,
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;

/* Only schemes that navigate. A CTA gets copied into projects where the
   destination comes from a content record rather than from the template.
   No usable destination drops the button rather than pointing it at "#". */
const SAFE_HREF = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const href =
	actionHref && SAFE_HREF.test(actionHref.trim()) ? actionHref.trim() : "";

/* A card with no headline and no description is only a button: render nothing. */
const hasContent = !!headline || !!description;

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

/* Clamped rather than trusted: these reach the shader, and a NaN uniform
   silently blanks the canvas rather than failing loudly. */
const config = {
	dotSize: Math.min(Math.max(num(dotSize, 2), 1), 16),
	scale: Math.min(Math.max(num(scale, 1.4), 0.2), 20),
	speed: Math.min(Math.max(num(speed, 0.2), 0), 5),
	hoverSpeed: Math.min(Math.max(num(hoverSpeed, 0.6), 0), 5),
	warp: Math.min(Math.max(num(warp, 4), 0), 12),
	maxPixelRatio: Math.min(Math.max(num(maxPixelRatio, 2), 1), 4),
};

const style = [
	`--dct-bg:${background}`,
	`--dct-card:${card}`,
	`--dct-ink:${ink}`,
	`--dct-muted:${mutedInk}`,
	`--dct-border:${border}`,
	`--dct-accent:${accent}`,
	`--dct-button-bg:${buttonBg}`,
	`--dct-button-ink:${buttonInk}`,
	`--dct-radius:${radius}`,
	`--dct-min-height:${minHeight}`,
	`--dct-max:${maxWidth}`,
	`--dct-dot-opacity:${Math.min(Math.max(num(dotOpacity, 0.4), 0), 1)}`,
].join(";");
---

{hasContent && (
<section
	class:list={["dithered-cta", className]}
	id={id}
	style={style}
	data-dct-root
	data-dct-config={JSON.stringify(config)}
>
	<div class="dct-inner">
		<div class="dct-card">
			{/*
			   Two layers, and only one of them is ever visible. The wash is a
			   CSS gradient that needs nothing; the canvas covers it once there
			   is a frame on it. So the card is never blank while the context
			   is being set up, and never blank if it cannot be.
			*/}
			<div class="dct-wash" aria-hidden="true"></div>
			<canvas class="dct-canvas" aria-hidden="true" data-dct-canvas></canvas>

			<div class="dct-content">
				{
					badge && (
						<p class="dct-badge">
							<span class="dct-dot" aria-hidden="true">
								<span class="dct-ping" />
								<span class="dct-core" />
							</span>
							{badge}
						</p>
					)
				}

				{
					headline && (
						<Heading class="dct-headline">
							{headline}
							{headlineTail && (
								<>
									<br />
									<span class="dct-headline-tail">{headlineTail}</span>
								</>
							)}
						</Heading>
					)
				}

				{description && <p class="dct-description">{description}</p>}

				{
					actionLabel && href && (
						<a class="dct-action" href={href}>
							<span>{actionLabel}</span>
							<svg
								class="dct-arrow"
								viewBox="0 0 24 24"
								width="20"
								height="20"
								aria-hidden="true"
								focusable="false"
							>
								<path
									fill="none"
									stroke="currentColor"
									stroke-width="1.8"
									stroke-linecap="round"
									stroke-linejoin="round"
									d="M5 12h13M12 5l7 7-7 7"
								/>
							</svg>
						</a>
					)
				}
			</div>
		</div>
	</div>
</section>
)}

<style>
	.dithered-cta {
		--dct-pad: 24px;
		--dct-pad-block: 48px;
		--dct-content-max: 896px;
		--dct-headline-size: 96px;
		--dct-description-size: 20px;

		/* One token, so reduced motion has a single place to reach. */
		--dct-duration: 300ms;
		--dct-ease: cubic-bezier(0.16, 1, 0.3, 1);

		/*
		 * A serif for the headline, and a system stack rather than a webfont:
		 * the component makes no network request, so there is nothing to wait
		 * for and no reflow when it arrives.
		 */
		--dct-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;

		display: block;
		box-sizing: border-box;
		/*
		 * The query container is the ROOT and it carries no padding. An element
		 * is never matched by its own container query, and `container-type:
		 * inline-size` queries the CONTENT box — so padding here would fire
		 * every breakpoint early by twice the padding, and because the padding
		 * itself steps at a breakpoint the box would grow as the window
		 * narrowed. Both traps are live in this repository's history.
		 */
		container-type: inline-size;
		background: var(--dct-bg);
		color: var(--dct-ink);
	}

	.dithered-cta *,
	.dithered-cta *::before,
	.dithered-cta *::after {
		box-sizing: border-box;
	}

	.dct-inner {
		max-inline-size: calc(var(--dct-max) + var(--dct-pad) * 2);
		margin-inline: auto;
		padding: var(--dct-pad-block) var(--dct-pad);
	}

	/* ---- the card -------------------------------------------------------- */

	.dct-card {
		position: relative;
		display: grid;
		place-items: center;
		min-block-size: var(--dct-min-height);
		/* Clips the dot field to the corner radius. */
		overflow: hidden;
		border: 1px solid var(--dct-border);
		border-radius: var(--dct-radius);
		background: var(--dct-card);
		box-shadow:
			0 1px 3px rgb(0 0 0 / 0.1),
			0 1px 2px -1px rgb(0 0 0 / 0.1);
	}

	/*
	 * The fallback. Three soft blooms of the accent, which is a reasonable
	 * reading of the field with the dither taken out — degraded, not missing.
	 * It is what shows before the context is ready, after teardown, and on a
	 * machine that cannot give a WebGL context at all.
	 */
	.dct-wash {
		position: absolute;
		inset: 0;
		opacity: calc(var(--dct-dot-opacity) * 0.55);
		background:
			radial-gradient(
				60% 70% at 18% 28%,
				var(--dct-accent) 0%,
				transparent 62%
			),
			radial-gradient(
				55% 65% at 82% 38%,
				var(--dct-accent) 0%,
				transparent 60%
			),
			radial-gradient(
				70% 60% at 45% 92%,
				var(--dct-accent) 0%,
				transparent 64%
			);
		mix-blend-mode: multiply;
		pointer-events: none;
	}

	.dct-canvas {
		position: absolute;
		inset: 0;
		inline-size: 100%;
		block-size: 100%;
		opacity: var(--dct-dot-opacity);
		/* Multiply is what keeps the dots reading as ink on the card rather
		   than as a film over it. */
		mix-blend-mode: multiply;
		pointer-events: none;
		/* Hidden until there is a frame on it, so the wash is what shows while
		   the context is being made — and stays showing if it never is. */
		visibility: hidden;
	}

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

	/* With the canvas up, the wash underneath would double the ink. */
	[data-dct-ready] .dct-wash {
		opacity: 0;
	}

	/* ---- content --------------------------------------------------------- */

	/*
	 * A calm zone behind the copy: the card colour fading out from the centre, so
	 * the words sit on paper while the halftone keeps the edges. A plain gradient
	 * on the content box, above the canvas and under the text.
	 */
	.dct-content::before {
		content: "";
		position: absolute;
		inset: -18% -14%;
		z-index: -1;
		background: radial-gradient(closest-side, var(--dct-card) 58%, color-mix(in srgb, var(--dct-card) 0%, transparent) 100%);
		pointer-events: none;
	}

	.dct-content {
		position: relative;
		z-index: 1;
		display: flex;
		flex-direction: column;
		align-items: center;
		max-inline-size: var(--dct-content-max);
		padding-inline: 24px;
		text-align: center;
	}

	.dct-badge {
		display: inline-flex;
		align-items: center;
		gap: 8px;
		margin: 0 0 32px;
		padding: 6px 16px;
		border: 1px solid color-mix(in srgb, var(--dct-ink) 10%, transparent);
		border-radius: 999px;
		background: color-mix(in srgb, var(--dct-ink) 5%, transparent);
		color: var(--dct-ink);
		font-size: 14px;
		font-weight: 500;
		line-height: 1.5;
		/* Reads over whatever density of dots happens to be behind it. */
		backdrop-filter: blur(8px);
	}

	.dct-dot {
		position: relative;
		display: inline-flex;
		inline-size: 8px;
		block-size: 8px;
		flex: none;
	}

	.dct-ping,
	.dct-core {
		position: absolute;
		inset: 0;
		border-radius: 50%;
		background: var(--dct-ink);
	}

	.dct-ping {
		opacity: 0.75;
		animation: dct-ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
	}

	@keyframes dct-ping {
		75%,
		100% {
			opacity: 0;
			transform: scale(2);
		}
	}

	.dct-headline {
		margin: 0 0 32px;
		color: var(--dct-ink);
		font-family: var(--dct-serif);
		font-size: var(--dct-headline-size);
		font-weight: 500;
		/* -2.4px on a 96px face. */
		letter-spacing: -0.025em;
		line-height: 1.05;
		text-wrap: balance;
	}

	/* The second line turns on type, not on a paler colour: same ink, set in italic. */
	.dct-headline-tail {
		font-style: italic;
	}

	.dct-description {
		max-inline-size: 672px;
		margin: 0 0 48px;
		color: color-mix(in srgb, var(--dct-muted) 55%, var(--dct-ink));
		font-size: var(--dct-description-size);
		line-height: 1.625;
	}

	.dct-action {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		gap: 12px;
		block-size: 56px;
		padding-inline: 48px;
		border-radius: 999px;
		background: var(--dct-button-bg);
		color: var(--dct-button-ink);
		font-size: 16px;
		font-weight: 500;
		text-decoration: none;
		transition:
			background-color var(--dct-duration) var(--dct-ease),
			transform var(--dct-duration) var(--dct-ease),
			box-shadow var(--dct-duration) var(--dct-ease);
	}

	.dct-action:hover {
		background: color-mix(in srgb, var(--dct-button-bg) 90%, transparent);
		box-shadow: 0 0 0 4px color-mix(in srgb, var(--dct-button-bg) 20%, transparent);
		transform: scale(1.05);
	}

	.dct-action:active {
		transform: scale(0.95);
	}

	.dct-arrow {
		display: block;
		transition: transform var(--dct-duration) var(--dct-ease);
	}

	.dct-action:hover .dct-arrow,
	.dct-action:focus-visible .dct-arrow {
		transform: translateX(4px);
	}

	/*
	 * Named on the class rather than through `:where(a)`. Astro cannot put its
	 * scope attribute inside `:where()`, so it adds a second one beside it and
	 * the selector stops matching — this library has shipped a focus ring that
	 * computed as `medium none currentColor` for exactly that reason.
	 */
	.dct-action:focus-visible {
		outline: 2px solid var(--dct-ink);
		outline-offset: 3px;
	}

	/* ---- responsive ------------------------------------------------------ */

	/* The reference steps the headline at 768 and again at 1024. */
	@container (max-width: 64rem) {
		.dct-inner {
			--dct-headline-size: 72px;
		}
	}

	@container (max-width: 48rem) {
		.dct-inner {
			--dct-pad: 16px;
			--dct-headline-size: 48px;
			--dct-description-size: 18px;
		}
	}

	/*
	 * Below this the reference keeps a 48px radius on a card barely 300px wide,
	 * which eats the corners of the content. This pulls it in instead.
	 */
	@container (max-width: 26rem) {
		.dct-inner {
			--dct-radius: 32px;
			--dct-headline-size: 38px;
		}

		.dct-content {
			padding-inline: 16px;
		}

		.dct-action {
			padding-inline: 32px;
		}
	}

	/* ---- 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.
		 */
		.dct-ping {
			animation: none;
		}

		.dithered-cta {
			--dct-duration: 0.01ms;
		}

		.dct-action:hover,
		.dct-action:active {
			transform: none;
		}

		.dct-action:hover .dct-arrow,
		.dct-action:focus-visible .dct-arrow {
			transform: none;
		}
	}

	@media (forced-colors: active) {
		/*
		 * Painted pixels do not answer a forced palette and neither does a CSS
		 * gradient, so both come off and the card is drawn in border terms.
		 */
		.dct-canvas,
		[data-dct-ready] .dct-canvas,
		.dct-wash,
		.dct-content::before {
			display: none;
		}

		.dct-card {
			border-color: CanvasText;
			background: Canvas;
		}

		.dct-badge {
			border-color: CanvasText;
			background: Canvas;
			backdrop-filter: none;
		}

		.dct-action {
			border: 1px solid CanvasText;
			background: Canvas;
			color: LinkText;
		}

		.dct-action:focus-visible {
			outline-color: Highlight;
		}
	}

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

		.dct-card {
			box-shadow: none;
		}

		.dct-action {
			border: 1px solid #000;
			background: transparent;
			color: #000;
		}
	}
</style>

<script>
	/*
	 * Dither field controller.
	 *
	 * One WebGL context per card. 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 = {
		dotSize: number;
		scale: number;
		speed: number;
		hoverSpeed: number;
		warp: number;
		maxPixelRatio: number;
	};

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

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

	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_scale;
uniform float u_cell;
uniform float u_warp;
uniform vec3 u_color;

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 < 4; i++) {
    sum += amp * vnoise(p);
    p *= 2.02;
    amp *= 0.5;
  }
  return sum;
}

/* The 2x2 Bayer matrix, as arithmetic. GLSL ES 1.0 will not index a matrix
   with a non-constant expression, so the 4x4 is built from the standard
   recursion instead of being looked up. */
float m2(vec2 a) {
  return mod(2.0 * a.x + 3.0 * a.y, 4.0);
}

float bayer4(vec2 p) {
  vec2 fine = mod(p, 2.0);
  vec2 coarse = mod(floor(p * 0.5), 2.0);
  return (4.0 * m2(fine) + m2(coarse)) / 16.0;
}

void main() {
  /* Field coordinates are normalised on the short side, so the marbling keeps
     its shape as the card changes proportion. */
  vec2 p = gl_FragCoord.xy / min(u_res.x, u_res.y) * u_scale;
  float t = u_time;

  vec2 q = vec2(
    fbm(p + vec2(0.0, t * 0.10)),
    fbm(p + vec2(5.2, 1.3) - vec2(0.0, t * 0.08))
  );
  vec2 r = vec2(
    fbm(p + u_warp * q + vec2(1.7, 9.2) + t * 0.06),
    fbm(p + u_warp * q + vec2(8.3, 2.8) - t * 0.05)
  );
  float v = fbm(p + u_warp * r);

  /* Shaped so most of the card stays clear and the ribbons read as ribbons. */
  v = smoothstep(0.24, 0.58, v);

  /* The dither grid is in DEVICE pixels and never scales with the field, which
     is what keeps the dots the same size at every card width. */
  vec2 cell = floor(gl_FragCoord.xy / u_cell);
  float threshold = bayer4(cell) + 0.5 / 16.0;

  float on = step(threshold, v);
  if (on < 0.5) discard;

  gl_FragColor = vec4(u_color, 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.93, 0.31, 0.01];
		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-dct-canvas]");
		const card = root.querySelector<HTMLElement>(".dct-card");
		if (!canvas || !card) return;

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

		const gl =
			canvas.getContext("webgl", {
				alpha: true,
				premultipliedAlpha: false,
				antialias: false,
				depth: false,
				stencil: false,
				powerPreference: "low-power",
			}) ||
			(canvas.getContext("experimental-webgl", {
				alpha: true,
				premultipliedAlpha: false,
			}) as WebGLRenderingContext | null);

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

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

		let program: WebGLProgram | null = null;
		let buffer: WebGLBuffer | null = null;
		let uRes: WebGLUniformLocation | null = null;
		let uTime: WebGLUniformLocation | null = null;
		let uScale: WebGLUniformLocation | null = null;
		let uCell: WebGLUniformLocation | null = null;
		let uWarp: WebGLUniformLocation | null = null;
		let uColor: WebGLUniformLocation | null = null;

		let raf = 0;
		let running = false;
		let visible = true;
		let elapsed = 0;
		let last = 0;
		let current = config.speed;
		let target = config.speed;
		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);

			uRes = gl.getUniformLocation(program, "u_res");
			uTime = gl.getUniformLocation(program, "u_time");
			uScale = gl.getUniformLocation(program, "u_scale");
			uCell = gl.getUniformLocation(program, "u_cell");
			uWarp = gl.getUniformLocation(program, "u_warp");
			uColor = gl.getUniformLocation(program, "u_color");
			return true;
		}

		function resize() {
			if (!gl) return;
			/*
			 * The CANVAS box, not the card's. The canvas sits inside the card's
			 * 1px border, so measuring the card makes the backing store 1.6px
			 * wider than the box it is painted into and the field is squashed
			 * by that much across the whole width.
			 */
			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);
			gl.uniform2f(uRes, canvas!.width, canvas!.height);
			gl.uniform1f(uTime, elapsed);
			gl.uniform1f(uScale, config.scale);
			/* Cell size is given in CSS pixels and multiplied up here, so a dot
			   is the same size on any display. */
			gl.uniform1f(uCell, Math.max(1, config.dotSize * dpr));
			gl.uniform1f(uWarp, config.warp);
			const accent = getComputedStyle(root).getPropertyValue("--dct-accent");
			const [r, g, b] = parseColor(accent.trim() || "#ec4e02");
			gl.uniform3f(uColor, r, g, b);

			gl.clearColor(0, 0, 0, 0);
			gl.clear(gl.COLOR_BUFFER_BIT);
			gl.drawArrays(gl.TRIANGLES, 0, 3);

			delete root.dataset.dctFallback;
			root.dataset.dctReady = "";
		}

		function frame(now: number) {
			if (!running) return;
			const delta = last ? Math.min((now - last) / 1000, 1 / 16) : 0;
			last = now;
			/* Eased towards the target rather than switched, so speeding up on
			   hover is a change of pace and not a jump. */
			current += (target - current) * Math.min(delta * 4, 1);
			elapsed += delta * current;
			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.dctFallback = "";
			controller.abort();
			return;
		}

		draw();

		/* Hover and focus both speed it up. Focus matters: the card holds a
		   link, and a keyboard user reaching it should get the same response a
		   pointer gets. */
		const hot = () => {
			target = config.hoverSpeed;
			start();
		};
		const cool = () => {
			target = config.speed;
		};
		card.addEventListener("pointerenter", hot, { signal });
		card.addEventListener("pointerleave", cool, { signal });
		card.addEventListener("focusin", hot, { signal });
		card.addEventListener("focusout", cool, { signal });

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

		/* On the root, not on the window: a card 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(card);

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

		canvas.addEventListener(
			"webglcontextlost",
			(event) => {
				event.preventDefault();
				stop();
				delete root.dataset.dctReady;
			},
			{ 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.dctReady;
				delete root.dataset.dctFallback;
				BOOTED.delete(root);
			},
		});
	}

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

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