Stencil
Canvas headline spray-painted through a cut stencil plate: seeded S-curve sweeps deposit grainy paint inside the letter cutouts with faint overspray and a few drips, then the plate lifts away to reveal the word; drag to add coats.
canvasfree
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { useVisibilityPause } from "@/registry/default/hooks/use-visibility-pause/use-visibility-pause";
export interface StencilHandle {
/** Wipe the paint, lay the plate back down and spray the headline again. */
replay(): void;
}
export interface StencilProps extends Omit<
React.HTMLAttributes<HTMLDivElement>,
"children" | "color"
> {
/** The headline (also rendered as a visually hidden heading for screen readers + SEO). @default "STENCIL" */
text?: string;
/** Element the visually hidden real text is rendered as, so it stays a heading. @default "h2" */
as?: "h1" | "h2" | "h3" | "p";
/** Spray paint color. @default "#f2f0eb" (off-white) */
paint?: string;
/** Stencil plate color — keep it a little lighter than the surface behind it. @default "#2a2b2f" (graphite) */
plate?: string;
/** Maximum glyph size in px; the headline scales down (and wraps to two lines) to fit. @default auto (~48% of the height) */
fontSize?: number;
/**
* Canvas font stack. Any counters (the holes in O, A, B…) get stencil
* bridges cut automatically, so plain bold fonts read as real stencils.
* Web fonts are picked up once `document.fonts` is ready.
* @default a bold condensed sans stack (Impact / Oswald / Arial Narrow…)
*/
fontFamily?: string;
/** Number of S-curve sweeps the can makes per line of text. 1–8. @default 3 */
passes?: number;
/** Paint runs that trickle 4–18px down from the letters' bottom edges during the intro. 0–16. @default 5 */
drips?: number;
/** Deterministic seed for the spray grain, sweep paths and drip spots. @default 7 */
seed?: number;
/** Imperative handle: `replay()`. */
ref?: React.Ref<StencilHandle>;
}
const DEFAULT_FONT =
'"Saira Stencil One", "Big Shoulders Stencil Display", Impact, Haettenschweiler, "Oswald", "Bebas Neue", "Arial Narrow Bold", "Arial Narrow", "Roboto Condensed", sans-serif';
const START = 0.3; // first sweep begins
const GAP = 0.07; // the can reverses between passes
const SETTLE = 0.4; // pause between the last pass and the plate lift
const LIFT_DUR = 1.05;
const OVERSPRAY = 0.22; // alpha of paint that creeps under the plate edge
const MAX_DRIPS = 40;
/** Deterministic mulberry32 PRNG. */
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const clamp = (v: number, lo: number, hi: number) =>
Math.min(hi, Math.max(lo, v));
const smooth = (a: number, b: number, v: number) => {
const t = clamp((v - a) / (b - a), 0, 1);
return t * t * (3 - 2 * t);
};
const easeOut = (u: number, p = 3) => 1 - Math.pow(1 - clamp(u, 0, 1), p);
interface Drip {
x: number;
y: number;
len: number;
w: number;
start: number;
dur: number;
drawn: number;
}
interface Layout {
w: number;
h: number;
dpr: number;
fs: number;
lines: number;
/** CSS-px glyph mask (post-bridging). */
mask: Uint8Array;
/** Text block bounds (CSS px). */
box: { x0: number; y0: number; x1: number; y1: number };
/** Plate rect (CSS px). */
plateRect: { x: number; y: number; w: number; h: number };
/** Candidate drip origins — points on the letters' bottom edges. */
edges: number[];
maskC: HTMLCanvasElement;
padSoftC: HTMLCanvasElement;
padHardC: HTMLCanvasElement;
plateC: HTMLCanvasElement;
cutShadowC: HTMLCanvasElement;
wordC: HTMLCanvasElement;
platePaintC: HTMLCanvasElement;
/** Fresh grain splats this frame. */
freshC: HTMLCanvasElement;
/** Fresh soft base coat this frame (kept out of the overspray so the halo stays speckled). */
coatC: HTMLCanvasElement;
dripFreshC: HTMLCanvasElement;
tmpC: HTMLCanvasElement;
blob: HTMLCanvasElement;
/** Spray cone radius for the intro sweeps (CSS px). */
R: number;
passDur: number;
passCount: number;
sprayEnd: number;
liftAt: number;
}
/**
* Stencil — a headline spray-painted through a cut stencil plate. The glyphs
* are rasterized into a mask (counters get stencil bridges cut automatically),
* a graphite plate with those letters cut out sits over the surface, and an
* off-screen can sweeps a few seeded S-curve passes, depositing thousands of
* soft, grainy low-alpha splats: full strength inside the cutouts, a faint
* overspray creeping under the plate edge, and the rest landing on the plate
* itself. A few drips run down from the letters' bottom edges, then the plate
* lifts away — shadow growing, fading out — revealing the crisp painted word.
* Pointer drag sprays more paint locally (coats stack; lingering makes it
* run). DPR-clamped, pauses offscreen/hidden; the real text ships as a
* visually hidden heading; reduced motion shows the finished painted word.
*/
export function Stencil({
text = "STENCIL",
as = "h2",
paint = "#f2f0eb",
plate = "#2a2b2f",
fontSize,
fontFamily = DEFAULT_FONT,
passes = 3,
drips = 5,
seed = 7,
className,
ref,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onKeyDown,
...props
}: StencilProps) {
const Tag = as;
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const reducedMotion = useReducedMotion();
const reducedRef = React.useRef(reducedMotion);
React.useLayoutEffect(() => {
reducedRef.current = reducedMotion;
}, [reducedMotion]);
const layoutRef = React.useRef<Layout | null>(null);
const clockRef = React.useRef(0); // seconds since the intro (re)started
const lastElapsedRef = React.useRef(0);
const introDoneRef = React.useRef(false);
const dripsRef = React.useRef<Drip[]>([]);
const randRef = React.useRef(mulberry32(seed));
const dirtyRef = React.useRef(true);
const dragRef = React.useRef({
active: false,
id: -1,
x: 0,
y: 0,
lx: 0,
ly: 0,
dwell: 0,
});
const passCount = clamp(Math.round(passes), 1, 8);
const dripCount = clamp(Math.round(drips), 0, 16);
// ---------------------------------------------------------------- painting
/** Bind a context for drawing in CSS px. */
const cssCtx = (c: HTMLCanvasElement, dpr: number) => {
const ctx = c.getContext("2d")!;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return ctx;
};
/** Spray one burst of grainy splats + a soft base coat at (x, y) into the fresh layer. */
const sprayAt = React.useCallback(
(
L: Layout,
x: number,
y: number,
R: number,
amount: number,
rand: () => number,
) => {
// Soft base coat — gives the coverage its smooth, misted body.
const coat = cssCtx(L.coatC, L.dpr);
// Jittered so repeated bursts on one spot never quantize into rings.
coat.globalAlpha = 0.15 * Math.min(1, amount);
const br = R * (0.85 + rand() * 0.3);
const bx = x + (rand() - 0.5) * R * 0.35;
const by = y + (rand() - 0.5) * R * 0.35;
coat.drawImage(L.blob, bx - br, by - br, br * 2, br * 2);
const ctx = cssCtx(L.freshC, L.dpr);
// Grain — many tiny low-alpha dots in a gaussian cone.
const n = Math.round(R * 0.75 * amount);
const grain = clamp(L.fs / 110, 0.7, 1.6);
ctx.fillStyle = paint;
for (let i = 0; i < n; i++) {
const r1 = Math.max(rand(), 1e-6);
const mag = Math.sqrt(-2 * Math.log(r1)) * R * 0.42;
const ang = rand() * Math.PI * 2;
const px = x + Math.cos(ang) * mag;
const py = y + Math.sin(ang) * mag;
const s = grain * (0.45 + rand() * rand() * 1.9);
ctx.globalAlpha = 0.14 + rand() * 0.42;
if (s < 1.1) ctx.fillRect(px - s / 2, py - s / 2, s, s);
else {
ctx.beginPath();
ctx.arc(px, py, s * 0.6, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.globalAlpha = 1;
},
[paint],
);
/** Point on intro sweep `i` at progress u — alternating S-curves stacked down the text block. */
const sweepPoint = (
L: Layout,
i: number,
u: number,
rand0: number,
): [number, number] => {
const { x0, x1, y0, y1 } = L.box;
const dir = i % 2 === 0 ? 1 : -1;
const xa = x0 - L.R * 0.8;
const xb = x1 + L.R * 0.8;
const x = dir > 0 ? xa + (xb - xa) * u : xb - (xb - xa) * u;
const band = (y1 - y0) / L.passCount;
const yc = y0 + band * (i + 0.5);
const amp = band * 0.5 + L.fs * 0.04;
const y = yc + Math.sin(u * Math.PI * 2 + rand0 * 1.2 - 0.6) * amp * dir;
return [x, y];
};
/** Spray the stretch of sweep `i` between progress u0 and u1. */
const sprayAlong = React.useCallback(
(L: Layout, i: number, u0: number, u1: number) => {
if (u1 <= u0) return;
const r0 = mulberry32(seed * 977 + i * 131)();
const [ax, ay] = sweepPoint(L, i, u0, r0);
const [bx, by] = sweepPoint(L, i, u1, r0);
const steps = Math.max(1, Math.ceil(Math.hypot(bx - ax, by - ay) / 3));
for (let s = 1; s <= steps; s++) {
const u = u0 + ((u1 - u0) * s) / steps;
const [x, y] = sweepPoint(L, i, u, r0);
sprayAt(L, x, y, L.R, 1, randRef.current);
}
},
[sprayAt, seed],
);
/** Extend every drip to its length at time t, drawing the new run into the drip layer. */
const growDrips = React.useCallback(
(L: Layout, t: number, instant: boolean) => {
let any = false;
const ctx = cssCtx(L.dripFreshC, L.dpr);
ctx.fillStyle = paint;
for (const d of dripsRef.current) {
const p = instant ? 1 : easeOut((t - d.start) / d.dur, 2.4);
const target = d.len * p;
if (target <= d.drawn + 0.05) continue;
any = true;
for (let l = d.drawn; l <= target; l += 0.6) {
const k = l / d.len;
const r = d.w * (0.42 + 0.22 * k); // thickens toward the bead
ctx.globalAlpha = 0.9;
ctx.beginPath();
ctx.arc(
d.x + Math.sin(l * 0.35 + d.x) * 0.25,
d.y + l,
r,
0,
Math.PI * 2,
);
ctx.fill();
}
d.drawn = target;
if (p >= 1) {
ctx.beginPath();
ctx.arc(d.x, d.y + d.len, d.w * 0.72, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.globalAlpha = 1;
return any;
},
[paint],
);
/** Route the fresh splats into the word (inside the cutouts + faint overspray) and onto the plate. */
const flush = React.useCallback((L: Layout, overspray = OVERSPRAY) => {
const tmp = L.tmpC.getContext("2d")!;
const word = L.wordC.getContext("2d")!;
const pp = L.platePaintC.getContext("2d")!;
for (const c of [tmp, word, pp]) c.setTransform(1, 0, 0, 1, 0, 0);
const pass = (
src: HTMLCanvasElement,
clip: HTMLCanvasElement,
op: GlobalCompositeOperation,
) => {
tmp.globalCompositeOperation = "copy";
tmp.drawImage(src, 0, 0);
tmp.globalCompositeOperation = op;
tmp.drawImage(clip, 0, 0);
tmp.globalCompositeOperation = "source-over";
};
// Overspray under the plate edge (soft padded mask, low alpha).
pass(L.freshC, L.padSoftC, "destination-in");
word.globalAlpha = overspray;
word.drawImage(L.tmpC, 0, 0);
word.globalAlpha = 1;
// Full-strength paint through the cutouts.
for (const src of [L.coatC, L.freshC]) {
pass(src, L.maskC, "destination-in");
word.drawImage(L.tmpC, 0, 0);
}
// Everything else lands on the plate.
for (const src of [L.coatC, L.freshC]) {
pass(src, L.plateC, "destination-in");
pp.drawImage(L.tmpC, 0, 0);
}
// Drips may creep a few px past the cut edge.
pass(L.dripFreshC, L.padHardC, "destination-in");
word.drawImage(L.tmpC, 0, 0);
for (const c of [L.freshC, L.coatC, L.dripFreshC]) {
const ctx = c.getContext("2d")!;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, c.width, c.height);
}
}, []);
/** Seed the intro drips on bottom edges of the letters. */
const planDrips = React.useCallback(
(L: Layout) => {
const rand = mulberry32(seed * 313 + 17);
const out: Drip[] = [];
const minGap = L.fs * 0.3;
const scale = clamp(L.fs / 120, 0.75, 1.5);
let guard = 0;
while (out.length < dripCount && L.edges.length && guard++ < 400) {
const k = Math.floor(rand() * (L.edges.length / 2)) * 2;
const x = L.edges[k];
const y = L.edges[k + 1];
if (out.some((d) => Math.abs(d.x - x) < minGap)) continue;
const len = (4 + rand() * 14) * scale;
out.push({
x,
y: y - L.fs * (0.02 + rand() * 0.05),
len,
w: (2 + rand() * 1.4) * scale,
start: L.sprayEnd - 0.55 + out.length * 0.16 + rand() * 0.25,
dur: 0.7 + (len / (18 * scale)) * 0.9,
drawn: 0,
});
}
dripsRef.current = out;
},
[seed, dripCount],
);
const clearPaint = (L: Layout) => {
for (const c of [L.wordC, L.platePaintC, L.freshC, L.coatC, L.dripFreshC]) {
const ctx = c.getContext("2d")!;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, c.width, c.height);
}
};
/** Paint the finished word in one go (reduced motion, resize after the intro). */
const paintInstant = React.useCallback(
(L: Layout) => {
clearPaint(L);
randRef.current = mulberry32(seed);
for (let i = 0; i < L.passCount; i++) sprayAlong(L, i, 0, 1);
planDrips(L);
growDrips(L, 0, true);
flush(L);
},
[seed, sprayAlong, planDrips, growDrips, flush],
);
// ---------------------------------------------------------------- render
const render = React.useCallback(() => {
const canvas = canvasRef.current;
const L = layoutRef.current;
if (!canvas || !L) return;
const ctx = canvas.getContext("2d")!;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(L.wordC, 0, 0);
const t = clockRef.current;
const lift =
introDoneRef.current || reducedRef.current
? 1
: clamp((t - L.liftAt) / LIFT_DUR, 0, 1);
if (lift >= 1) return;
// Unstick first (rise toward the viewer, shadow blooming), then pull away.
const rise = smooth(0, 0.32, lift);
const e = smooth(0.22, 1, lift);
const alpha = 1 - smooth(0.35, 0.9, lift);
// Plate thickness casts a hairline shadow into each cutout while it's down.
ctx.globalAlpha = 1 - smooth(0, 0.25, lift);
ctx.drawImage(L.cutShadowC, 0, 0);
const { x, y, w, h } = L.plateRect;
const cx = (x + w / 2) * L.dpr;
const cy = (y + h / 2) * L.dpr;
ctx.save();
ctx.translate(cx + e * L.fs * 0.2, cy - e * (h * 0.6 + L.fs * 0.3));
ctx.rotate(-0.05 * e);
const sc = 1 + 0.025 * rise + 0.05 * e;
ctx.scale(sc, sc);
ctx.translate(-cx, -cy);
ctx.globalAlpha = alpha;
const lifted = Math.max(rise, e);
ctx.shadowColor = `rgba(0,0,0,${0.6 * (1 - e * 0.5)})`;
ctx.shadowBlur = (2 + 14 * rise + 22 * e) * L.dpr;
ctx.shadowOffsetY = (1 + 8 * rise + 18 * e) * L.dpr;
ctx.shadowOffsetX = -lifted * 6 * L.dpr;
ctx.drawImage(L.plateC, 0, 0);
ctx.shadowColor = "transparent";
ctx.drawImage(L.platePaintC, 0, 0);
ctx.restore();
ctx.globalAlpha = 1;
}, []);
/** Pointer spray: interpolate along the stroke, and let paint build up (and run) where it lingers. */
const sprayDrag = React.useCallback(
(L: Layout, dt: number) => {
const d = dragRef.current;
const R = clamp(L.fs * 0.2, 14, 46);
const dist = Math.hypot(d.x - d.lx, d.y - d.ly);
const steps = Math.max(1, Math.ceil(dist / 4));
const amount = clamp((dt * 60) / steps, 0.25, 1) * 0.55;
for (let s = 1; s <= steps; s++) {
const x = d.lx + ((d.x - d.lx) * s) / steps;
const y = d.ly + ((d.y - d.ly) * s) / steps;
sprayAt(L, x, y, R, amount, randRef.current);
}
d.lx = d.x;
d.ly = d.y;
if (reducedRef.current) return;
// Lingering over a letter's bottom edge pools the paint into a fresh drip.
d.dwell += dt * (dist < R * 0.5 ? 1 : 0.35);
if (d.dwell > 0.75 && dripsRef.current.length < MAX_DRIPS) {
d.dwell = 0;
let best = -1;
let bestD = R * 1.3;
for (let k = 0; k < L.edges.length; k += 2) {
const dd = Math.hypot(L.edges[k] - d.x, L.edges[k + 1] - d.y);
if (
dd < bestD &&
!dripsRef.current.some((q) => Math.abs(q.x - L.edges[k]) < 4)
) {
bestD = dd;
best = k;
}
}
if (best >= 0) {
const rand = randRef.current;
const scale = clamp(L.fs / 120, 0.75, 1.5);
const len = (6 + rand() * 12) * scale;
dripsRef.current.push({
x: L.edges[best],
y: L.edges[best + 1] - L.fs * 0.03,
len,
w: (1.4 + rand() * 1.2) * scale,
start: clockRef.current,
dur: 0.8 + (len / (18 * scale)) * 0.9,
drawn: 0,
});
}
}
},
[sprayAt],
);
// ---------------------------------------------------------------- loop
const tick = React.useCallback(
(elapsed: number) => {
if (elapsed < lastElapsedRef.current) lastElapsedRef.current = elapsed;
const dt = Math.min(elapsed - lastElapsedRef.current, 0.05);
lastElapsedRef.current = elapsed;
const L = layoutRef.current;
if (!L || dt <= 0) return;
const drag = dragRef.current;
let changed = false;
if (!introDoneRef.current) {
const t0 = clockRef.current;
const t1 = t0 + dt;
clockRef.current = t1;
// Intro sweeps overlapping (t0, t1].
for (let i = 0; i < L.passCount; i++) {
const ps = START + i * (L.passDur + GAP);
const a = clamp((t0 - ps) / L.passDur, 0, 1);
const b = clamp((t1 - ps) / L.passDur, 0, 1);
if (b > a) {
const ease = (v: number) => 0.5 - 0.5 * Math.cos(Math.PI * v);
sprayAlong(L, i, ease(a), ease(b));
}
}
if (
t1 >= L.liftAt + LIFT_DUR &&
dripsRef.current.every((d) => t1 >= d.start + d.dur)
) {
introDoneRef.current = true;
}
changed = true;
} else {
clockRef.current += dt;
}
if (growDrips(L, clockRef.current, false)) changed = true;
if (drag.active) {
sprayDrag(L, dt);
changed = true;
}
if (!changed && !dirtyRef.current) return;
// With the plate gone, a hand-held can mists the surround more heavily.
flush(L, drag.active && introDoneRef.current ? OVERSPRAY * 2 : OVERSPRAY);
render();
dirtyRef.current = false;
},
[sprayAlong, growDrips, sprayDrag, flush, render],
);
const containerRef = useVisibilityPause<HTMLDivElement>(tick, {
paused: reducedMotion,
});
// ---------------------------------------------------------------- layout
React.useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
let cancelled = false;
let lastW = 0;
let lastH = 0;
const build = (force: boolean) => {
if (cancelled) return;
const w = Math.max(container.clientWidth, 1);
const h = Math.max(container.clientHeight, 1);
if (!force && w === lastW && h === lastH) return;
lastW = w;
lastH = h;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const W = Math.round(w * dpr);
const H = Math.round(h * dpr);
canvas.width = W;
canvas.height = H;
const prev = layoutRef.current;
const mk = (c?: HTMLCanvasElement) => {
const el = c ?? document.createElement("canvas");
el.width = W;
el.height = H;
return el;
};
const maskC = mk(prev?.maskC);
const mctx = maskC.getContext("2d", { willReadFrequently: true });
if (!mctx) return;
// Fit: one line, or a balanced two-line wrap if that renders larger.
const probe = 100;
const font = (px: number) => `800 ${px}px ${fontFamily}`;
const setSpacing = (px: number) => {
if ("letterSpacing" in mctx) {
(
mctx as CanvasRenderingContext2D & { letterSpacing: string }
).letterSpacing = `${px}px`;
}
};
mctx.setTransform(1, 0, 0, 1, 0, 0);
mctx.clearRect(0, 0, W, H);
mctx.font = font(probe);
setSpacing(probe * 0.03);
const target = fontSize ?? Math.min(h * 0.48, 200);
const fit = (lines: string[]) => {
const widest =
Math.max(...lines.map((l) => mctx.measureText(l).width), 1) / probe;
// Plate padding: 0.32em each side, 0.24em top/bottom.
return Math.min(
target,
(w * 0.94) / (widest + 0.64),
(h * 0.92) / (lines.length * 1.04 + 0.5),
);
};
let lines = [text];
let fs = fit(lines);
const words = text.split(" ");
if (words.length > 1) {
let best: string[] | null = null;
let bestDiff = Infinity;
for (let i = 1; i < words.length; i++) {
const a = words.slice(0, i).join(" ");
const b = words.slice(i).join(" ");
const diff = Math.abs(
mctx.measureText(a).width - mctx.measureText(b).width,
);
if (diff < bestDiff) {
bestDiff = diff;
best = [a, b];
}
}
if (best) {
const fs2 = fit(best);
if (fs2 > fs * 1.25) {
lines = best;
fs = fs2;
}
}
}
mctx.setTransform(dpr, 0, 0, dpr, 0, 0);
mctx.font = font(fs);
setSpacing(fs * 0.03);
mctx.textAlign = "center";
mctx.textBaseline = "alphabetic";
const m = mctx.measureText(lines.join(""));
const ascent = m.actualBoundingBoxAscent || fs * 0.74;
const descent = m.actualBoundingBoxDescent || fs * 0.04;
const lh = fs * 1.04;
const blockH = (lines.length - 1) * lh + ascent + descent;
const top = (h - blockH) / 2;
mctx.fillStyle = "#fff";
let bx0 = w;
let bx1 = 0;
lines.forEach((line, i) => {
// letterSpacing trails the last glyph — nudge back by half to stay centered.
mctx.fillText(line, w / 2 + fs * 0.015, top + ascent + i * lh);
const lw = mctx.measureText(line).width;
bx0 = Math.min(bx0, w / 2 - lw / 2);
bx1 = Math.max(bx1, w / 2 + lw / 2);
});
const box = { x0: bx0, y0: top, x1: bx1, y1: top + blockH };
// CSS-px mask for bridging + drip spots.
const readMask = () => {
const data = mctx.getImageData(0, 0, W, H).data;
const mask = new Uint8Array(w * h);
for (let y = 0; y < h; y++) {
const sy = Math.min(H - 1, Math.round((y + 0.5) * dpr - 0.5));
for (let x = 0; x < w; x++) {
const sx = Math.min(W - 1, Math.round((x + 0.5) * dpr - 0.5));
mask[y * w + x] = data[(sy * W + sx) * 4 + 3] > 110 ? 1 : 0;
}
}
return mask;
};
let mask = readMask();
// Stencil bridges: any enclosed counter would fall out of a real plate,
// so cut a tie above and below each one.
{
const N = w * h;
const outside = new Uint8Array(N);
const queue = new Int32Array(N);
let qh = 0;
let qt = 0;
const push = (i: number) => {
if (!mask[i] && !outside[i]) {
outside[i] = 1;
queue[qt++] = i;
}
};
for (let x = 0; x < w; x++) {
push(x);
push((h - 1) * w + x);
}
for (let y = 0; y < h; y++) {
push(y * w);
push(y * w + w - 1);
}
while (qh < qt) {
const i = queue[qh++];
const x = i % w;
if (x > 0) push(i - 1);
if (x < w - 1) push(i + 1);
if (i >= w) push(i - w);
if (i < N - w) push(i + w);
}
const seen = new Uint8Array(N);
const bw = Math.max(1.6, fs * 0.06);
const minArea = Math.max(6, fs * fs * 0.0015);
const strips: [number, number, number, number][] = [];
for (let s = 0; s < N; s++) {
if (mask[s] || outside[s] || seen[s]) continue;
// Flood one counter.
qh = 0;
qt = 0;
seen[s] = 1;
queue[qt++] = s;
let x0 = w;
let x1 = 0;
let y0 = h;
let y1 = 0;
let sumX = 0;
while (qh < qt) {
const i = queue[qh++];
const x = i % w;
const y = (i - x) / w;
sumX += x;
if (x < x0) x0 = x;
if (x > x1) x1 = x;
if (y < y0) y0 = y;
if (y > y1) y1 = y;
const nb = [
x > 0 ? i - 1 : -1,
x < w - 1 ? i + 1 : -1,
i >= w ? i - w : -1,
i < N - w ? i + w : -1,
];
for (const j of nb) {
if (j >= 0 && !mask[j] && !outside[j] && !seen[j]) {
seen[j] = 1;
queue[qt++] = j;
}
}
}
if (qt < minArea) continue;
const cx = Math.round((sumX / qt + (x0 + x1) / 2) / 2);
// Find the column's top-most counter pixel, then cut upward through ink.
const colTop = (() => {
for (let y = y0; y <= y1; y++)
if (seen[y * w + cx] && !mask[y * w + cx]) return y;
return y0;
})();
const colBot = (() => {
for (let y = y1; y >= y0; y--)
if (seen[y * w + cx] && !mask[y * w + cx]) return y;
return y1;
})();
let y = colTop - 1;
while (y > 0 && mask[y * w + cx]) y--;
strips.push([cx - bw / 2, y, bw, colTop - y + 1]);
y = colBot + 1;
while (y < h - 1 && mask[y * w + cx]) y++;
strips.push([cx - bw / 2, colBot, bw, y - colBot + 1]);
}
if (strips.length) {
for (const [sx, sy, sw, sh] of strips) mctx.clearRect(sx, sy, sw, sh);
mask = readMask();
}
}
// Bottom-edge drip candidates.
const edges: number[] = [];
const stepX = Math.max(1, Math.round(fs * 0.02));
for (
let y = Math.max(1, Math.floor(box.y0));
y < Math.min(h - 3, Math.ceil(box.y1) + 2);
y++
) {
for (let x = 1; x < w - 1; x += stepX) {
const i = y * w + x;
if (!(
mask[i] &&
mask[i - 1] &&
mask[i + 1] &&
!mask[i + w] &&
!mask[i + 2 * w]
))
continue;
// Only the letters' true feet — no ink below in this column for half an em.
let clear = true;
for (let k = 2; k < fs * 0.9 && y + k < h; k++)
if (mask[i + k * w]) clear = false;
if (clear) edges.push(x, y);
}
}
// Padded masks: hard (drips creep ~3px) and soft (overspray falloff).
const stamp = (
c: HTMLCanvasElement,
radii: number[],
alphas: number[],
) => {
const ctx = c.getContext("2d")!;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, W, H);
radii.forEach((r, ri) => {
ctx.globalAlpha = alphas[ri];
for (let a = 0; a < 12; a++) {
const ang = (a / 12) * Math.PI * 2;
ctx.drawImage(
maskC,
Math.cos(ang) * r * dpr,
Math.sin(ang) * r * dpr,
);
}
});
ctx.globalAlpha = 1;
ctx.drawImage(maskC, 0, 0);
};
const padHardC = mk(prev?.padHardC);
stamp(padHardC, [1.5, 3], [1, 1]);
const padSoftC = mk(prev?.padSoftC);
const sr = clamp(fs * 0.055, 3, 10);
stamp(padSoftC, [sr, sr * 0.66, sr * 0.33], [0.06, 0.1, 0.22]);
// The plate: graphite with lighter edges, cut letters and two hanging holes.
const padX = fs * 0.32;
const padY = fs * 0.24;
const plateRect = {
x: box.x0 - padX,
y: box.y0 - padY,
w: box.x1 - box.x0 + padX * 2,
h: box.y1 - box.y0 + padY * 2,
};
const plateC = mk(prev?.plateC);
{
const ctx = cssCtx(plateC, dpr);
ctx.clearRect(0, 0, w, h);
const { x, y, w: pw, h: ph } = plateRect;
const rr = Math.min(8, fs * 0.06);
const path = new Path2D();
path.roundRect(x, y, pw, ph, rr);
ctx.fillStyle = plate;
ctx.fill(path);
ctx.save();
ctx.clip(path);
const g = ctx.createLinearGradient(0, y, 0, y + ph);
g.addColorStop(0, "rgba(255,255,255,0.07)");
g.addColorStop(0.5, "rgba(255,255,255,0)");
g.addColorStop(1, "rgba(0,0,0,0.18)");
ctx.fillStyle = g;
ctx.fillRect(x, y, pw, ph);
// Milled-sheet speckle.
const rand = mulberry32(seed * 53 + 3);
const specks = Math.round((pw * ph) / 90);
for (let i = 0; i < specks; i++) {
ctx.fillStyle =
rand() < 0.5 ? "rgba(255,255,255,0.035)" : "rgba(0,0,0,0.08)";
ctx.fillRect(x + rand() * pw, y + rand() * ph, 1, 1);
}
// Lighter worn edges.
ctx.lineWidth = 2;
ctx.strokeStyle = "rgba(255,255,255,0.09)";
ctx.stroke(path);
ctx.restore();
ctx.lineWidth = 1;
ctx.strokeStyle = "rgba(255,255,255,0.14)";
const inner = new Path2D();
inner.roundRect(x + 0.5, y + 0.5, pw - 1, ph - 1, rr);
ctx.stroke(inner);
// Cut the letters (and two hanging holes) out of the sheet.
ctx.globalCompositeOperation = "destination-out";
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.drawImage(maskC, 0, 0);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const hr = clamp(fs * 0.035, 2, 6);
for (const hx of [x + padX * 0.42, x + pw - padX * 0.42]) {
ctx.beginPath();
ctx.arc(hx, y + padY * 0.48, hr, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalCompositeOperation = "source-over";
}
// Cut-face rim: the plate's far edge of each cutout catches the light.
const tmpC = mk(prev?.tmpC);
{
const t = tmpC.getContext("2d")!;
t.setTransform(1, 0, 0, 1, 0, 0);
t.globalCompositeOperation = "copy";
t.drawImage(maskC, 1.2 * dpr, 1.2 * dpr);
t.globalCompositeOperation = "destination-out";
t.drawImage(maskC, 0, 0);
t.globalCompositeOperation = "source-in";
t.fillStyle = "rgba(255,255,255,0.2)";
t.fillRect(0, 0, W, H);
t.globalCompositeOperation = "source-over";
const p = plateC.getContext("2d")!;
p.setTransform(1, 0, 0, 1, 0, 0);
p.globalCompositeOperation = "source-atop";
p.drawImage(tmpC, 0, 0);
p.globalCompositeOperation = "source-over";
}
// Plate thickness: hairline shadow inside each cutout's top-left edge.
const cutShadowC = mk(prev?.cutShadowC);
{
const c = cutShadowC.getContext("2d")!;
c.setTransform(1, 0, 0, 1, 0, 0);
c.globalCompositeOperation = "copy";
c.drawImage(maskC, 0, 0);
c.globalCompositeOperation = "destination-out";
c.drawImage(maskC, 2 * dpr, 2 * dpr);
c.globalCompositeOperation = "source-in";
c.fillStyle = "rgba(0,0,0,0.6)";
c.fillRect(0, 0, W, H);
c.globalCompositeOperation = "source-over";
}
// Soft spray sprite in the paint color.
const blob = prev?.blob ?? document.createElement("canvas");
blob.width = blob.height = 64;
{
const b = blob.getContext("2d")!;
b.clearRect(0, 0, 64, 64);
// Solid paint, alpha-shaped below — a color gradient to transparent
// black would leave a dark fringe on every splat.
b.fillStyle = paint;
b.globalAlpha = 1;
b.fillRect(0, 0, 64, 64);
// Feather the core so the coat builds softly.
b.globalCompositeOperation = "destination-in";
const f = b.createRadialGradient(32, 32, 0, 32, 32, 32);
f.addColorStop(0, "rgba(0,0,0,1)");
f.addColorStop(0.5, "rgba(0,0,0,0.55)");
f.addColorStop(1, "rgba(0,0,0,0)");
b.fillStyle = f;
b.fillRect(0, 0, 64, 64);
b.globalCompositeOperation = "source-over";
}
const passTotal = passCount * lines.length;
const R = Math.max(((box.y1 - box.y0) / passTotal) * 1.3, fs * 0.28);
const passDur = clamp(0.5 + (box.x1 - box.x0) / 1500, 0.6, 1.05);
const sprayEnd = START + passTotal * (passDur + GAP) - GAP;
const L: Layout = {
w,
h,
dpr,
fs,
lines: lines.length,
mask,
box,
plateRect,
edges,
maskC,
padSoftC,
padHardC,
plateC,
cutShadowC,
wordC: mk(prev?.wordC),
platePaintC: mk(prev?.platePaintC),
freshC: mk(prev?.freshC),
coatC: mk(prev?.coatC),
dripFreshC: mk(prev?.dripFreshC),
tmpC,
blob,
R,
passDur,
passCount: passTotal,
sprayEnd,
liftAt: sprayEnd + SETTLE,
};
layoutRef.current = L;
if (reducedRef.current || introDoneRef.current) {
introDoneRef.current = true;
paintInstant(L);
} else {
// (Re)start the intro against the new geometry.
clearPaint(L);
randRef.current = mulberry32(seed);
clockRef.current = 0;
planDrips(L);
}
dirtyRef.current = true;
render();
};
const ro = new ResizeObserver(() => build(false));
ro.observe(container);
build(true);
// Re-measure once web fonts land so the mask matches the final glyphs.
document.fonts?.ready.then(() => build(true)).catch(() => {});
return () => {
cancelled = true;
ro.disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
text,
fontSize,
fontFamily,
paint,
plate,
passCount,
seed,
paintInstant,
planDrips,
render,
]);
// Reduced motion flipping on mid-intro: jump to the finished word.
React.useEffect(() => {
const L = layoutRef.current;
if (!reducedMotion || !L || introDoneRef.current) return;
introDoneRef.current = true;
paintInstant(L);
render();
}, [reducedMotion, paintInstant, render]);
const replay = React.useCallback(() => {
const L = layoutRef.current;
if (!L) return;
if (reducedRef.current) {
paintInstant(L);
render();
return;
}
clearPaint(L);
randRef.current = mulberry32(seed);
clockRef.current = 0;
introDoneRef.current = false;
planDrips(L);
dirtyRef.current = true;
render();
}, [paintInstant, planDrips, render, seed]);
React.useImperativeHandle(ref, () => ({ replay }), [replay]);
// ---------------------------------------------------------------- input
const local = (event: React.PointerEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
return [event.clientX - rect.left, event.clientY - rect.top] as const;
};
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
onPointerDown?.(event);
if (event.defaultPrevented || event.button > 0) return;
const [x, y] = local(event);
event.currentTarget.setPointerCapture?.(event.pointerId);
dragRef.current = {
active: true,
id: event.pointerId,
x,
y,
lx: x,
ly: y,
dwell: 0,
};
if (reducedRef.current) {
const L = layoutRef.current;
if (!L) return;
sprayDrag(L, 1 / 60);
flush(L, OVERSPRAY * 2);
render();
}
};
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
onPointerMove?.(event);
const d = dragRef.current;
if (!d.active || d.id !== event.pointerId) return;
const [x, y] = local(event);
d.x = x;
d.y = y;
if (reducedRef.current) {
const L = layoutRef.current;
if (!L) return;
sprayDrag(L, 1 / 60);
flush(L, OVERSPRAY * 2);
render();
}
};
const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {
if (dragRef.current.id === event.pointerId) dragRef.current.active = false;
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
replay();
}
};
return (
<div
ref={containerRef}
data-crucible="stencil"
role="group"
tabIndex={0}
aria-roledescription="spray-painted headline"
aria-label={`${text} — drag to add paint, press Enter to spray it again`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={(e) => {
onPointerUp?.(e);
endDrag(e);
}}
onPointerCancel={(e) => {
onPointerCancel?.(e);
endDrag(e);
}}
onKeyDown={handleKeyDown}
className={cn(
"relative block h-full min-h-40 w-full cursor-crosshair touch-none overflow-hidden rounded-md outline-none select-none focus-visible:ring-2 focus-visible:ring-white/40",
className,
)}
{...props}
>
<canvas
ref={canvasRef}
aria-hidden
className="absolute inset-0 h-full w-full"
/>
{/* Real text for screen readers + SEO — the canvas is purely presentational. */}
<Tag className="sr-only">{text}</Tag>
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/stencilProps
| Prop | Type | Default | Description |
|---|---|---|---|
| text | string | "STENCIL" | The headline (also rendered as a visually hidden heading for screen readers + SEO). |
| as | "h1" | "h2" | "h3" | "p" | "h2" | Element the visually hidden real text is rendered as, so it stays a heading. |
| paint | string | "#f2f0eb" (off-white) | Spray paint color. |
| plate | string | "#2a2b2f" (graphite) | Stencil plate color — keep it a little lighter than the surface behind it. |
| fontSize | number | auto (~48% of the height) | Maximum glyph size in px; the headline scales down (and wraps to two lines) to fit. |
| fontFamily | string | a bold condensed sans stack (Impact / Oswald / Arial Narrow…) | Canvas font stack. Any counters (the holes in O, A, B…) get stencil bridges cut automatically, so plain bold fonts read as real stencils. Web fonts are picked up once document.fonts is ready. |
| passes | number | 3 | Number of S-curve sweeps the can makes per line of text. 1–8. |
| drips | number | 5 | Paint runs that trickle 4–18px down from the letters' bottom edges during the intro. 0–16. |
| seed | number | 7 | Deterministic seed for the spray grain, sweep paths and drip spots. |
| ref | React.Ref<StencilHandle> | Imperative handle: replay(). | |
Also accepts all props of Omit< React.HTMLAttributes<HTMLDivElement>, "children" | "color" > — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, pauses offscreen and on hidden tabs, and passes className through.