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.