Component library
Back to catalog

Smoke CTA

A dark closing card with violet smoke rising through it, a headline, two actions, and a scrim that keeps the copy readable however bright the field gets.

ctaconversionwebglshadersmokedarkcard

Open preview in a new tab

Use this component

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

Library page, for reference only:
https://astro.baysixmedia.com/components/smoke-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: "Smoke CTA"
Library page: https://astro.baysixmedia.com/components/smoke-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/SmokeCta.astro =====
---
/**
 * Smoke CTA
 *
 * A dark closing card with violet smoke rising through it, and one button.
 * The smoke thickens while the card is hovered or holds focus.
 *
 * Continuous tone, deliberately — this is the opposite choice to the library's
 * Dithered CTA, which thresholds the same family of field through a Bayer
 * matrix to get hard halftone dots. Here the field is kept smooth and ramped
 * through three colours, so there is no visible pixel structure at all.
 *
 * Smooth gradients on a near-black ground band badly in 8 bits, so the shader
 * adds a hash of plus or minus half a level to the output. That is a thousand
 * times finer than the dither in the other component and is invisible as
 * texture; it exists only to break up the steps. See the note in the shader.
 *
 * Self-contained: no imports, no packages, no global stylesheet, no webfont,
 * no image, and no graphics library — raw WebGL 1.0 and about sixty lines of
 * GLSL written for this component. Every colour and measurement is a
 * `--smk-*` custom property on the root.
 */

interface Props {
	/** Small line over the headline. `""` drops it. */
	eyebrow?: string;
	/** First line of the headline. */
	headline?: string;
	/** Second line, set in the accent's light tone. `""` gives one line. */
	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 primary button's text. `""` drops it. */
	actionLabel?: string;
	/** Where the primary button goes. Without a usable one it is left out. */
	actionHref?: string;
	/** An optional quieter second action. `""` drops it. */
	secondaryLabel?: string;
	/** Where the second action goes. Without a usable one it is left out. */
	secondaryHref?: string;
	/** The page colour behind the card. */
	background?: string;
	/** The card's ground, under the smoke. */
	card?: string;
	/** Headline and primary button ink. */
	ink?: string;
	/** The description and the second action. */
	mutedInk?: string;
	/** The card's hairline. */
	border?: string;
	/** The body of the smoke. */
	accent?: string;
	/** The light in the thickest part of the smoke. */
	glow?: string;
	/** The primary button's ground. */
	buttonBg?: string;
	/** The primary 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 strongly the card's own colour is washed back over the smoke behind
	 * the copy, 0 to 1. This is what keeps the text legible over a field that
	 * is otherwise free to be as bright as it likes.
	 */
	scrim?: number;
	/** How much smoke there is, 0 to 2. */
	density?: number;
	/** How large the plumes are. Lower is broader. */
	scale?: number;
	/** How fast it rises at rest. `0` is a still field. */
	speed?: number;
	/** How fast it rises while hovered or focused. */
	hoverSpeed?: number;
	/** How far the field folds through itself. Higher is wispier. */
	warp?: number;
	/**
	 * How much the smoke gathers at the foot of the card. `0` fills it evenly,
	 * higher values keep it low and let it thin out towards the top.
	 */
	rise?: 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 destinations: all of it is the caller's. */
	eyebrow = "",
	headline = "",
	headlineTail = "",
	headingLevel = "h2",
	description = "",
	actionLabel = "",
	actionHref = "",
	secondaryLabel = "",
	secondaryHref = "",
	background = "#000000",
	card = "#050409",
	ink = "#f6f4ff",
	mutedInk = "#9a93b5",
	border = "#241a4d",
	accent = "#351e82",
	glow = "#8b6df0",
	buttonBg = "#f4f1ff",
	buttonInk = "#0b0715",
	radius = "32px",
	minHeight = "620px",
	maxWidth = "1200px",
	scrim = 0.82,
	density = 1,
	scale = 1.9,
	speed = 0.35,
	hoverSpeed = 0.8,
	warp = 4.4,
	rise = 1.5,
	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 that action rather than pointing it at "#". */
const SAFE_HREF = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (value?: string) =>
	value && SAFE_HREF.test(value.trim()) ? value.trim() : "";
const primaryHref = actionLabel ? safeHref(actionHref) : "";
const secondHref = secondaryLabel ? safeHref(secondaryHref) : "";

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

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

const clamp = (value: number, min: number, max: number) =>
	Math.min(Math.max(value, min), max);

/* Clamped rather than trusted: these reach the shader, and a NaN uniform does
   not throw — it silently blanks the canvas. */
const config = {
	density: clamp(num(density, 1), 0, 2),
	scale: clamp(num(scale, 1.9), 0.2, 20),
	speed: clamp(num(speed, 0.35), 0, 5),
	hoverSpeed: clamp(num(hoverSpeed, 0.8), 0, 5),
	warp: clamp(num(warp, 4.4), 0, 12),
	rise: clamp(num(rise, 1.5), 0, 6),
	maxPixelRatio: clamp(num(maxPixelRatio, 2), 1, 4),
};

const style = [
	`--smk-bg:${background}`,
	`--smk-card:${card}`,
	`--smk-ink:${ink}`,
	`--smk-muted:${mutedInk}`,
	`--smk-border:${border}`,
	`--smk-accent:${accent}`,
	`--smk-glow:${glow}`,
	`--smk-button-bg:${buttonBg}`,
	`--smk-button-ink:${buttonInk}`,
	`--smk-radius:${radius}`,
	`--smk-min-height:${minHeight}`,
	`--smk-max:${maxWidth}`,
	`--smk-scrim:${clamp(num(scrim, 0.82), 0, 1)}`,
].join(";");
---

{hasContent && (
<section
	class:list={["smoke-cta", className]}
	id={id}
	style={style}
	data-smk-root
	data-smk-config={JSON.stringify(config)}
>
	<div class="smk-inner">
		<div class="smk-card">
			{/*
			   Three layers under the copy, in order: a CSS plume that needs
			   nothing, the canvas that covers it once there is a frame on it,
			   and a scrim that washes the card's own colour back over whichever
			   of the two is showing. The scrim is what guarantees the text is
			   legible no matter how bright the smoke gets.
			*/}
			<div class="smk-plume" aria-hidden="true"></div>
			<canvas class="smk-canvas" aria-hidden="true" data-smk-canvas></canvas>
			<div class="smk-scrim" aria-hidden="true"></div>

			<div class="smk-content">
				{eyebrow && <p class="smk-eyebrow">{eyebrow}</p>}

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

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

				{
					(primaryHref || secondHref) && (
						<div class="smk-actions">
							{primaryHref && (
								<a class="smk-action" href={primaryHref}>
									<span>{actionLabel}</span>
									<svg
										class="smk-arrow"
										viewBox="0 0 24 24"
										width="18"
										height="18"
										aria-hidden="true"
										focusable="false"
									>
										<path
											fill="none"
											stroke="currentColor"
											stroke-width="1.9"
											stroke-linecap="round"
											stroke-linejoin="round"
											d="M5 12h13M12 5l7 7-7 7"
										/>
									</svg>
								</a>
							)}
							{secondHref && (
								<a class="smk-secondary" href={secondHref}>
									{secondaryLabel}
								</a>
							)}
						</div>
					)
				}
			</div>
		</div>
	</div>
</section>
)}

<style>
	.smoke-cta {
		--smk-pad: 24px;
		--smk-pad-block: 48px;
		--smk-content-max: 880px;
		--smk-eyebrow-size: 12px;
		--smk-headline-size: 76px;
		--smk-description-size: 19px;

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

		--smk-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
			"Liberation Mono", monospace;

		display: block;
		box-sizing: border-box;
		/*
		 * The query container is the ROOT and 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(--smk-bg);
		color: var(--smk-ink);
	}

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

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

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

	.smk-card {
		position: relative;
		display: grid;
		place-items: center;
		min-block-size: var(--smk-min-height);
		/* Clips the smoke to the corner radius. */
		overflow: hidden;
		border: 1px solid var(--smk-border);
		border-radius: var(--smk-radius);
		background: var(--smk-card);
	}

	/*
	 * The fallback. A plume of the accent rising from the foot of the card with
	 * a hotter core, which is a reasonable reading of the field with the
	 * turbulence taken out — degraded, not missing. It shows before boot, after
	 * teardown, and on a machine that cannot give a WebGL context at all.
	 */
	.smk-plume {
		position: absolute;
		inset: 0;
		background:
			radial-gradient(
				48% 42% at 50% 104%,
				var(--smk-glow) 0%,
				transparent 68%
			),
			radial-gradient(
				75% 62% at 28% 100%,
				var(--smk-accent) 0%,
				transparent 72%
			),
			radial-gradient(
				80% 66% at 76% 96%,
				var(--smk-accent) 0%,
				transparent 74%
			);
		opacity: 0.85;
		pointer-events: none;
	}

	.smk-canvas {
		position: absolute;
		inset: 0;
		inline-size: 100%;
		block-size: 100%;
		pointer-events: none;
		/*
		 * Hidden until there is a frame on it. The canvas is OPAQUE — it paints
		 * the card's ground itself rather than compositing over it — so an
		 * empty one would be a black rectangle covering the plume rather than
		 * nothing at all.
		 */
		visibility: hidden;
	}

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

	/* With the canvas up, the plume underneath is redundant and would show
	   through nothing — but it costs a composite, so it comes off. */
	[data-smk-ready] .smk-plume {
		opacity: 0;
	}

	/*
	 * The scrim, and it is doing real work rather than decoration. The smoke
	 * runs to a light violet at its thickest, and muted body text over that is
	 * about 1.3:1 — unreadable. This washes the card's own colour back over the
	 * middle of the card, where the copy is, and fades out before the edges so
	 * the smoke still reads there.
	 */
	.smk-scrim {
		position: absolute;
		inset: 0;
		background: radial-gradient(
			72% 58% at 50% 46%,
			color-mix(in srgb, var(--smk-card) calc(var(--smk-scrim) * 100%), transparent)
				0%,
			color-mix(
					in srgb,
					var(--smk-card) calc(var(--smk-scrim) * 62%),
					transparent
				)
				48%,
			transparent 78%
		);
		pointer-events: none;
	}

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

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

	/*
	 * Every string here arrives from a caller. `anywhere` rather than
	 * `break-word` because only `anywhere` also shrinks the min-content size,
	 * which is what stops a long word forcing a track open in the first place.
	 */
	.smk-eyebrow,
	.smk-headline,
	.smk-description,
	.smk-action,
	.smk-secondary {
		overflow-wrap: anywhere;
	}

	.smk-eyebrow {
		margin: 0 0 24px;
		color: var(--smk-glow);
		font-family: var(--smk-mono);
		font-size: var(--smk-eyebrow-size);
		font-weight: 500;
		letter-spacing: 0.16em;
		line-height: 1.4;
		text-transform: uppercase;
	}

	.smk-headline {
		margin: 0 0 24px;
		color: var(--smk-ink);
		font-size: var(--smk-headline-size);
		font-weight: 500;
		letter-spacing: -0.035em;
		line-height: 1.04;
		text-wrap: balance;
	}

	.smk-headline-tail {
		/* The second line takes the smoke's light tone, which ties the copy to
		   the field behind it without needing a gradient on the text. */
		color: var(--smk-glow);
	}

	.smk-description {
		max-inline-size: 620px;
		margin: 0 0 40px;
		color: var(--smk-muted);
		font-size: var(--smk-description-size);
		line-height: 1.65;
	}

	.smk-actions {
		display: flex;
		flex-wrap: wrap;
		align-items: center;
		justify-content: center;
		gap: 12px 28px;
	}

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

	.smk-action:hover {
		box-shadow: 0 0 0 5px color-mix(in srgb, var(--smk-glow) 32%, transparent);
		transform: translateY(-2px);
	}

	.smk-action:active {
		transform: translateY(0) scale(0.98);
	}

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

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

	.smk-secondary {
		display: inline-flex;
		align-items: center;
		block-size: 54px;
		color: var(--smk-muted);
		font-size: 16px;
		text-decoration: none;
		/* Underlined from the start rather than on hover: on a busy field a
		   link that looks like body text until you touch it is not a link. */
		text-decoration: underline;
		text-decoration-color: color-mix(in srgb, var(--smk-muted) 45%, transparent);
		text-underline-offset: 5px;
		transition: color var(--smk-duration) var(--smk-ease);
	}

	.smk-secondary:hover {
		color: var(--smk-ink);
		text-decoration-color: currentColor;
	}

	/*
	 * Named on the classes 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.
	 */
	.smk-action:focus-visible,
	.smk-secondary:focus-visible {
		outline: 2px solid var(--smk-glow);
		outline-offset: 4px;
		border-radius: 999px;
	}

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

	@container (max-width: 64rem) {
		.smk-inner {
			--smk-headline-size: 60px;
		}
	}

	@container (max-width: 48rem) {
		.smk-inner {
			--smk-pad: 16px;
			--smk-headline-size: 44px;
			--smk-description-size: 17px;
			--smk-min-height: 560px;
		}

		.smk-content {
			padding: 40px 20px;
		}
	}

	@container (max-width: 27rem) {
		.smk-inner {
			--smk-radius: 24px;
			--smk-headline-size: 34px;
			--smk-min-height: 520px;
		}

		.smk-actions {
			flex-direction: column;
			gap: 4px;
			inline-size: 100%;
		}

		.smk-action {
			inline-size: 100%;
		}
	}

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

	@media (prefers-reduced-motion: reduce) {
		/*
		 * The field is drawn once and left standing. Not blank and not hidden:
		 * a still plume 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.
		 */
		.smoke-cta {
			--smk-duration: 0.01ms;
		}

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

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

	@media (forced-colors: active) {
		/*
		 * Painted pixels do not answer a forced palette and neither does a CSS
		 * gradient, so the field comes off entirely and the card is drawn in
		 * border terms.
		 */
		.smk-canvas,
		[data-smk-ready] .smk-canvas,
		.smk-plume,
		.smk-scrim {
			display: none;
		}

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

		.smk-eyebrow,
		.smk-headline,
		.smk-headline-tail,
		.smk-description {
			color: CanvasText;
		}

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

		.smk-secondary {
			color: LinkText;
		}

		.smk-action:focus-visible,
		.smk-secondary:focus-visible {
			outline-color: Highlight;
		}
	}

	@media print {
		.smk-canvas,
		[data-smk-ready] .smk-canvas,
		.smk-plume,
		.smk-scrim {
			display: none;
		}

		.smoke-cta {
			background: #fff;
			color: #000;
		}

		.smk-card {
			border-color: #000;
			background: #fff;
		}

		.smk-eyebrow,
		.smk-headline,
		.smk-headline-tail,
		.smk-description,
		.smk-secondary {
			color: #000;
		}

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

<script>
	/*
	 * Smoke 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 = {
		density: number;
		scale: number;
		speed: number;
		hoverSpeed: number;
		warp: number;
		rise: 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_warp;
uniform float u_density;
uniform float u_rise;
uniform vec3 u_bg;
uniform vec3 u_accent;
uniform vec3 u_glow;

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

void main() {
  /* Normalised on HEIGHT, so a plume keeps its proportions as the card
     changes width instead of stretching with it. */
  vec2 p = gl_FragCoord.xy / u_res.y * u_scale;
  float t = u_time;

  /* Smoke rises, so the field is pulled downward through the sample point.
     The three layers move at different rates, which is what stops the whole
     thing sliding as one sheet. */
  vec2 flow = vec2(0.0, -t);

  vec2 q = vec2(
    fbm(p + flow * 0.30),
    fbm(p + flow * 0.26 + vec2(3.1, 1.7))
  );
  vec2 r = vec2(
    fbm(p + u_warp * q + flow * 0.18 + vec2(1.7, 9.2)),
    fbm(p + u_warp * q + flow * 0.14 + vec2(8.3, 2.8))
  );
  float d = fbm(p + u_warp * r + flow * 0.08);

  /* Soft shoulder, then a power curve: small bright cores with long thin
     tails, which is what reads as smoke rather than as cloud. */
  d = smoothstep(0.26, 0.92, d);
  d = pow(d, 1.7);

  /* It gathers at the foot of the card and thins upward, never quite to
     nothing so the top is haze rather than a hard edge. */
  float y = gl_FragCoord.y / u_res.y;
  d *= mix(0.12, 1.0, pow(1.0 - y, u_rise));
  d *= u_density;

  vec3 col = u_bg;
  col = mix(col, u_accent, clamp(d * 1.5, 0.0, 1.0));
  col = mix(col, u_glow, clamp(pow(d, 2.4) * 1.35, 0.0, 1.0));

  /*
   * Anti-banding, and NOT the halftone the sibling component uses. A smooth
   * violet ramp across a near-black card crosses only a few dozen 8-bit
   * levels, so it steps into visible contour lines. This adds a hash of plus
   * or minus half a level, which dissolves the steps and cannot be seen as
   * texture: the dither in Dithered CTA is a two-pixel cell at full accent
   * strength, this is a one-pixel cell at 1/255.
   */
  float n = hash(gl_FragCoord.xy + fract(t)) - 0.5;
  col += n / 255.0;

  gl_FragColor = vec4(col, 1.0);
}
`;

	function compile(gl: WebGLRenderingContext, type: number, source: string) {
		const shader = gl.createShader(type);
		if (!shader) return null;
		gl.shaderSource(shader, source);
		gl.compileShader(shader);
		if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
			gl.deleteShader(shader);
			return null;
		}
		return shader;
	}

	function parseColor(value: string, fallback: [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 fallback;
		return [Number(m[0]) / 255, Number(m[1]) / 255, Number(m[2]) / 255] as [
			number,
			number,
			number,
		];
	}

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

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

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

		const attrs = {
			alpha: false,
			antialias: false,
			depth: false,
			stencil: false,
			powerPreference: "low-power" as const,
		};
		/* Cast over the whole expression, not the second half. With `attrs`
		   hoisted into a variable TypeScript cannot resolve getContext's
		   literal overload and falls back to the one returning
		   RenderingContext, which has none of the GL methods on it. */
		const gl = (canvas.getContext("webgl", attrs) ||
			canvas.getContext("experimental-webgl", attrs)) as
			| WebGLRenderingContext
			| null;

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

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

		let program: WebGLProgram | null = null;
		let buffer: WebGLBuffer | null = null;
		const u: Record<string, WebGLUniformLocation | null> = {};

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

			for (const name of [
				"u_res",
				"u_time",
				"u_scale",
				"u_warp",
				"u_density",
				"u_rise",
				"u_bg",
				"u_accent",
				"u_glow",
			]) {
				u[name] = gl.getUniformLocation(program, name);
			}
			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 wider
			   than the box it is painted into and the field is squashed. */
			const rect = canvas!.getBoundingClientRect();
			dpr = Math.min(window.devicePixelRatio || 1, config.maxPixelRatio);
			const w = Math.max(1, Math.round(rect.width * dpr));
			const h = Math.max(1, Math.round(rect.height * dpr));
			if (canvas!.width !== w || canvas!.height !== h) {
				canvas!.width = w;
				canvas!.height = h;
			}
			gl.viewport(0, 0, w, h);
		}

		function draw() {
			if (!gl || !program) return;
			resize();
			gl.useProgram(program);

			const cs = getComputedStyle(root);
			const bg = parseColor(cs.getPropertyValue("--smk-card").trim(), [0.02, 0.016, 0.035]);
			const accent = parseColor(cs.getPropertyValue("--smk-accent").trim(), [0.208, 0.118, 0.51]);
			const glow = parseColor(cs.getPropertyValue("--smk-glow").trim(), [0.545, 0.427, 0.941]);

			gl.uniform2f(u.u_res, canvas!.width, canvas!.height);
			gl.uniform1f(u.u_time, elapsed);
			gl.uniform1f(u.u_scale, config.scale);
			gl.uniform1f(u.u_warp, config.warp);
			gl.uniform1f(u.u_density, config.density);
			gl.uniform1f(u.u_rise, config.rise);
			gl.uniform3f(u.u_bg, bg[0], bg[1], bg[2]);
			gl.uniform3f(u.u_accent, accent[0], accent[1], accent[2]);
			gl.uniform3f(u.u_glow, glow[0], glow[1], glow[2]);

			gl.drawArrays(gl.TRIANGLES, 0, 3);

			delete root.dataset.smkFallback;
			root.dataset.smkReady = "";
		}

		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 thickening on
			   hover is a change of pace and not a jump. */
			current += (target - current) * Math.min(delta * 3.5, 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.smkFallback = "";
			controller.abort();
			return;
		}

		draw();

		/* Hover and focus both thicken it. Focus matters: the card holds two
		   links, and a keyboard user reaching them 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 card, 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.smkReady;
			},
			{ 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.smkReady;
				delete root.dataset.smkFallback;
				BOOTED.delete(root);
			},
		});
	}

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

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

===== FILE (supporting): src/components/SmokeCta.md =====
# Smoke CTA

A dark closing card with violet smoke rising through it, a headline, two
actions, and a scrim that keeps the copy readable however bright the field
gets.

Catalog page: `/components/smoke-cta`

## Copying it into another Astro project

Copy **`SmokeCta.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 `--smk-*` property on the root |
| Webfonts | none — system sans and mono stacks |
| Images | none — the arrow is an inline `<svg>` |
| Network requests | **none at all** |
| Host requirement | Astro compiles `.astro` files |

Raw **WebGL 1.0** and about sixty lines of GLSL written for this component. No
three.js, no OGL, no shader-component package.

### Exact steps

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

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

<SmokeCta
  eyebrow="Now in open beta"
  headline="Everything you shipped,"
  headlineTail="finally in one place."
  description="One timeline for the work and the decisions behind it."
  actionLabel="Start free"
  actionHref="/signup/"
  secondaryLabel="Read the docs"
  secondaryHref="/docs/"
/>
```

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 links: with no `headline` and no
`description` it renders nothing, and each action appears only when it has
both a label and a usable href (`http(s):`, `mailto:`, `tel:`, or a `/`, `#`,
`.` or `?` path). The catalog demo passes its copy — and `"#"` hrefs — as
props.

## Sibling component

[Dithered CTA](./DitheredCta.md) is the same idea with the opposite surface: it
takes a field of this family and thresholds it through a 4×4 Bayer matrix, so
it comes out as hard halftone dots on white. This one keeps the field smooth
and ramps it through three colours, so there is no pixel structure at all. Pick
by which surface you want, not by which field.

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `eyebrow` | `string` | `""` | Mono line over the headline. Empty drops it. |
| `headline` | `string` | `""` | First line. |
| `headlineTail` | `string` | `""` | Second line, in the glow tone. Empty gives one line. |
| `headingLevel` | `"h2" \| "h3" \| "h4"` | `"h2"` | So it fits your page outline. |
| `description` | `string` | `""` | Empty drops it. |
| `actionLabel` | `string` | `""` | Primary button. Empty drops it. |
| `actionHref` | `string` | `""` | Where it goes. Empty or unsafe drops the button. |
| `secondaryLabel` | `string` | `""` | Quieter second action. Empty drops it. |
| `secondaryHref` | `string` | `""` | Where it goes. Empty or unsafe drops the action. |
| `background` | `string` | `"#000000"` | The page behind the card. |
| `card` | `string` | `"#050409"` | The card's ground, under the smoke. |
| `ink` | `string` | `"#f6f4ff"` | Headline. |
| `mutedInk` | `string` | `"#9a93b5"` | Description and second action. |
| `border` | `string` | `"#241a4d"` | The card's hairline. |
| `accent` | `string` | `"#351e82"` | **The body of the smoke.** |
| `glow` | `string` | `"#8b6df0"` | The light in its thickest parts. Also the eyebrow and headline tail. |
| `buttonBg` | `string` | `"#f4f1ff"` | Primary button ground. |
| `buttonInk` | `string` | `"#0b0715"` | Primary button text. |
| `radius` | `string` | `"32px"` | Card corner radius. |
| `minHeight` | `string` | `"620px"` | The card's height floor. |
| `maxWidth` | `string` | `"1200px"` | Where the card stops widening. |
| `scrim` | `number` | `0.82` | How hard the card's colour is washed back over the middle. See below. |
| `density` | `number` | `1` | How much smoke there is, 0–2. |
| `scale` | `number` | `1.9` | Plume size. Lower is broader. |
| `speed` | `number` | `0.35` | Rise rate at rest. `0` is a still field. |
| `hoverSpeed` | `number` | `0.8` | Rise rate while hovered or focused. |
| `warp` | `number` | `4.4` | How far the field folds through itself. Higher is wispier. |
| `rise` | `number` | `1.5` | How much it gathers at the foot. `0` fills evenly. |
| `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 smoke is made

Per pixel, with no texture sampling anywhere:

1. **Domain-warped fbm.** Five octaves of value noise, warped twice through
   itself. The three layers are pulled downward through the sample point at
   *different rates*, which is what makes it rise without sliding as one sheet.
2. `smoothstep` for a soft shoulder, then `pow(d, 1.7)` — small bright cores
   with long thin tails, which is what reads as smoke rather than as cloud.
3. A vertical gather so it pools at the foot of the card and thins upward,
   never quite to nothing, so the top is haze rather than a hard edge.
4. A three-stop ramp: card → `accent` → `glow`.

The field is normalised on **height**, so a plume keeps its proportions as the
card changes width instead of stretching with it.

### Why there is a dither in here, and why it is not the other component's

A smooth violet ramp across a near-black card crosses only a few dozen 8-bit
levels, so it steps into visible contour lines. The shader adds a per-pixel
hash of **± half a level** to the output, which dissolves the steps.

That is not the halftone in Dithered CTA and cannot be confused with it: that
one is a two-pixel cell at full accent strength and is the entire visual idea;
this is a one-pixel cell at 1/255 and is invisible as texture. Measured on the
rendered card, a 200×140 patch of smoke contains **1,483 distinct colours**,
with the two commonest covering 1% of it, and its autocorrelation decays
monotonically with no peak at any shift. The dithered sibling spikes at 8px.

## The scrim is doing real work

At its brightest the smoke reaches the glow tone, and muted body text over that
is about **1.3:1** — unreadable. The `.smk-scrim` layer washes the card's own
colour back over the middle of the card, where the copy is, and fades out
before the edges so the smoke still reads there.

Measured against the field actually behind each block:

| | Contrast |
| :--- | :--- |
| Headline | 18.8:1 (AAA) |
| Headline tail | 5.4:1 (AA) |
| Description | 7.0:1 (AAA) |
| Eyebrow | 5.4:1 (AA) |

Turn `scrim` down and you get more smoke and less contrast. If you recolour the
component, re-check those numbers rather than assuming they hold — the scrim is
tuned to this palette.

## Tuning it

In the order they change the most:

- **`rise`** — 0 fills the card evenly; 3 keeps it low and moody.
- **`scale`** — 0.8 gives two or three huge plumes; 5 gives fine wisps.
- **`density`** — 0.4 is a hint of haze; 1.6 is a full room.
- **`warp`** — 0 is soft blobby cloud; 8 is torn and filamentary.
- **`speed` / `hoverSpeed`** — both `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 plume rising
  from its foot, which is the field with the turbulence taken out. Degraded,
  not missing. The canvas is hidden until there is a frame on it — it is
  **opaque**, painting the card's ground itself, so an empty one would be a
  black rectangle covering the plume rather than nothing. A failed boot sets
  `data-smk-fallback` on the root.
- **A lost context.** Drops the ready flag so the plume returns, waits for
  `webglcontextrestored`, rebuilds and carries on.
- **Scrolled out of view.** An IntersectionObserver stops the loop, with a
  120px margin. Measured: zero frames requested while off screen.
- **A resized card.** A ResizeObserver on the card, not the window.
- **A background tab.** `requestAnimationFrame` stops on its own, and elapsed
  time is clamped to a sixteenth of a second per frame so a tab restored after
  a minute does not jump a minute's worth of drift.

## Accessibility

- The canvas, plume and scrim are all `aria-hidden` and `pointer-events: none`.
- Both actions are real **anchors**, so they work with JavaScript disabled and
  can be opened in a new tab or copied.
- The second action is underlined from the start, not on hover. On a busy field
  a link that looks like body text until you touch it is not a link.
- The focus ring is 2px in the glow tone at 4px offset, named on the classes
  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 thicken the smoke, so a keyboard user gets the
  same response a pointer does.
- `prefers-reduced-motion: reduce` **draws one frame and leaves it standing** —
  measured at zero animation frames over 600ms with the field still visible.
- `forced-colors: active` removes the canvas, the plume *and* the scrim, and
  redraws the card in border terms. The headline tail and eyebrow are returned
  to `CanvasText`; left in the glow tone they would be unreadable once the
  field behind them is gone.
- `@media print` inverts to black on white and outlines the button.

## Cost

Five octaves of value noise evaluated five times per pixel for the warp — about
25 noise samples — over a single full-screen triangle, with no texture reads.
The context is requested with `powerPreference: "low-power"` and the backing
store is capped at 2× rather than following the device pixel ratio upward.

Measured on **integrated Intel UHD Graphics** through ANGLE/D3D11, at 2.87
megapixels — what a 2× display demands at the card's normal size — the median
frame was **12.1ms**, so roughly 83fps with headroom over 60. At 1× it was
6.1ms. A discrete GPU will be well clear of this.

If you need it cheaper: drop `maxPixelRatio` to 1, or `scale` down, which does
not reduce the sample count but makes the result read fine at a smaller backing
store.

## Relationship to the reference

This one has no reference. It was asked for directly — black ground, `#351e82`
accent, smoke rather than halftone — and the palette beyond the accent, the
composition, the copy and the shader are all original to this library.

The controller is the same shape as [Dithered CTA](./DitheredCta.md)'s, because
that shape was already verified here: ready-flag handoff from a CSS fallback,
IntersectionObserver pause, ResizeObserver, context loss and restore, a DPR
cap, reduced-motion handling, and an AbortController teardown that survives a
ClientRouter swap. The field and the surface are entirely different.

===== END src/components/SmokeCta.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, 440, 432, 400, 390, 360, 320px.
Passed: full-page context, keyboard, no javascript, reduced motion, two instances, no overflow, declarations corroborated, lifecycle.
Library source fingerprint: sha256:847edd4c89f2549fce4fa4be37db7d798edda8f4728e69e2a614f4bae3a894e2 — 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
“Smoke CTA” from my Astro Component Library:
https://astro.baysixmedia.com/components/smoke-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, 440, 432, 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 eyebrow, headline, description and both actions 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.
  • Built to a brief rather than from a reference: black ground, #351e82 accent, smoke rather than halftone. The palette beyond the accent, the composition, the copy and the shader are all original to this library.
  • Continuous tone, and measured to be so rather than asserted. A 200x140 patch of smoke on the rendered card contains 1483 distinct colours with the two commonest covering 1% of it, and its autocorrelation decays monotonically - 0.981, 0.966, 0.950, 0.936 - with no peak at any shift. The library's Dithered CTA, measured the same way, spikes at 8px and is dominated by two colours. There is no pixel structure here.
  • There IS a dither in the shader and it is not that one. A smooth violet ramp across a near-black card crosses only a few dozen 8-bit levels and steps into contour lines, so the output carries a per-pixel hash of plus or minus half a level. It is a one-pixel cell at 1/255 against the sibling's two-pixel cell at full strength. Checked on a ray across the card: every flat run of identical luminance is either the button or the exact card colour where there is no smoke at all, so there is no banding to see.
  • The scrim is load-bearing, not decoration. At its brightest the smoke reaches the glow tone and muted body text over that is about 1.3:1, which is unreadable. A radial wash of the card's own colour over the middle of the card fixes it without flattening the edges. Measured against the field actually behind each block: headline 18.8:1, headline tail 5.4:1, description 7.0:1, eyebrow 5.4:1 - all passing, the two 5.4s at AA and the others at AAA.
  • Because the scrim is tuned to this palette, recolouring the component is a reason to re-measure those four numbers rather than assume they hold. Said in the README as well as here.
  • Performance measured rather than asserted, and on a modest GPU: integrated Intel UHD Graphics through ANGLE and D3D11. At 2.87 megapixels - what a 2x display demands at the card's normal size - the median frame was 12.1ms and the 95th percentile 24.3ms, so about 83fps with headroom over 60. At 1x and 0.74 megapixels the median was 6.1ms. Five octaves evaluated five times per pixel for the warp is around 25 noise samples and no texture reads.
  • Hover and focus both thicken the field, by raising the rise rate rather than the frame rate. Focus is a deliberate addition: the card holds two links and a keyboard user reaching them gets the same response a pointer does.
  • Both actions are real anchors, so they work with JavaScript disabled and can be opened in a new tab or copied. The demo hrefs are # and are placeholders rather than destinations. The second action is underlined from the start rather than on hover, because on a busy field a link that looks like body text until you touch it is not a link.
  • Reduced motion draws one frame and leaves it standing. Measured: zero animation frames requested over 600ms while the canvas is still visible and carrying pixels and the ready flag is set, with the button transitions collapsed to 0.01ms.
  • 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 plume rising from its foot, which is the field with the turbulence taken out. Verified against the built markup - no ready flag, all four copy blocks present, both actions anchors and not buttons, one canvas, one plume, and the scrim. The canvas is hidden until there is a frame on it, which matters more here than in the sibling because this canvas is OPAQUE: it paints the card's ground itself, so an empty one would be a black rectangle covering the plume rather than nothing.
  • Forced colours removes the canvas, the plume AND the scrim, then redraws the card in border terms. Measured under emulation: all three display: none, card and button on Canvas with CanvasText borders, actions LinkText, focus ring Highlight, and every text colour returned to CanvasText - including the headline tail and the eyebrow, which are the glow tone normally and would be unreadable once the field behind them is gone.
  • Responsive by container query on the ROOT, with the padding on .smk-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 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 1200px cap, the headline stepping 76 to 60 to 44 to 34, the actions stacking to full width below 432, and the backing store matching the canvas box at every width.
  • The backing store is measured from the CANVAS box rather than the card's. The canvas sits inside the card's 1px border, so measuring the card makes the buffer wider than the box it paints into and squashes the field - a defect found and fixed in the sibling component during its own verification, and avoided here from the start.
  • Two live WebGL contexts on one page stay independent. The fixture's second instance declares a different accent and glow, a speed of zero and no vertical gather, 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 with the outgoing instance dropping its ready flag, the root is a new node afterwards, the controller re-boots with a live backing store, and across five transitions the animation frame count at a fixed scroll position went from 198 to 200 - 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. Written down because it looks like a missing cleanup and is not.
  • A typing defect found during the check run: hoisting the context attributes into a variable stopped TypeScript resolving getContext's literal overload, so the context typed as RenderingContext and forty call sites lost their methods. The build did not care - it type-strips - but astro check did, which is the point of running both. Fixed by casting the whole expression.
  • Declarations corroborated against the rendered fixture: four focusable controls against hasFocusableContent, a running loop against hasMotion, the id prop reaching both roots against acceptsIdProp, and the ready flag on both against interactivity: scripted.
  • No console errors or warnings, and no external requests of any kind - no webfont, no image, no shader package.
  • Enrolled with no profile, so it owes the core checks and nothing is invented for it: it is neither a carousel nor navigation. It carries its own driver contract, which the fixture publishes and which was verified to resolve against the component.
  • Chromium only. No WebKit or Gecko build was available in this session and no claim is made about either. The places that gap matters most here are WebGL 1.0 and color-mix, the first long settled and the second now broadly supported, but settled is not the same as observed.

Open the verification fixtureOpen the ClientRouter fixture

src/components/library/SmokeCta.astro

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

---
/**
 * Smoke CTA
 *
 * A dark closing card with violet smoke rising through it, and one button.
 * The smoke thickens while the card is hovered or holds focus.
 *
 * Continuous tone, deliberately — this is the opposite choice to the library's
 * Dithered CTA, which thresholds the same family of field through a Bayer
 * matrix to get hard halftone dots. Here the field is kept smooth and ramped
 * through three colours, so there is no visible pixel structure at all.
 *
 * Smooth gradients on a near-black ground band badly in 8 bits, so the shader
 * adds a hash of plus or minus half a level to the output. That is a thousand
 * times finer than the dither in the other component and is invisible as
 * texture; it exists only to break up the steps. See the note in the shader.
 *
 * Self-contained: no imports, no packages, no global stylesheet, no webfont,
 * no image, and no graphics library — raw WebGL 1.0 and about sixty lines of
 * GLSL written for this component. Every colour and measurement is a
 * `--smk-*` custom property on the root.
 */

interface Props {
	/** Small line over the headline. `""` drops it. */
	eyebrow?: string;
	/** First line of the headline. */
	headline?: string;
	/** Second line, set in the accent's light tone. `""` gives one line. */
	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 primary button's text. `""` drops it. */
	actionLabel?: string;
	/** Where the primary button goes. Without a usable one it is left out. */
	actionHref?: string;
	/** An optional quieter second action. `""` drops it. */
	secondaryLabel?: string;
	/** Where the second action goes. Without a usable one it is left out. */
	secondaryHref?: string;
	/** The page colour behind the card. */
	background?: string;
	/** The card's ground, under the smoke. */
	card?: string;
	/** Headline and primary button ink. */
	ink?: string;
	/** The description and the second action. */
	mutedInk?: string;
	/** The card's hairline. */
	border?: string;
	/** The body of the smoke. */
	accent?: string;
	/** The light in the thickest part of the smoke. */
	glow?: string;
	/** The primary button's ground. */
	buttonBg?: string;
	/** The primary 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 strongly the card's own colour is washed back over the smoke behind
	 * the copy, 0 to 1. This is what keeps the text legible over a field that
	 * is otherwise free to be as bright as it likes.
	 */
	scrim?: number;
	/** How much smoke there is, 0 to 2. */
	density?: number;
	/** How large the plumes are. Lower is broader. */
	scale?: number;
	/** How fast it rises at rest. `0` is a still field. */
	speed?: number;
	/** How fast it rises while hovered or focused. */
	hoverSpeed?: number;
	/** How far the field folds through itself. Higher is wispier. */
	warp?: number;
	/**
	 * How much the smoke gathers at the foot of the card. `0` fills it evenly,
	 * higher values keep it low and let it thin out towards the top.
	 */
	rise?: 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 destinations: all of it is the caller's. */
	eyebrow = "",
	headline = "",
	headlineTail = "",
	headingLevel = "h2",
	description = "",
	actionLabel = "",
	actionHref = "",
	secondaryLabel = "",
	secondaryHref = "",
	background = "#000000",
	card = "#050409",
	ink = "#f6f4ff",
	mutedInk = "#9a93b5",
	border = "#241a4d",
	accent = "#351e82",
	glow = "#8b6df0",
	buttonBg = "#f4f1ff",
	buttonInk = "#0b0715",
	radius = "32px",
	minHeight = "620px",
	maxWidth = "1200px",
	scrim = 0.82,
	density = 1,
	scale = 1.9,
	speed = 0.35,
	hoverSpeed = 0.8,
	warp = 4.4,
	rise = 1.5,
	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 that action rather than pointing it at "#". */
const SAFE_HREF = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (value?: string) =>
	value && SAFE_HREF.test(value.trim()) ? value.trim() : "";
const primaryHref = actionLabel ? safeHref(actionHref) : "";
const secondHref = secondaryLabel ? safeHref(secondaryHref) : "";

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

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

const clamp = (value: number, min: number, max: number) =>
	Math.min(Math.max(value, min), max);

/* Clamped rather than trusted: these reach the shader, and a NaN uniform does
   not throw — it silently blanks the canvas. */
const config = {
	density: clamp(num(density, 1), 0, 2),
	scale: clamp(num(scale, 1.9), 0.2, 20),
	speed: clamp(num(speed, 0.35), 0, 5),
	hoverSpeed: clamp(num(hoverSpeed, 0.8), 0, 5),
	warp: clamp(num(warp, 4.4), 0, 12),
	rise: clamp(num(rise, 1.5), 0, 6),
	maxPixelRatio: clamp(num(maxPixelRatio, 2), 1, 4),
};

const style = [
	`--smk-bg:${background}`,
	`--smk-card:${card}`,
	`--smk-ink:${ink}`,
	`--smk-muted:${mutedInk}`,
	`--smk-border:${border}`,
	`--smk-accent:${accent}`,
	`--smk-glow:${glow}`,
	`--smk-button-bg:${buttonBg}`,
	`--smk-button-ink:${buttonInk}`,
	`--smk-radius:${radius}`,
	`--smk-min-height:${minHeight}`,
	`--smk-max:${maxWidth}`,
	`--smk-scrim:${clamp(num(scrim, 0.82), 0, 1)}`,
].join(";");
---

{hasContent && (
<section
	class:list={["smoke-cta", className]}
	id={id}
	style={style}
	data-smk-root
	data-smk-config={JSON.stringify(config)}
>
	<div class="smk-inner">
		<div class="smk-card">
			{/*
			   Three layers under the copy, in order: a CSS plume that needs
			   nothing, the canvas that covers it once there is a frame on it,
			   and a scrim that washes the card's own colour back over whichever
			   of the two is showing. The scrim is what guarantees the text is
			   legible no matter how bright the smoke gets.
			*/}
			<div class="smk-plume" aria-hidden="true"></div>
			<canvas class="smk-canvas" aria-hidden="true" data-smk-canvas></canvas>
			<div class="smk-scrim" aria-hidden="true"></div>

			<div class="smk-content">
				{eyebrow && <p class="smk-eyebrow">{eyebrow}</p>}

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

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

				{
					(primaryHref || secondHref) && (
						<div class="smk-actions">
							{primaryHref && (
								<a class="smk-action" href={primaryHref}>
									<span>{actionLabel}</span>
									<svg
										class="smk-arrow"
										viewBox="0 0 24 24"
										width="18"
										height="18"
										aria-hidden="true"
										focusable="false"
									>
										<path
											fill="none"
											stroke="currentColor"
											stroke-width="1.9"
											stroke-linecap="round"
											stroke-linejoin="round"
											d="M5 12h13M12 5l7 7-7 7"
										/>
									</svg>
								</a>
							)}
							{secondHref && (
								<a class="smk-secondary" href={secondHref}>
									{secondaryLabel}
								</a>
							)}
						</div>
					)
				}
			</div>
		</div>
	</div>
</section>
)}

<style>
	.smoke-cta {
		--smk-pad: 24px;
		--smk-pad-block: 48px;
		--smk-content-max: 880px;
		--smk-eyebrow-size: 12px;
		--smk-headline-size: 76px;
		--smk-description-size: 19px;

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

		--smk-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
			"Liberation Mono", monospace;

		display: block;
		box-sizing: border-box;
		/*
		 * The query container is the ROOT and 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(--smk-bg);
		color: var(--smk-ink);
	}

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

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

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

	.smk-card {
		position: relative;
		display: grid;
		place-items: center;
		min-block-size: var(--smk-min-height);
		/* Clips the smoke to the corner radius. */
		overflow: hidden;
		border: 1px solid var(--smk-border);
		border-radius: var(--smk-radius);
		background: var(--smk-card);
	}

	/*
	 * The fallback. A plume of the accent rising from the foot of the card with
	 * a hotter core, which is a reasonable reading of the field with the
	 * turbulence taken out — degraded, not missing. It shows before boot, after
	 * teardown, and on a machine that cannot give a WebGL context at all.
	 */
	.smk-plume {
		position: absolute;
		inset: 0;
		background:
			radial-gradient(
				48% 42% at 50% 104%,
				var(--smk-glow) 0%,
				transparent 68%
			),
			radial-gradient(
				75% 62% at 28% 100%,
				var(--smk-accent) 0%,
				transparent 72%
			),
			radial-gradient(
				80% 66% at 76% 96%,
				var(--smk-accent) 0%,
				transparent 74%
			);
		opacity: 0.85;
		pointer-events: none;
	}

	.smk-canvas {
		position: absolute;
		inset: 0;
		inline-size: 100%;
		block-size: 100%;
		pointer-events: none;
		/*
		 * Hidden until there is a frame on it. The canvas is OPAQUE — it paints
		 * the card's ground itself rather than compositing over it — so an
		 * empty one would be a black rectangle covering the plume rather than
		 * nothing at all.
		 */
		visibility: hidden;
	}

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

	/* With the canvas up, the plume underneath is redundant and would show
	   through nothing — but it costs a composite, so it comes off. */
	[data-smk-ready] .smk-plume {
		opacity: 0;
	}

	/*
	 * The scrim, and it is doing real work rather than decoration. The smoke
	 * runs to a light violet at its thickest, and muted body text over that is
	 * about 1.3:1 — unreadable. This washes the card's own colour back over the
	 * middle of the card, where the copy is, and fades out before the edges so
	 * the smoke still reads there.
	 */
	.smk-scrim {
		position: absolute;
		inset: 0;
		background: radial-gradient(
			72% 58% at 50% 46%,
			color-mix(in srgb, var(--smk-card) calc(var(--smk-scrim) * 100%), transparent)
				0%,
			color-mix(
					in srgb,
					var(--smk-card) calc(var(--smk-scrim) * 62%),
					transparent
				)
				48%,
			transparent 78%
		);
		pointer-events: none;
	}

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

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

	/*
	 * Every string here arrives from a caller. `anywhere` rather than
	 * `break-word` because only `anywhere` also shrinks the min-content size,
	 * which is what stops a long word forcing a track open in the first place.
	 */
	.smk-eyebrow,
	.smk-headline,
	.smk-description,
	.smk-action,
	.smk-secondary {
		overflow-wrap: anywhere;
	}

	.smk-eyebrow {
		margin: 0 0 24px;
		color: var(--smk-glow);
		font-family: var(--smk-mono);
		font-size: var(--smk-eyebrow-size);
		font-weight: 500;
		letter-spacing: 0.16em;
		line-height: 1.4;
		text-transform: uppercase;
	}

	.smk-headline {
		margin: 0 0 24px;
		color: var(--smk-ink);
		font-size: var(--smk-headline-size);
		font-weight: 500;
		letter-spacing: -0.035em;
		line-height: 1.04;
		text-wrap: balance;
	}

	.smk-headline-tail {
		/* The second line takes the smoke's light tone, which ties the copy to
		   the field behind it without needing a gradient on the text. */
		color: var(--smk-glow);
	}

	.smk-description {
		max-inline-size: 620px;
		margin: 0 0 40px;
		color: var(--smk-muted);
		font-size: var(--smk-description-size);
		line-height: 1.65;
	}

	.smk-actions {
		display: flex;
		flex-wrap: wrap;
		align-items: center;
		justify-content: center;
		gap: 12px 28px;
	}

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

	.smk-action:hover {
		box-shadow: 0 0 0 5px color-mix(in srgb, var(--smk-glow) 32%, transparent);
		transform: translateY(-2px);
	}

	.smk-action:active {
		transform: translateY(0) scale(0.98);
	}

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

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

	.smk-secondary {
		display: inline-flex;
		align-items: center;
		block-size: 54px;
		color: var(--smk-muted);
		font-size: 16px;
		text-decoration: none;
		/* Underlined from the start rather than on hover: on a busy field a
		   link that looks like body text until you touch it is not a link. */
		text-decoration: underline;
		text-decoration-color: color-mix(in srgb, var(--smk-muted) 45%, transparent);
		text-underline-offset: 5px;
		transition: color var(--smk-duration) var(--smk-ease);
	}

	.smk-secondary:hover {
		color: var(--smk-ink);
		text-decoration-color: currentColor;
	}

	/*
	 * Named on the classes 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.
	 */
	.smk-action:focus-visible,
	.smk-secondary:focus-visible {
		outline: 2px solid var(--smk-glow);
		outline-offset: 4px;
		border-radius: 999px;
	}

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

	@container (max-width: 64rem) {
		.smk-inner {
			--smk-headline-size: 60px;
		}
	}

	@container (max-width: 48rem) {
		.smk-inner {
			--smk-pad: 16px;
			--smk-headline-size: 44px;
			--smk-description-size: 17px;
			--smk-min-height: 560px;
		}

		.smk-content {
			padding: 40px 20px;
		}
	}

	@container (max-width: 27rem) {
		.smk-inner {
			--smk-radius: 24px;
			--smk-headline-size: 34px;
			--smk-min-height: 520px;
		}

		.smk-actions {
			flex-direction: column;
			gap: 4px;
			inline-size: 100%;
		}

		.smk-action {
			inline-size: 100%;
		}
	}

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

	@media (prefers-reduced-motion: reduce) {
		/*
		 * The field is drawn once and left standing. Not blank and not hidden:
		 * a still plume 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.
		 */
		.smoke-cta {
			--smk-duration: 0.01ms;
		}

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

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

	@media (forced-colors: active) {
		/*
		 * Painted pixels do not answer a forced palette and neither does a CSS
		 * gradient, so the field comes off entirely and the card is drawn in
		 * border terms.
		 */
		.smk-canvas,
		[data-smk-ready] .smk-canvas,
		.smk-plume,
		.smk-scrim {
			display: none;
		}

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

		.smk-eyebrow,
		.smk-headline,
		.smk-headline-tail,
		.smk-description {
			color: CanvasText;
		}

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

		.smk-secondary {
			color: LinkText;
		}

		.smk-action:focus-visible,
		.smk-secondary:focus-visible {
			outline-color: Highlight;
		}
	}

	@media print {
		.smk-canvas,
		[data-smk-ready] .smk-canvas,
		.smk-plume,
		.smk-scrim {
			display: none;
		}

		.smoke-cta {
			background: #fff;
			color: #000;
		}

		.smk-card {
			border-color: #000;
			background: #fff;
		}

		.smk-eyebrow,
		.smk-headline,
		.smk-headline-tail,
		.smk-description,
		.smk-secondary {
			color: #000;
		}

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

<script>
	/*
	 * Smoke 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 = {
		density: number;
		scale: number;
		speed: number;
		hoverSpeed: number;
		warp: number;
		rise: 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_warp;
uniform float u_density;
uniform float u_rise;
uniform vec3 u_bg;
uniform vec3 u_accent;
uniform vec3 u_glow;

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

void main() {
  /* Normalised on HEIGHT, so a plume keeps its proportions as the card
     changes width instead of stretching with it. */
  vec2 p = gl_FragCoord.xy / u_res.y * u_scale;
  float t = u_time;

  /* Smoke rises, so the field is pulled downward through the sample point.
     The three layers move at different rates, which is what stops the whole
     thing sliding as one sheet. */
  vec2 flow = vec2(0.0, -t);

  vec2 q = vec2(
    fbm(p + flow * 0.30),
    fbm(p + flow * 0.26 + vec2(3.1, 1.7))
  );
  vec2 r = vec2(
    fbm(p + u_warp * q + flow * 0.18 + vec2(1.7, 9.2)),
    fbm(p + u_warp * q + flow * 0.14 + vec2(8.3, 2.8))
  );
  float d = fbm(p + u_warp * r + flow * 0.08);

  /* Soft shoulder, then a power curve: small bright cores with long thin
     tails, which is what reads as smoke rather than as cloud. */
  d = smoothstep(0.26, 0.92, d);
  d = pow(d, 1.7);

  /* It gathers at the foot of the card and thins upward, never quite to
     nothing so the top is haze rather than a hard edge. */
  float y = gl_FragCoord.y / u_res.y;
  d *= mix(0.12, 1.0, pow(1.0 - y, u_rise));
  d *= u_density;

  vec3 col = u_bg;
  col = mix(col, u_accent, clamp(d * 1.5, 0.0, 1.0));
  col = mix(col, u_glow, clamp(pow(d, 2.4) * 1.35, 0.0, 1.0));

  /*
   * Anti-banding, and NOT the halftone the sibling component uses. A smooth
   * violet ramp across a near-black card crosses only a few dozen 8-bit
   * levels, so it steps into visible contour lines. This adds a hash of plus
   * or minus half a level, which dissolves the steps and cannot be seen as
   * texture: the dither in Dithered CTA is a two-pixel cell at full accent
   * strength, this is a one-pixel cell at 1/255.
   */
  float n = hash(gl_FragCoord.xy + fract(t)) - 0.5;
  col += n / 255.0;

  gl_FragColor = vec4(col, 1.0);
}
`;

	function compile(gl: WebGLRenderingContext, type: number, source: string) {
		const shader = gl.createShader(type);
		if (!shader) return null;
		gl.shaderSource(shader, source);
		gl.compileShader(shader);
		if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
			gl.deleteShader(shader);
			return null;
		}
		return shader;
	}

	function parseColor(value: string, fallback: [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 fallback;
		return [Number(m[0]) / 255, Number(m[1]) / 255, Number(m[2]) / 255] as [
			number,
			number,
			number,
		];
	}

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

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

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

		const attrs = {
			alpha: false,
			antialias: false,
			depth: false,
			stencil: false,
			powerPreference: "low-power" as const,
		};
		/* Cast over the whole expression, not the second half. With `attrs`
		   hoisted into a variable TypeScript cannot resolve getContext's
		   literal overload and falls back to the one returning
		   RenderingContext, which has none of the GL methods on it. */
		const gl = (canvas.getContext("webgl", attrs) ||
			canvas.getContext("experimental-webgl", attrs)) as
			| WebGLRenderingContext
			| null;

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

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

		let program: WebGLProgram | null = null;
		let buffer: WebGLBuffer | null = null;
		const u: Record<string, WebGLUniformLocation | null> = {};

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

			for (const name of [
				"u_res",
				"u_time",
				"u_scale",
				"u_warp",
				"u_density",
				"u_rise",
				"u_bg",
				"u_accent",
				"u_glow",
			]) {
				u[name] = gl.getUniformLocation(program, name);
			}
			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 wider
			   than the box it is painted into and the field is squashed. */
			const rect = canvas!.getBoundingClientRect();
			dpr = Math.min(window.devicePixelRatio || 1, config.maxPixelRatio);
			const w = Math.max(1, Math.round(rect.width * dpr));
			const h = Math.max(1, Math.round(rect.height * dpr));
			if (canvas!.width !== w || canvas!.height !== h) {
				canvas!.width = w;
				canvas!.height = h;
			}
			gl.viewport(0, 0, w, h);
		}

		function draw() {
			if (!gl || !program) return;
			resize();
			gl.useProgram(program);

			const cs = getComputedStyle(root);
			const bg = parseColor(cs.getPropertyValue("--smk-card").trim(), [0.02, 0.016, 0.035]);
			const accent = parseColor(cs.getPropertyValue("--smk-accent").trim(), [0.208, 0.118, 0.51]);
			const glow = parseColor(cs.getPropertyValue("--smk-glow").trim(), [0.545, 0.427, 0.941]);

			gl.uniform2f(u.u_res, canvas!.width, canvas!.height);
			gl.uniform1f(u.u_time, elapsed);
			gl.uniform1f(u.u_scale, config.scale);
			gl.uniform1f(u.u_warp, config.warp);
			gl.uniform1f(u.u_density, config.density);
			gl.uniform1f(u.u_rise, config.rise);
			gl.uniform3f(u.u_bg, bg[0], bg[1], bg[2]);
			gl.uniform3f(u.u_accent, accent[0], accent[1], accent[2]);
			gl.uniform3f(u.u_glow, glow[0], glow[1], glow[2]);

			gl.drawArrays(gl.TRIANGLES, 0, 3);

			delete root.dataset.smkFallback;
			root.dataset.smkReady = "";
		}

		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 thickening on
			   hover is a change of pace and not a jump. */
			current += (target - current) * Math.min(delta * 3.5, 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.smkFallback = "";
			controller.abort();
			return;
		}

		draw();

		/* Hover and focus both thicken it. Focus matters: the card holds two
		   links, and a keyboard user reaching them 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 card, 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.smkReady;
			},
			{ 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.smkReady;
				delete root.dataset.smkFallback;
				BOOTED.delete(root);
			},
		});
	}

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

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