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.