← All moods

Rumble Flash

A black "screen" plays the text word by word (about three a second), like a hand-made lyric video. Each word lands with a hit: on most words a white flash frame, often followed by one inverted frame (the word cut out of the white); then a punch-in, speed lines bursting outward, red/cyan split fringes and a hard shake that settles to a light rumble. Some words smash-zoom closer halfway through. Every word is drawn in a different hand-lettered style from the word before, sometimes with ink splats around it. The drawings "boil" (re-drawn slightly differently at 12 fps), the frame jitters at 24 fps, glitch blocks tear through it, and now and then a scribbled figure (ring, eye, X, lightning, spiral, burst, boxes) flashes up between words. Then it loops, with a fresh look each time.

React · Motion

Ink
Background
Flash

RumbleFlash.tsx

"use client";

import { useEffect, useRef, type CSSProperties } from "react";
import { useInView, useReducedMotion } from "motion/react";

export interface RumbleFlashProps {
  /** Words are shown one at a time, in order. */
  text: string;
  /** Seconds per word (roughly; longer words stay a little longer). */
  beat?: number;
  /** Chance (0–1) that a word slams in after a white flash frame. 0 = never. */
  flash?: number;
  /** Flash frame length, ms. Rounded to whole frames (at least one). */
  flashDuration?: number;
  flashColor?: string;
  /** Camera shake, 0–2. */
  rumble?: number;
  /** Glitch blocks, 0–1. */
  glitch?: number;
  /** Red/cyan split fringes on hits and glitches, 0–1. */
  chroma?: number;
  /** Size of the lettering and figures relative to the screen, 0.3–1.2. */
  size?: number;
  /** Lettering styles to pick from, one per word (never the same twice in a row when there's a choice). */
  styles?: Style[];
  /** Abstract scribbled figures between some words. */
  figures?: boolean;
  /** Frame rate. Drawings re-draw ("boil") at half of it. */
  fps?: number;
  loop?: boolean;
  delay?: number;
  /** Ink color. */
  color?: string;
  background?: string;
  trigger?: "load" | "inView";
  className?: string;
  style?: CSSProperties;
}

type Pt = [number, number];
export type Style = "dry" | "bubble" | "heavy" | "scrawl" | "nib" | "sketch" | "spray" | "drip";
const STYLES: Style[] = ["dry", "bubble", "heavy", "scrawl", "nib", "sketch", "spray", "drip"];
const FIGURES = 7;
// WCAG 2.3.1: never more than three flashes in any one second.
const MIN_FLASH_GAP = 1 / 3 + 0.01;

interface Shot {
  kind: "word" | "figure" | "gap";
  word: string;
  style: Style;
  vertical: boolean;
  figure: number;
  seed: number;
  start: number;
  dur: number;
  flash: boolean;
  /** Flash ends on one inverted frame (word in background color on white). */
  invert: boolean;
  /** Mid-shot smash zoom. */
  cut: boolean;
}

// ---------- randomness (only ever used inside effects) ----------

function mulberry32(a: number) {
  return () => {
    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;
  };
}
function hashStr(s: string) {
  let h = 2166136261;
  for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619);
  return h >>> 0;
}
function h1(n: number) {
  const x = Math.sin(n * 127.1 + 311.7) * 43758.5453;
  return x - Math.floor(x);
}
function noise1(x: number) {
  const i = Math.floor(x);
  const f = x - i;
  const u = f * f * (3 - 2 * f);
  return h1(i) * (1 - u) + h1(i + 1) * u;
}

// ---------- single-stroke letters (units: x 0..w, y 0 top .. 6 bottom) ----------

const RING = "2,0 3.4,0.6 4,2 4,4 3.4,5.4 2,6 0.6,5.4 0,4 0,2 0.6,0.6 2,0";
const BOWL_P = "0,6 0,0 2.8,0 3.8,0.8 3.8,2.4 2.8,3.2 0,3.2";
const GLYPHS: Record<string, [number, string]> = {
  A: [4, "0,6 2,0 4,6|0.8,3.9 3.2,3.9"],
  B: [3.6, "0,0 0,6|0,0 2.6,0 3.4,0.7 3.4,2.2 2.6,3 0,3|2.6,3 3.6,3.7 3.6,5.3 2.8,6 0,6"],
  C: [4, "3.8,0.8 2.8,0 1.2,0 0.2,1 0,3 0.2,5 1.2,6 2.8,6 3.8,5.2"],
  D: [4, "0,0 0,6|0,0 2,0 3.4,0.8 4,3 3.4,5.2 2,6 0,6"],
  E: [3.6, "3.6,0 0,0 0,6 3.6,6|0,3 2.8,3"],
  F: [3.6, "3.6,0 0,0 0,6|0,3 2.8,3"],
  G: [4, "3.8,0.8 2.8,0 1.2,0 0.2,1 0,3 0.2,5 1.2,6 2.8,6 4,5 4,3.4 2.2,3.4"],
  H: [4, "0,0 0,6|4,0 4,6|0,3 4,3"],
  I: [0, "0,0 0,6"],
  J: [3, "3,0 3,4.6 2.4,5.8 1.2,6 0.2,5.2"],
  K: [3.8, "0,0 0,6|3.8,0 0,3.6|1.4,2.4 3.8,6"],
  L: [3.4, "0,0 0,6 3.4,6"],
  M: [5, "0,6 0,0 2.5,4 5,0 5,6"],
  N: [4, "0,6 0,0 4,6 4,0"],
  O: [4, RING],
  P: [3.8, BOWL_P],
  Q: [4, `${RING}|2.4,4.4 4.2,6.4`],
  R: [3.9, `${BOWL_P}|1.8,3.2 3.9,6`],
  S: [3.8, "3.8,0.8 2.8,0 1.2,0 0.2,0.8 0.2,2.2 1.2,2.9 2.8,3.1 3.8,3.8 3.8,5.2 2.8,6 1.2,6 0.1,5.2"],
  T: [4, "0,0 4,0|2,0 2,6"],
  U: [4, "0,0 0,4.4 0.6,5.6 2,6 3.4,5.6 4,4.4 4,0"],
  V: [4, "0,0 2,6 4,0"],
  W: [5, "0,0 1.2,6 2.5,2 3.8,6 5,0"],
  X: [4, "0,0 4,6|4,0 0,6"],
  Y: [4, "0,0 2,3|4,0 2,3 2,6"],
  Z: [4, "0,0 4,0 0,6 4,6"],
  "0": [3.2, "1.6,0 2.8,0.6 3.2,2 3.2,4 2.8,5.4 1.6,6 0.4,5.4 0,4 0,2 0.4,0.6 1.6,0"],
  "1": [2, "0,1.2 2,0 2,6"],
  "2": [4, "0.2,1 1.2,0 2.8,0 3.8,1 3.8,2.2 0,6 4,6"],
  "3": [3.9, "0.2,0.8 1.2,0 2.8,0 3.8,0.8 3.8,2.2 2.8,3 1.4,3|2.8,3 3.9,3.8 3.9,5.2 2.8,6 1.2,6 0.1,5.2"],
  "4": [4, "3,6 3,0 0,4.2 4,4.2"],
  "5": [3.8, "3.8,0 0.4,0 0.2,2.8 1.4,2.4 2.8,2.4 3.8,3.2 3.8,5.2 2.8,6 1.2,6 0.1,5.2"],
  "6": [3.9, "3.6,0.6 2.6,0 1.4,0 0.3,1 0,3.2 0.1,5 1.2,6 2.8,6 3.9,5 3.9,3.8 2.8,3 1.2,3 0.1,3.8"],
  "7": [4, "0,0 4,0 1.4,6"],
  "8": [4, "2,3 0.6,2.4 0.4,1 1.2,0 2.8,0 3.6,1 3.4,2.4 2,3 0.4,3.8 0.1,5.2 1.2,6 2.8,6 3.9,5.2 3.6,3.8 2,3"],
  "9": [3.9, "3.9,2.8 2.8,3.2 1.2,3.2 0.1,2.2 0.1,1 1.2,0 2.8,0 3.9,1 3.9,3.2 3.4,5.2 2.2,6 0.6,5.6"],
  "!": [0, "0,0 0,4|0,5.5 0,6"],
  "?": [3, "0,1 1,0 2,0 3,1 3,2 1.5,3.2 1.5,4.2|1.5,5.5 1.5,6"],
  ".": [0, "0,5.5 0,6"],
  ",": [0.5, "0.5,5.4 0,6.6"],
  "'": [0, "0,0 0,1.4"],
  "-": [2.4, "0,3.2 2.4,3.2"],
  ":": [0, "0,1.6 0,2.1|0,5.4 0,5.9"],
  "/": [3, "3,0 0,6"],
};
const parsed = new Map<string, Pt[][]>();
function glyph(ch: string): Pt[][] | null {
  const g = GLYPHS[ch];
  if (!g) return null;
  let p = parsed.get(ch);
  if (!p) {
    p = g[1].split("|").map((s) => s.split(" ").map((xy) => xy.split(",").map(Number) as Pt));
    parsed.set(ch, p);
  }
  return p;
}

// Split a polyline at sharp corners: hand-letterers lift the brush there.
function splitCorners(pts: Pt[], maxTurn = 0.9): Pt[][] {
  const out: Pt[][] = [];
  let cur: Pt[] = [pts[0]];
  for (let i = 1; i < pts.length; i++) {
    cur.push(pts[i]);
    if (i < pts.length - 1) {
      const a = Math.atan2(pts[i][1] - pts[i - 1][1], pts[i][0] - pts[i - 1][0]);
      const b = Math.atan2(pts[i + 1][1] - pts[i][1], pts[i + 1][0] - pts[i][0]);
      let d = Math.abs(b - a);
      if (d > Math.PI) d = 2 * Math.PI - d;
      if (d > maxTurn) {
        out.push(cur);
        cur = [pts[i]];
      }
    }
  }
  if (cur.length > 1) out.push(cur);
  return out;
}

// ---------- dry brush ----------

interface BrushOpts {
  /** 0 = fully inked, 1 = very dry and streaky. */
  dry: number;
  /** Solid stroke under the bristles. */
  core?: number;
  /** Sideways hand wobble, in brush widths. */
  wobble?: number;
  /** Extend both ends by half the width (square, blocky ends). */
  extend?: boolean;
}

/** Draws one brush stroke along `pts` and returns its (wobbled) centre line. */
function brush(ctx: CanvasRenderingContext2D, input: Pt[], width: number, seed: number, o: BrushOpts): Pt[] {
  let pts = input;
  if (o.extend && pts.length > 1) {
    const ext = (a: Pt, b: Pt): Pt => {
      const dx = a[0] - b[0];
      const dy = a[1] - b[1];
      const l = Math.hypot(dx, dy) || 1;
      return [a[0] + (dx / l) * width * 0.45, a[1] + (dy / l) * width * 0.45];
    };
    pts = [ext(pts[0], pts[1]), ...pts.slice(1, -1), ext(pts[pts.length - 1], pts[pts.length - 2])];
    if (input.length === 2) pts = [pts[0], pts[pts.length - 1]];
  }
  const cum = [0];
  for (let i = 1; i < pts.length; i++) cum.push(cum[i - 1] + Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]));
  const total = cum[cum.length - 1];
  if (total < 0.5) return pts;
  const n = Math.max(2, Math.ceil(total / Math.max(1, width / 5)));
  const wob = (o.wobble ?? 0.25) * width;
  const cx: number[] = [];
  const cy: number[] = [];
  const nx: number[] = [];
  const ny: number[] = [];
  const ws: number[] = [];
  let j = 0;
  for (let k = 0; k <= n; k++) {
    const d = (k / n) * total;
    while (j < pts.length - 2 && cum[j + 1] < d) j++;
    const segLen = cum[j + 1] - cum[j] || 1;
    const f = (d - cum[j]) / segLen;
    const tx = (pts[j + 1][0] - pts[j][0]) / segLen;
    const ty = (pts[j + 1][1] - pts[j][1]) / segLen;
    const off = (noise1(d / (width * 4) + seed) - 0.5) * 2 * wob;
    nx.push(-ty);
    ny.push(tx);
    cx.push(pts[j][0] + (pts[j + 1][0] - pts[j][0]) * f - ty * off);
    cy.push(pts[j][1] + (pts[j + 1][1] - pts[j][1]) * f + tx * off);
    const t = k / n;
    // Pressure: lands heavy, lifts a little at the end.
    ws.push(width * (0.82 + 0.22 * Math.sin(Math.PI * Math.min(1, t * 1.3)) - 0.12 * t));
  }

  if (o.core) {
    ctx.lineWidth = width * o.core;
    ctx.lineCap = o.extend ? "butt" : "round";
    ctx.lineJoin = "round";
    ctx.beginPath();
    ctx.moveTo(cx[0], cy[0]);
    for (let k = 1; k <= n; k++) ctx.lineTo(cx[k], cy[k]);
    ctx.stroke();
  }

  const B = Math.max(6, Math.min(40, Math.round(width / 1.4)));
  ctx.lineCap = "butt";
  ctx.lineJoin = "round";
  for (let b = 0; b < B; b++) {
    const r = h1(seed * 3.1 + b * 7.7);
    const off = (b + 0.5) / B - 0.5 + (r - 0.5) * (0.6 / B);
    const freq = 1 / (width * (1.5 + 2.5 * r));
    const edge = Math.abs(off) * 2;
    ctx.lineWidth = (width / B) * (0.8 + 1.1 * h1(seed + b * 1.3));
    ctx.beginPath();
    let down = false;
    for (let k = 0; k <= n; k++) {
      const t = k / n;
      const d = (k / n) * total;
      // Sharpened noise: long inked streaks with clean gaps between them.
      const ink = Math.min(1, Math.max(0, (noise1(d * freq + b * 13.7 + seed) - 0.5) * 2.2 + 0.5));
      const thresh = o.dry * (0.3 + 0.55 * t + 0.5 * edge * edge) - 0.08;
      const x = cx[k] + nx[k] * off * ws[k];
      const y = cy[k] + ny[k] * off * ws[k];
      if (ink > thresh) {
        if (!down) ctx.moveTo(x, y);
        else ctx.lineTo(x, y);
        down = true;
      } else down = false;
    }
    ctx.stroke();
  }
  return cx.map((x, k) => [x, cy[k]] as Pt);
}

/** Cut `len` px off both ends of a polyline. */
function trim(pts: Pt[], len: number): Pt[] {
  const cutStart = (p: Pt[]) => {
    let left = len;
    for (let i = 1; i < p.length; i++) {
      const d = Math.hypot(p[i][0] - p[i - 1][0], p[i][1] - p[i - 1][1]);
      if (d >= left) {
        const f = left / (d || 1);
        return [[p[i - 1][0] + (p[i][0] - p[i - 1][0]) * f, p[i - 1][1] + (p[i][1] - p[i - 1][1]) * f] as Pt, ...p.slice(i)];
      }
      left -= d;
    }
    return [];
  };
  return cutStart(cutStart(pts).reverse()).reverse();
}

function brushPath(ctx: CanvasRenderingContext2D, pts: Pt[], width: number, seed: number, o: BrushOpts) {
  splitCorners(pts).forEach((s, i) => brush(ctx, s, width, seed + i * 17.3, o));
}

/** Evenly spaced points along a polyline, wobbled sideways by noise. */
function resample(pts: Pt[], step: number, wob: number, seed: number): Pt[] {
  const out: Pt[] = [];
  for (let i = 1; i < pts.length; i++) {
    const [ax, ay] = pts[i - 1];
    const [bx, by] = pts[i];
    const len = Math.hypot(bx - ax, by - ay);
    const n = Math.max(1, Math.ceil(len / step));
    const nx = -(by - ay) / (len || 1);
    const ny = (bx - ax) / (len || 1);
    for (let k = i === 1 ? 0 : 1; k <= n; k++) {
      const f = k / n;
      const off = (noise1((out.length * step) / (step * 25) + seed) - 0.5) * 2 * wob;
      out.push([ax + (bx - ax) * f + nx * off, ay + (by - ay) * f + ny * off]);
    }
  }
  return out;
}

/** Broad-nib calligraphy: a flat pen held at a fixed angle, thick and thin. */
function nib(ctx: CanvasRenderingContext2D, pts: Pt[], width: number, angle: number, seed: number) {
  const s = resample(pts, Math.max(1, width / 8), width * 0.08, seed);
  if (s.length < 2) return;
  for (let k = 1; k < s.length; k++) {
    const t = k / (s.length - 1);
    const pw = (width / 2) * (0.75 + 0.35 * Math.sin(Math.PI * Math.min(1, t * 1.2)));
    const nx = Math.cos(angle) * pw;
    const ny = Math.sin(angle) * pw;
    const [ax, ay] = s[k - 1];
    const [bx, by] = s[k];
    ctx.beginPath();
    ctx.moveTo(ax + nx, ay + ny);
    ctx.lineTo(bx + nx, by + ny);
    ctx.lineTo(bx - nx, by - ny);
    ctx.lineTo(ax - nx, ay - ny);
    ctx.closePath();
    ctx.fill();
  }
  // Ink runs dry in streaks toward the end of the stroke.
  ctx.save();
  ctx.globalCompositeOperation = "destination-out";
  ctx.lineWidth = Math.max(1, width * 0.045);
  ctx.lineCap = "butt";
  for (let b = 0; b < 7; b++) {
    const off = (h1(seed + b * 3.1) - 0.5) * 0.9 * (width / 2);
    const ox = Math.cos(angle) * off;
    const oy = Math.sin(angle) * off;
    ctx.beginPath();
    let down = false;
    s.forEach(([x, y], k) => {
      const t = k / (s.length - 1);
      const gap = noise1(k * 0.12 + b * 9.1 + seed) < -0.15 + t * 0.9;
      if (gap) {
        if (!down) ctx.moveTo(x + ox, y + oy);
        else ctx.lineTo(x + ox, y + oy);
        down = true;
      } else down = false;
    });
    ctx.stroke();
  }
  ctx.restore();
}

/** Pencil sketch: a few loose overshooting passes plus hatched shading. */
function sketch(ctx: CanvasRenderingContext2D, pts: Pt[], U: number, seed: number, rng: () => number) {
  for (let pass = 0; pass < 3; pass++) {
    const jit = pts.map(([x, y]) => [x + (rng() - 0.5) * U * 0.35, y + (rng() - 0.5) * U * 0.35] as Pt);
    const ext = (a: Pt, b: Pt): Pt => {
      const l = Math.hypot(a[0] - b[0], a[1] - b[1]) || 1;
      const o = U * (0.15 + rng() * 0.6);
      return [a[0] + ((a[0] - b[0]) / l) * o, a[1] + ((a[1] - b[1]) / l) * o];
    };
    if (jit.length > 1) {
      jit[0] = ext(jit[0], jit[1]);
      jit[jit.length - 1] = ext(jit[jit.length - 1], jit[jit.length - 2]);
    }
    brushPath(ctx, jit, U * (pass ? 0.12 : 0.2), seed + pass * 5.1, { dry: 0.3, wobble: 0.9 });
  }
  // Hatched drop shadow down-right of the stroke.
  const s = resample(pts, U * 0.42, 0, seed);
  ctx.lineWidth = Math.max(1, U * 0.07);
  ctx.lineCap = "round";
  ctx.beginPath();
  s.forEach(([x, y]) => {
    const x0 = x + U * 0.45 + (rng() - 0.5) * U * 0.15;
    const y0 = y + U * 0.5 + (rng() - 0.5) * U * 0.15;
    ctx.moveTo(x0, y0);
    ctx.lineTo(x0 + U * 0.45, y0 - U * 0.6);
  });
  ctx.stroke();
}

function drip(ctx: CanvasRenderingContext2D, x: number, y: number, len: number, w: number) {
  ctx.beginPath();
  ctx.moveTo(x - w / 2, y);
  ctx.quadraticCurveTo(x - w * 0.2, y + len * 0.5, x - w * 0.3, y + len);
  ctx.arc(x, y + len, w * 0.5, Math.PI, 0, true);
  ctx.quadraticCurveTo(x + w * 0.2, y + len * 0.5, x + w / 2, y);
  ctx.closePath();
  ctx.fill();
}

/** Paint drips hanging from flat parts of a stroke and from its lowest end. */
function drips(ctx: CanvasRenderingContext2D, pts: Pt[], w: number, U: number, rng: () => number, chance: number) {
  for (let i = 1; i < pts.length; i++) {
    const [ax, ay] = pts[i - 1];
    const [bx, by] = pts[i];
    if (Math.abs(by - ay) < Math.abs(bx - ax) * 0.6 && rng() < chance) {
      const t = 0.15 + rng() * 0.7;
      drip(ctx, ax + (bx - ax) * t, ay + (by - ay) * t + w * 0.4, U * (0.8 + rng() * 4.5 * rng()), w * (0.45 + rng() * 0.55));
    }
  }
  const a = pts[0];
  const b = pts[pts.length - 1];
  const low = a[1] > b[1] ? a : b;
  if (rng() < chance) drip(ctx, low[0], low[1], U * (0.6 + rng() * rng() * 4), w * 0.8);
}

/** Spray paint: crisp core, soft overspray dots, runs. */
function spray(ctx: CanvasRenderingContext2D, pts: Pt[], U: number, seed: number, rng: () => number) {
  const s = resample(pts, U * 0.1, U * 0.05, seed);
  ctx.save();
  // Soft haze: wide faint passes, narrower and stronger toward the core.
  ctx.lineCap = "round";
  ctx.lineJoin = "round";
  for (const [w, a] of [[2.4, 0.07], [1.7, 0.1], [1.15, 0.16]]) {
    ctx.globalAlpha = a;
    ctx.lineWidth = U * w;
    ctx.beginPath();
    s.forEach(([x, y], k) => (k ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
    ctx.stroke();
  }
  // Overspray speckle.
  ctx.globalAlpha = 0.85;
  s.forEach(([x, y]) => {
    for (let i = 0; i < 16; i++) {
      const g = (rng() + rng() + rng() - 1.5) / 1.5;
      const r = U * 1.3 * g;
      const a = rng() * Math.PI * 2;
      const d = Math.max(1, U * (0.025 + 0.07 * rng() * rng()));
      ctx.fillRect(x + Math.cos(a) * r, y + Math.sin(a) * r, d, d);
    }
  });
  ctx.restore();
  brushPath(ctx, pts, U * 0.62, seed, { dry: 0.04, core: 0.75, wobble: 0.08 });
  drips(ctx, pts, U * 0.2, U, rng, 0.7);
}

/** Ink splat: lumpy blob, satellite drops and flung streaks. */
function splat(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, rng: () => number) {
  ctx.beginPath();
  const n = 14;
  for (let i = 0; i <= n; i++) {
    const a = (i / n) * Math.PI * 2;
    const rr = r * (0.55 + rng() * 0.6);
    if (i) ctx.lineTo(x + Math.cos(a) * rr, y + Math.sin(a) * rr);
    else ctx.moveTo(x + Math.cos(a) * rr, y + Math.sin(a) * rr);
  }
  ctx.fill();
  const drops = 8 + Math.floor(rng() * 10);
  for (let i = 0; i < drops; i++) {
    const a = rng() * Math.PI * 2;
    const d = r * (1.2 + rng() * rng() * 4);
    const sz = r * (0.05 + rng() * 0.22);
    ctx.beginPath();
    ctx.arc(x + Math.cos(a) * d, y + Math.sin(a) * d, sz, 0, Math.PI * 2);
    ctx.fill();
    if (rng() < 0.35) {
      ctx.lineWidth = sz * 0.8;
      ctx.lineCap = "round";
      ctx.beginPath();
      ctx.moveTo(x + Math.cos(a) * r * 0.8, y + Math.sin(a) * r * 0.8);
      ctx.lineTo(x + Math.cos(a) * d, y + Math.sin(a) * d);
      ctx.stroke();
    }
  }
}

// ---------- plates (one drawing of a shot) ----------

interface Placed {
  ch: string;
  cx: number;
  cy: number;
  sc: number;
  rot: number;
}

function layout(shot: Shot, W: number, H: number, measure: (ch: string) => number) {
  const rng = mulberry32(shot.seed);
  const chars = [...shot.word.toUpperCase()].filter((c) => c.trim());
  const width = (c: string) => (glyph(c) ? GLYPHS[c][0] : measure(c));
  const gap = { dry: 1.4, bubble: 2.6, heavy: 2, scrawl: 1.2, nib: 1.5, sketch: 1.6, spray: 2.1, drip: 2 }[shot.style];
  const pad = { dry: 1, bubble: 2.6, heavy: 1.8, scrawl: 0.8, nib: 1.4, sketch: 1.4, spray: 2, drip: 1.8 }[shot.style];
  const rotAmt = { dry: 0.14, bubble: 0.32, heavy: 0.09, scrawl: 0.1, nib: 0.06, sketch: 0.16, spray: 0.12, drip: 0.08 }[shot.style];
  const placed: Placed[] = [];
  let U: number;
  if (shot.vertical) {
    const maxW = Math.max(...chars.map(width)) + pad;
    U = Math.min((H * 0.88) / (chars.length * 6 + (chars.length - 1) * 1.4 + pad), (W * 0.55) / maxW);
    const step = (6 + 1.4) * U;
    const y0 = H / 2 - ((chars.length - 1) * step) / 2;
    chars.forEach((ch, i) => {
      placed.push({
        ch,
        cx: W / 2 + (rng() - 0.5) * 1.6 * U,
        cy: y0 + i * step + (rng() - 0.5) * 0.6 * U,
        sc: 0.95 + rng() * 0.15,
        rot: (rng() - 0.5) * rotAmt,
      });
    });
    return { placed, U };
  }
  // One row or two, whichever lets the letters be bigger.
  const rowH = 6 + pad + 1.5;
  const fit = (rows: string[][]) => {
    const rowW = rows.map((r) => r.reduce((s, c) => s + width(c), 0) + gap * (r.length - 1) + pad);
    const U = Math.min((W * 0.82) / Math.max(...rowW), (H * (rows.length > 1 ? 0.8 : 0.58)) / (rowH * rows.length));
    return { rows, rowW, U };
  };
  const half = Math.ceil(chars.length / 2);
  const one = fit([chars]);
  const two = chars.length >= 4 ? fit([chars.slice(0, half), chars.slice(half)]) : one;
  const { rows, rowW } = two.U > one.U * 1.15 ? two : one;
  U = (two.U > one.U * 1.15 ? two : one).U;
  rows.forEach((row, ri) => {
    let x = W / 2 - ((rowW[ri] - pad) * U) / 2;
    const cy = H / 2 + (ri - (rows.length - 1) / 2) * rowH * U;
    row.forEach((ch) => {
      const w = width(ch);
      placed.push({
        ch,
        cx: x + (w * U) / 2,
        cy: cy + (rng() - 0.5) * 1.1 * U,
        sc: 0.88 + rng() * 0.24,
        rot: (rng() - 0.5) * rotAmt,
      });
      x += (w + gap) * U;
    });
  });
  return { placed, U };
}

function drawWord(ctx: CanvasRenderingContext2D, shot: Shot, variant: number, W: number, H: number, font: string) {
  const measure = (ch: string) => {
    ctx.font = `900 100px ${font}`;
    return (ctx.measureText(ch).width / 100) * 6.6;
  };
  const { placed, U } = layout(shot, W, H, measure);
  const vr = mulberry32(shot.seed + variant * 1013);
  placed.forEach((p, li) => {
    const strokes = glyph(p.ch);
    const s = U * p.sc;
    const rot = p.rot + (vr() - 0.5) * 0.03;
    const cos = Math.cos(rot);
    const sin = Math.sin(rot);
    const skew = shot.style === "scrawl" ? 0.28 : shot.style === "nib" ? 0.2 : 0;
    const w = strokes ? GLYPHS[p.ch][0] : 0;
    const map = ([gx, gy]: Pt): Pt => {
      const lx = (gx - w / 2 + (6 - gy - 3) * skew) * s;
      const ly = (gy - 3) * s;
      return [p.cx + lx * cos - ly * sin, p.cy + lx * sin + ly * cos];
    };
    const seed = shot.seed * 0.001 + li * 31.7 + variant * 5.3;
    if (!strokes) {
      // No hand-drawn glyph: use the CSS font, roughened later.
      ctx.save();
      ctx.translate(p.cx, p.cy);
      ctx.rotate(rot);
      ctx.font = `900 ${6.6 * s}px ${font}`;
      ctx.textAlign = "center";
      ctx.textBaseline = "middle";
      if (shot.style === "bubble") {
        ctx.lineWidth = s * 0.5;
        ctx.lineJoin = "miter";
        ctx.strokeText(p.ch, 0, 0);
      } else ctx.fillText(p.ch, 0, 0);
      ctx.restore();
      return;
    }
    const paths = strokes.map((st) => st.map(map));
    if (shot.style === "dry") {
      paths.forEach((pts, i) => brushPath(ctx, pts, U * 0.78, seed + i * 3.3, { dry: 0.6, core: 0.18, wobble: 0.3 }));
    } else if (shot.style === "heavy") {
      paths.forEach((pts, i) => brushPath(ctx, pts, U * 1.2, seed + i * 3.3, { dry: 0.3, core: 0.55, wobble: 0.2 }));
    } else if (shot.style === "nib") {
      paths.forEach((pts, i) => splitCorners(pts, 1.5).forEach((seg, k) => nib(ctx, seg, U * 1.15, -0.8, seed + i * 3.3 + k)));
    } else if (shot.style === "sketch") {
      paths.forEach((pts, i) => sketch(ctx, pts, U, seed + i * 3.3, vr));
    } else if (shot.style === "spray") {
      const dr = mulberry32(shot.seed + li * 97);
      paths.forEach((pts, i) => spray(ctx, pts, U, seed + i * 3.3, dr));
    } else if (shot.style === "drip") {
      const dr = mulberry32(shot.seed + li * 97);
      paths.forEach((pts, i) => {
        brushPath(ctx, pts, U * 1.1, seed + i * 3.3, { dry: 0.25, core: 0.6, wobble: 0.15 });
        drips(ctx, pts, U * 0.38, U, dr, 0.85);
      });
    } else if (shot.style === "scrawl") {
      paths.forEach((pts, i) => {
        brushPath(ctx, pts, U * 0.3, seed + i * 3.3, { dry: 0.15, wobble: 0.6 });
        if (vr() < 0.6) {
          const o = U * 0.22;
          brushPath(ctx, pts.map(([x, y]) => [x + o, y - o * 0.5] as Pt), U * 0.2, seed + i * 9.1, { dry: 0.5, wobble: 0.8 });
        }
      });
    } else {
      // Bubble: thick blocky stroke, hollowed out, a few dry streaks inside.
      const outer = U * 1.9;
      const border = U * 0.42;
      const inner = outer - border * 2;
      const centres: Pt[][] = [];
      paths.forEach((pts, i) =>
        splitCorners(pts).forEach((seg, k) =>
          centres.push(brush(ctx, seg, outer, seed + i * 3.3 + k * 1.7, { dry: 0.1, core: 0.85, wobble: 0.1, extend: true })),
        ),
      );
      // Hollow out with square-ended cuts, stopping `border` short of each
      // stroke end, so the outline stays blocky and closed.
      ctx.save();
      ctx.globalCompositeOperation = "destination-out";
      ctx.lineWidth = inner;
      ctx.lineCap = "butt";
      ctx.lineJoin = "miter";
      centres.forEach((c) => {
        const cut = trim(c, border);
        if (cut.length < 2) return;
        ctx.beginPath();
        cut.forEach(([x, y], k) => (k ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
        ctx.stroke();
      });
      ctx.restore();
      centres.forEach((c, k) => {
        if (vr() < 0.3) brush(ctx, trim(c, border), inner * 0.7, seed + k * 5.9 + 40, { dry: 0.9, wobble: 0.1 });
      });
    }
  });

  // Calligraphy gets a swash underline now and then.
  const sr = mulberry32(shot.seed ^ 0x5a5a);
  if (shot.style === "nib" && placed.length && sr() < 0.65) {
    const x0 = Math.min(...placed.map((p) => p.cx)) - U * 2;
    const x1 = Math.max(...placed.map((p) => p.cx)) + U * 2.5;
    const yb = Math.max(...placed.map((p) => p.cy)) + U * 3.8;
    const pts: Pt[] = [];
    for (let i = 0; i <= 40; i++) {
      const t = i / 40;
      pts.push([x0 + (x1 - x0) * t, yb + Math.sin(t * Math.PI * 1.6 + 0.4) * U * 0.9 - t * U * 1.2]);
    }
    nib(ctx, pts, U * 0.8, -0.8, shot.seed * 0.003);
  }
}

/** Ink splats around the drawing (same in every boil variant). */
function splats(ctx: CanvasRenderingContext2D, shot: Shot, W: number, H: number) {
  const rng = mulberry32(shot.seed ^ 0x1234);
  if (rng() > 0.45) return;
  const n = 1 + Math.floor(rng() * 3);
  const m = Math.min(W, H);
  for (let i = 0; i < n; i++) {
    const a = rng() * Math.PI * 2;
    splat(ctx, W / 2 + Math.cos(a) * W * (0.3 + rng() * 0.15), H / 2 + Math.sin(a) * H * (0.3 + rng() * 0.12), m * (0.02 + rng() * 0.04), rng);
  }
}

function drawFigure(ctx: CanvasRenderingContext2D, shot: Shot, variant: number, W: number, H: number) {
  const rng = mulberry32(shot.seed);
  const vr = mulberry32(shot.seed + variant * 1013);
  const cx = W / 2 + (rng() - 0.5) * W * 0.15;
  const cy = H / 2 + (rng() - 0.5) * H * 0.1;
  const R = Math.min(W, H) * (0.28 + rng() * 0.08);
  const seed = shot.seed * 0.001 + variant * 7.1;
  const j = () => (vr() - 0.5) * R * 0.04;
  const circle = (r: number, turns: number, from = 0, wobble = 0.12): Pt[] => {
    const pts: Pt[] = [];
    const n = Math.ceil(48 * turns);
    for (let i = 0; i <= n; i++) {
      const a = from + (i / n) * turns * Math.PI * 2;
      const rr = r * (1 + (noise1(i * 0.15 + seed + rng()) - 0.5) * wobble);
      pts.push([cx + Math.cos(a) * rr + j(), cy + Math.sin(a) * rr * 0.92 + j()]);
    }
    return pts;
  };
  const line = (a: Pt, b: Pt, w: number, s: number, dry = 0.4) => brush(ctx, [a, b], w, s, { dry, core: 0.3 });
  switch (shot.figure % FIGURES) {
    case 0: // scribbled ring
      brush(ctx, circle(R, 2.4, rng() * 6, 0.25), R * 0.08, seed, { dry: 0.45, wobble: 0.5 });
      break;
    case 1: {
      // eye
      const up: Pt[] = [];
      const lo: Pt[] = [];
      for (let i = 0; i <= 24; i++) {
        const t = i / 24;
        const x = cx + (t - 0.5) * 2.2 * R;
        const y = Math.sin(Math.PI * t) * R * 0.62;
        up.push([x + j(), cy - y + j()]);
        lo.push([x + j(), cy + y * 0.8 + j()]);
      }
      brush(ctx, up, R * 0.09, seed, { dry: 0.4 });
      brush(ctx, lo, R * 0.09, seed + 3, { dry: 0.4 });
      brush(ctx, circle(R * 0.22, 3.5, 0, 0.3).map(([x, y]) => [cx + (x - cx) * (0.4 + 0.6 * vr()), y] as Pt), R * 0.16, seed + 5, { dry: 0.2, core: 0.7 });
      for (let i = 0; i < 7; i++) {
        const t = 0.15 + (i / 6) * 0.7;
        const x = cx + (t - 0.5) * 2.2 * R;
        const y = cy - Math.sin(Math.PI * t) * R * 0.62;
        line([x, y], [x + (t - 0.5) * R * 0.5, y - R * 0.35], R * 0.04, seed + 9 + i, 0.3);
      }
      break;
    }
    case 2: // big X
      line([cx - R, cy - R], [cx + R, cy + R], R * 0.24, seed, 0.35);
      line([cx + R, cy - R * 0.9], [cx - R * 0.9, cy + R], R * 0.24, seed + 4, 0.35);
      break;
    case 3: {
      // lightning zigzag
      const pts: Pt[] = [];
      for (let i = 0; i <= 6; i++) pts.push([cx + (i % 2 ? 0.45 : -0.45) * R + j() * 4, cy - R * 1.2 + (i / 6) * R * 2.4]);
      brushPath(ctx, pts, R * 0.16, seed, { dry: 0.35, core: 0.4 });
      break;
    }
    case 4: {
      // spiral
      const pts: Pt[] = [];
      for (let i = 0; i <= 160; i++) {
        const t = i / 160;
        const a = t * Math.PI * 2 * 3.4;
        pts.push([cx + Math.cos(a) * R * t + j(), cy + Math.sin(a) * R * t + j()]);
      }
      brush(ctx, pts, R * 0.07, seed, { dry: 0.35, wobble: 0.4 });
      break;
    }
    case 5: // burst
      brush(ctx, circle(R * 0.3, 1.1, 0, 0.2), R * 0.1, seed, { dry: 0.3, core: 0.5 });
      for (let i = 0; i < 14; i++) {
        const a = (i / 14) * Math.PI * 2 + vr() * 0.15;
        const r1 = R * 0.48;
        const r2 = R * (0.8 + rng() * 0.45);
        line([cx + Math.cos(a) * r1, cy + Math.sin(a) * r1], [cx + Math.cos(a) * r2, cy + Math.sin(a) * r2], R * 0.07, seed + i, 0.5);
      }
      break;
    default: {
      // nested boxes
      for (let k = 0; k < 3; k++) {
        const r = R * (1 - k * 0.3);
        const a = (rng() - 0.5) * 0.4;
        const c = Math.cos(a);
        const s = Math.sin(a);
        const pts: Pt[] = [[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1.15]].map(([x, y]) => [cx + (x * c - y * s) * r + j(), cy + (x * s + y * c) * r + j()] as Pt);
        brushPath(ctx, pts, R * 0.08, seed + k * 7, { dry: 0.5, extend: true });
      }
    }
  }
}

/** Ragged, hand-cut edges: shift thin rows sideways by noise. */
function roughen(src: HTMLCanvasElement, seed: number, dpr: number) {
  const out = document.createElement("canvas");
  out.width = src.width;
  out.height = src.height;
  const ctx = out.getContext("2d")!;
  const h = Math.max(2, Math.round(2 * dpr));
  const amp = Math.max(1, Math.min(src.width, src.height) * 0.0035);
  for (let y = 0; y < src.height; y += h) {
    const dx = (noise1(y / (10 * dpr) + seed) - 0.5) * 2 * amp + (h1(y + seed) - 0.5) * amp;
    ctx.drawImage(src, 0, y, src.width, h, dx, y, src.width, h);
  }
  return out;
}

/** The plate's shape filled with one flat color. */
function tint(src: HTMLCanvasElement, col: string) {
  const c = document.createElement("canvas");
  c.width = src.width;
  c.height = src.height;
  const ctx = c.getContext("2d")!;
  ctx.drawImage(src, 0, 0);
  ctx.globalCompositeOperation = "source-in";
  ctx.fillStyle = col;
  ctx.fillRect(0, 0, c.width, c.height);
  return c;
}

function makePlate(shot: Shot, variant: number, W: number, H: number, ink: string, font: string, dpr: number, size: number) {
  const c = document.createElement("canvas");
  c.width = W;
  c.height = H;
  const ctx = c.getContext("2d")!;
  ctx.fillStyle = ink;
  ctx.strokeStyle = ink;
  // Scale the whole drawing about the centre.
  ctx.translate(W / 2, H / 2);
  ctx.scale(size, size);
  ctx.translate(-W / 2, -H / 2);
  if (shot.kind === "word") drawWord(ctx, shot, variant, W, H, font);
  else if (shot.kind === "figure") drawFigure(ctx, shot, variant, W, H);
  if (shot.kind !== "gap") splats(ctx, shot, W, H);
  return roughen(c, shot.seed * 0.01 + variant * 3.7, dpr);
}

// ---------- timeline ----------

function schedule(words: string[], seed: number, o: { beat: number; flash: number; figures: boolean; loop: boolean; tall: boolean; styles: Style[] }) {
  const rng = mulberry32(seed);
  const shots: Shot[] = [];
  let t = 0;
  let lastFlash = -10;
  let lastStyle: Style | null = null;
  const push = (s: Omit<Shot, "start" | "flash" | "seed" | "invert" | "cut">, flashChance: number) => {
    const flash = o.flash > 0 && rng() < flashChance && t - lastFlash >= MIN_FLASH_GAP;
    if (flash) lastFlash = t;
    const invert = flash && rng() < 0.6;
    const cut = s.kind === "word" && s.dur > 0.22 && rng() < 0.55;
    shots.push({ ...s, start: t, flash, invert, cut, seed: Math.floor(rng() * 1e9) });
    t += s.dur;
  };
  words.forEach((word, i) => {
    if (o.figures && i > 0 && rng() < 0.35) {
      push({ kind: "figure", word: "", style: "dry", vertical: false, figure: Math.floor(rng() * FIGURES), dur: Math.max(0.1, o.beat * 0.45) }, o.flash * 0.7);
    }
    const choices = o.styles.length > 1 ? o.styles.filter((s) => s !== lastStyle) : o.styles;
    const style = choices[Math.floor(rng() * choices.length)];
    lastStyle = style;
    const len = [...word].length;
    push(
      {
        kind: "word",
        word,
        style,
        vertical: o.tall && style === "bubble" && len <= 4 && len > 1 && rng() < 0.7,
        figure: 0,
        dur: o.beat * (0.8 + 0.06 * Math.min(len, 8)) * (0.8 + 0.4 * rng()),
      },
      i === 0 ? 1 : o.flash,
    );
  });
  if (o.loop) push({ kind: "gap", word: "", style: "dry", vertical: false, figure: 0, dur: Math.max(MIN_FLASH_GAP, o.beat * 0.8) }, 0);
  return { shots, total: t };
}

// ---------- component ----------

export function RumbleFlash({
  text,
  beat = 0.32,
  flash = 0.8,
  flashDuration = 50,
  flashColor = "#ffffff",
  rumble = 1,
  glitch = 0.6,
  chroma = 0.7,
  size = 0.65,
  styles = STYLES,
  figures = true,
  fps = 24,
  loop = true,
  delay = 0.2,
  color = "#ffffff",
  background = "#000000",
  trigger = "load",
  className,
  style,
}: RumbleFlashProps) {
  const reduceMotion = useReducedMotion();
  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const inView = useInView(rootRef, { once: true, amount: 0.3 });
  const started = trigger === "load" || inView;

  useEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    if (!root || !canvas || reduceMotion) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    const words = text.split(/\s+/).filter(Boolean);
    // Stack short words vertically only in tall boxes.
    const opts = { beat: Math.max(0.12, beat), flash: Math.min(1, Math.max(0, flash)), figures, loop, tall: root.clientHeight > root.clientWidth * 0.9, styles: styles.length ? styles : STYLES };
    const base = hashStr(text);

    let W = 1;
    let H = 1;
    let dpr = 1;
    let font = "sans-serif";
    let lastFrame = -1;
    let shot: Shot | null = null;
    interface Plate {
      ink: HTMLCanvasElement;
      red?: HTMLCanvasElement;
      cyan?: HTMLCanvasElement;
      inv?: HTMLCanvasElement;
    }
    let plates: Plate[] = [];
    // Film grain tiles, picked at random each frame.
    const grain = [0, 1, 2].map(() => {
      const g = document.createElement("canvas");
      g.width = g.height = 160;
      const gc = g.getContext("2d")!;
      gc.fillStyle = color;
      for (let i = 0; i < 900; i++) gc.fillRect(Math.random() * 160, Math.random() * 160, 1 + Math.random(), 1 + Math.random());
      return ctx.createPattern(g, "repeat");
    });
    const resize = () => {
      dpr = Math.min(window.devicePixelRatio || 1, 2);
      W = Math.max(1, Math.round(root.clientWidth * dpr));
      H = Math.max(1, Math.round(root.clientHeight * dpr));
      canvas.width = W;
      canvas.height = H;
      // Fallback letters (no hand-drawn glyph) use the CSS font family, heavy.
      font = getComputedStyle(root).fontFamily || "sans-serif";
      shot = null;
      lastFrame = -1;
    };
    resize();
    const observer = new ResizeObserver(resize);
    observer.observe(root);
    const onFonts = () => {
      shot = null;
      lastFrame = -1;
    };
    document.fonts?.addEventListener("loadingdone", onFonts);

    let loopIdx = 0;
    let loopStart = 0;
    let sched = schedule(words, base, opts);
    const t0 = performance.now();
    let raf = 0;

    const clear = () => {
      ctx.setTransform(1, 0, 0, 1, 0, 0);
      ctx.globalAlpha = 1;
      ctx.fillStyle = background;
      ctx.fillRect(0, 0, W, H);
    };

    const draw = (now: number) => {
      raf = requestAnimationFrame(draw);
      const t = (now - t0) / 1000 - delay;
      const frame = Math.floor(t * fps);
      if (frame === lastFrame) return;
      lastFrame = frame;
      if (!started || t < 0 || !words.length) return clear();

      while (loop && t - loopStart >= sched.total) {
        loopStart += sched.total;
        loopIdx++;
        sched = schedule(words, base + loopIdx * 7919, opts);
      }
      let local = t - loopStart;
      let cur = sched.shots.find((s) => local >= s.start && local < s.start + s.dur);
      let held = false;
      if (!cur) {
        // Not looping: hold the last word, still boiling and rumbling softly.
        cur = [...sched.shots].reverse().find((s) => s.kind === "word")!;
        local = cur.start + cur.dur + (t - loopStart - sched.total);
        held = true;
      }
      if (cur !== shot) {
        shot = cur;
        plates = [];
      }
      const ts = local - cur.start;
      const flashFrames = Math.max(1, Math.round((flashDuration / 1000) * fps));
      const lf = Math.floor(ts * fps + 1e-6);
      const minD = Math.min(W, H);
      const getPlate = (v: number) => (plates[v] ??= { ink: makePlate(cur, v, W, H, color, font, dpr, size) });
      if (cur.flash && !held && lf < flashFrames) {
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.fillStyle = flashColor;
        ctx.fillRect(0, 0, W, H);
        return;
      }
      // Still inside the same flash: one inverted frame, the word cut out of the white.
      if (cur.flash && cur.invert && !held && lf === flashFrames && cur.kind !== "gap") {
        const p = getPlate(0);
        p.inv ??= tint(p.ink, background);
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.fillStyle = flashColor;
        ctx.fillRect(0, 0, W, H);
        ctx.setTransform(1.2, 0, 0, 1.2, W / 2, H / 2);
        ctx.drawImage(p.inv, -W / 2, -H / 2);
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        return;
      }
      clear();
      const glitchy = glitch > 0 && cur.kind !== "gap" && Math.random() < glitch * 0.6;
      if (cur.kind !== "gap") {
        const flashLen = cur.flash ? (flashFrames + (cur.invert ? 1 : 0)) / fps : 0;
        let ti = Math.max(0, ts - flashLen);
        // Smash zoom: partway through, the word jumps closer with a fresh hit.
        const cutAt = (cur.dur - flashLen) * 0.55;
        const zoomed = cur.cut && !held && ti >= cutAt;
        if (zoomed) ti -= cutAt;
        const variant = Math.floor((ti * fps) / 2) % 3;
        const plate = getPlate(variant);
        const hit = Math.exp(-ti * 8);
        const A = rumble * minD * (0.05 * hit + 0.005);
        const dx = (Math.random() * 2 - 1) * A;
        const dy = (Math.random() * 2 - 1) * A;
        const tilt = zoomed ? ((cur.seed % 7) - 3) * 0.02 : 0;
        const rot = tilt + (Math.random() * 2 - 1) * 0.03 * rumble * Math.exp(-ti * 5);
        const sc = (zoomed ? 1.12 : 1) * (1 + 0.2 * Math.exp(-ti * 14) * Math.min(1, rumble + 0.3));
        const place = (img: HTMLCanvasElement, ox: number, oy: number) => {
          ctx.setTransform(sc, 0, 0, sc, W / 2 + ox, H / 2 + oy);
          ctx.rotate(rot);
          ctx.drawImage(img, -W / 2, -H / 2);
        };
        // Speed lines bursting out from behind the word on the hit.
        if (ti < 2 / fps && rumble > 0) {
          ctx.setTransform(1, 0, 0, 1, 0, 0);
          ctx.strokeStyle = color;
          ctx.globalAlpha = 0.45;
          ctx.lineCap = "round";
          for (let i = 0; i < 28; i++) {
            const a = Math.random() * Math.PI * 2;
            const r0 = minD * (0.32 + Math.random() * 0.2);
            const r1 = r0 + minD * (0.15 + Math.random() * 0.5);
            ctx.lineWidth = Math.max(1, minD * Math.random() * 0.008);
            ctx.beginPath();
            ctx.moveTo(W / 2 + Math.cos(a) * r0 * 1.4, H / 2 + Math.sin(a) * r0);
            ctx.lineTo(W / 2 + Math.cos(a) * r1 * 1.4, H / 2 + Math.sin(a) * r1);
            ctx.stroke();
          }
          ctx.globalAlpha = 1;
        }
        // Red/cyan split fringes while it's shaking hard or glitching.
        const split = chroma * (ti < 0.18 ? 1 - ti / 0.18 : glitchy ? 0.6 : 0);
        if (split > 0.02) {
          plate.red ??= tint(plate.ink, "#ff1744");
          plate.cyan ??= tint(plate.ink, "#00e5ff");
          const o = minD * 0.018 * split + A;
          ctx.globalCompositeOperation = "lighter";
          ctx.globalAlpha = Math.min(1, 0.4 + split);
          place(plate.red, dx - o, dy);
          place(plate.cyan, dx + o, dy + o * 0.3);
          ctx.globalAlpha = 1;
          ctx.globalCompositeOperation = "source-over";
        } else if (rumble > 0 && ti < 0.2) {
          ctx.globalAlpha = 0.3 * (1 - ti / 0.2);
          place(plate.ink, dx * -2.5, dy * -2.5);
          ctx.globalAlpha = 1;
        }
        place(plate.ink, dx, dy);
        ctx.setTransform(1, 0, 0, 1, 0, 0);
      }

      // Glitch: shifted slices and stray pixel blocks.
      if (glitchy) {
        const k = 2 + Math.floor(Math.random() * 7 * glitch);
        for (let i = 0; i < k; i++) {
          const w = W * (0.04 + Math.random() * 0.25);
          const h = H * (0.006 + Math.random() * 0.05);
          const x = Math.random() * (W - w);
          const y = Math.random() * (H - h);
          ctx.drawImage(canvas, x, y, w, h, x + (Math.random() - 0.5) * W * 0.08, y + (Math.random() - 0.5) * h, w, h);
        }
        for (let i = 0; i < k; i++) {
          const s = minD * (0.008 + Math.random() * 0.025);
          ctx.fillStyle = Math.random() < 0.5 ? color : background;
          ctx.fillRect(W * (0.2 + Math.random() * 0.6), H * (0.15 + Math.random() * 0.7), s, s * (0.5 + Math.random()));
        }
      }
      // Film grain.
      const pat = grain[Math.floor(Math.random() * grain.length)];
      if (pat) {
        ctx.globalAlpha = 0.13;
        ctx.fillStyle = pat;
        ctx.setTransform(dpr, 0, 0, dpr, Math.random() * 160, Math.random() * 160);
        ctx.fillRect(-160, -160, W / dpr + 320, H / dpr + 320);
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.globalAlpha = 1;
      }
      // Dust and scratches.
      ctx.fillStyle = color;
      ctx.globalAlpha = 0.55;
      const specks = Math.floor(Math.random() * 7);
      for (let i = 0; i < specks; i++) {
        const s = Math.max(1, minD * Math.random() * 0.004);
        ctx.fillRect(Math.random() * W, Math.random() * H, s, s);
      }
      if (Math.random() < 0.12) ctx.fillRect(Math.random() * W, 0, Math.max(1, dpr * 0.8), H);
      ctx.globalAlpha = 1;
    };
    raf = requestAnimationFrame(draw);

    return () => {
      cancelAnimationFrame(raf);
      observer.disconnect();
      document.fonts?.removeEventListener("loadingdone", onFonts);
    };
  }, [text, beat, flash, styles.join(), flashDuration, flashColor, rumble, glitch, chroma, size, figures, fps, loop, delay, color, background, started, reduceMotion]);

  return (
    <div
      ref={rootRef}
      role="img"
      aria-label={text}
      className={className}
      style={{ position: "relative", aspectRatio: "16 / 10", background, color, overflow: "hidden", ...style }}
    >
      {reduceMotion ? (
        <div
          aria-hidden
          style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center", textAlign: "center", fontSize: "2em", fontWeight: 900, padding: "0.5em" }}
        >
          {text}
        </div>
      ) : (
        <canvas ref={canvasRef} aria-hidden style={{ position: "absolute", inset: 0, width: "100%", height: "100%" }} />
      )}
    </div>
  );
}