Skip to content

Eroding Headline

A big statement that the wind builds out of sand as the scroll brings it in, holds while you read it, and erodes into sand as it leaves.

WebGL20 deps1 file · 49 KB TSXreduced-motion aware

FreeAeolianeditorialminimal
Preview

Props

0
No runtime dependencies
"use client";

import { createContext, useContext, useEffect, useRef, useState, useSyncExternalStore } from "react";
import type { CSSProperties, ReactNode, RefObject } from "react";

/**
 * ERODING HEADLINE.
 * A big statement that the wind builds out of sand as the scroll brings it in,
 * holds while you read it, and erodes into sand as it leaves.
 *
 * FONTS. Archivo with its width axis for display, Instrument Serif italic for
 * the accent word, Geist Mono for labels. In Next: Archivo({ subsets:
 * ["latin"], axes: ["wdth"], variable: "--font-ae" }), Instrument_Serif({
 * subsets: ["latin"], weight: "400", style: "italic", variable:
 * "--font-ae-serif" }) and Geist_Mono({ subsets: ["latin"], variable:
 * "--font-ae-mono" }) on <html>. Without them it falls back to system fonts.
 *
 * CLIENT. The file starts with "use client".
 *
 * WIND. Every Aeolian entry reads one page-wide wind (aeWind on globalThis),
 * so separately pasted entries still turn together.
 *
 * DUST. The sand is drawn on a sticky canvas over the section, which no
 * ancestor with overflow: hidden may wrap; use overflow-x: clip instead.
 */

/** The deepest ground: dusk, the footer. */
const AE_NIGHT = "#150E0A";

/** Ink on the light grounds. */
const AE_UMBER = "#2B1B12";

const AE_DUNE = "#B8743F";

/** Lit sand: marks on the night ground. */
const AE_SAND = "#DDAA72";

/** The pale ground, and the colour a sandstorm turns everything. */
const AE_HAZE = "#EFE2CD";

/**
 * The display face: Archivo, wide and light. Set its width with font-stretch
 * (112.5% or 125%), not font-variation-settings, so a canvas can paint the
 * same instance. In Next: Archivo({ subsets: ["latin"], axes: ["wdth"], variable: "--font-ae" }).
 */
const AE_DISPLAY = 'var(--font-ae, var(--font-display, "Archivo")), "Archivo", ui-sans-serif, system-ui, sans-serif';

/** The accent: Instrument Serif italic, one word at a time. */
const AE_SERIF = 'var(--font-ae-serif, var(--font-instrument-serif, "Instrument Serif")), "Instrument Serif", ui-serif, Georgia, serif';

/** Labels and readouts. */
const AE_MONO = 'var(--font-ae-mono, var(--font-geist-mono, "Geist Mono")), "Geist Mono", ui-monospace, "SFMono-Regular", Menlo, monospace';

/** Which ground a section sits on; the nav reads it to pick its ink. */
export type AeGround = "night" | "haze";

const aeClamp = (v: number, a = 0, b = 1) => (v < a ? a : v > b ? b : v);

/** Where p sits between a and b, clamped to 0..1. */
const aeRange = (p: number, a: number, b: number) => aeClamp((p - a) / (b - a));

/** Ease in and out: slow at both ends. */
const aeInOut = (t: number) => {
  const x = aeClamp(t);
  return x * x * (3 - 2 * x);
};

function aeReducedSub(fn: () => void) {
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") return () => {};
  const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
  mq.addEventListener("change", fn);
  return () => mq.removeEventListener("change", fn);
}

const aeReducedSnap = () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;

/** True when the reader asked for reduced motion. Live: follows the system setting as it changes. */
function useAeReduced(): boolean {
  return useSyncExternalStore(aeReducedSub, aeReducedSnap, () => false);
}

/**
 * The scrolling box an element lives in: the nearest ancestor that actually
 * scrolls, or null for the window. Resolved on every frame, because a box only
 * becomes a scroller once its content is taller than it.
 */
function aeScrollParent(el: Element | null): HTMLElement | null {
  let n = el?.parentElement ?? null;
  while (n && n !== document.body && n !== document.documentElement) {
    const oy = getComputedStyle(n).overflowY;
    if ((oy === "auto" || oy === "scroll" || oy === "overlay") && n.scrollHeight > n.clientHeight + 1) return n;
    n = n.parentElement;
  }
  return null;
}

interface AeFrame {
  /** The element's box, in viewport pixels. */
  rect: DOMRect;
  /** The visible part of whatever scrolls it: the scroller's box, or the window. */
  view: { top: number; height: number; left: number; width: number };
}

/**
 * Run `apply` with the element's position whenever anything scrolls or
 * resizes, at most once per frame. One capture-phase scroll listener hears
 * every scroller, so the same section works on a page and inside a scrolling
 * preview. `apply` writes to the DOM; nothing here re-renders React.
 */
function useAeScrollFrame<T extends HTMLElement>(ref: RefObject<T | null>, apply: (f: AeFrame) => void) {
  const applyRef = useRef(apply);
  useEffect(() => {
    applyRef.current = apply;
  });
  useEffect(() => {
    let raf = 0;
    const run = () => {
      raf = 0;
      const el = ref.current;
      if (!el) return;
      const sp = aeScrollParent(el);
      const r = sp ? sp.getBoundingClientRect() : null;
      const view = sp && r ? { top: r.top, height: sp.clientHeight, left: r.left, width: sp.clientWidth } : { top: 0, height: window.innerHeight, left: 0, width: window.innerWidth };
      applyRef.current({ rect: el.getBoundingClientRect(), view });
    };
    const kick = () => {
      if (!raf) raf = requestAnimationFrame(run);
    };
    document.addEventListener("scroll", kick, { capture: true, passive: true });
    window.addEventListener("resize", kick);
    const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(kick) : null;
    if (ref.current && ro) ro.observe(ref.current);
    run();
    return () => {
      document.removeEventListener("scroll", kick, { capture: true });
      window.removeEventListener("resize", kick);
      ro?.disconnect();
      if (raf) cancelAnimationFrame(raf);
    };
  }, [ref]);
}

/** Progress of a tall pinned block: 0 when its top meets the view's top, 1 when its bottom meets the view's bottom. */
function aePin(f: AeFrame): number {
  const span = f.rect.height - f.view.height;
  if (span <= 0) return f.rect.top <= f.view.top ? 1 : 0;
  return aeClamp((f.view.top - f.rect.top) / span);
}

/** Progress of a block passing through the view: 0 as its top enters at the bottom, 1 as its bottom leaves at the top. */
function aePass(f: AeFrame): number {
  return aeClamp((f.view.top + f.view.height - f.rect.top) / (f.rect.height + f.view.height));
}

/** True once the element has come within `margin` of its scroller's view. Never goes back. */
function useAeSeen<T extends HTMLElement>(ref: RefObject<T | null>, margin = "200px"): boolean {
  const [seen, setSeen] = useState(false);
  useEffect(() => {
    const el = ref.current;
    if (!el || seen) return;
    if (typeof IntersectionObserver === "undefined") {
      const id = requestAnimationFrame(() => setSeen(true));
      return () => cancelAnimationFrame(id);
    }
    const io = new IntersectionObserver(
      (es) => {
        if (es.some((e) => e.isIntersecting)) {
          setSeen(true);
          io.disconnect();
        }
      },
      { root: aeScrollParent(el), rootMargin: margin },
    );
    io.observe(el);
    return () => io.disconnect();
  }, [ref, margin, seen]);
  return seen;
}

/** How a scroll-told section is driven: by the page's scroll, or by its own slow loop (previews and embeds). */
export type AeDriveMode = "scroll" | "auto";

/**
 * Drive a scene with one number from 0 to 1.
 *
 * - mode "scroll": the value chases the measured scroll a little each frame
 *   (v += (target - v) * 0.14), so a flicked wheel glides and stops clean.
 * - mode "auto": a slow loop that plays forward, holds, and plays back.
 * - `progress`: hold one moment.
 * - Reduced motion: hold `still` (the settled state).
 *
 * `render` writes to the DOM; nothing here re-renders React per frame.
 */
function useAeDrive<T extends HTMLElement>(
  ref: RefObject<T | null>,
  { mode, progress, still, period = 16000, measure = aePin }: { mode: AeDriveMode; progress?: number; still: number; period?: number; measure?: (f: AeFrame) => number },
  render: (p: number) => void,
): boolean {
  const reduced = useAeReduced();
  const seen = useAeSeen(ref, "200px");
  const renderRef = useRef(render);
  const measureRef = useRef(measure);
  useEffect(() => {
    renderRef.current = render;
    measureRef.current = measure;
  });
  const cur = useRef<number | null>(null);
  const goal = useRef(0);
  const raf = useRef(0);
  const scrolling = mode === "scroll" && progress === undefined && !reduced;

  useAeScrollFrame(ref, (f) => {
    if (!scrolling) return;
    goal.current = aeClamp(measureRef.current(f));
    if (cur.current === null) {
      cur.current = goal.current;
      renderRef.current(cur.current);
      return;
    }
    if (raf.current) return;
    const step = () => {
      const c = cur.current ?? goal.current;
      const next = Math.abs(goal.current - c) < 0.0004 ? goal.current : c + (goal.current - c) * 0.14;
      cur.current = next;
      renderRef.current(next);
      raf.current = next === goal.current ? 0 : requestAnimationFrame(step);
    };
    raf.current = requestAnimationFrame(step);
  });
  useEffect(
    () => () => {
      if (raf.current) cancelAnimationFrame(raf.current);
      raf.current = 0;
    },
    [],
  );

  useEffect(() => {
    if (scrolling) return;
    if (progress !== undefined || reduced) {
      renderRef.current(progress !== undefined ? aeClamp(progress) : still);
      return;
    }
    renderRef.current(0);
    if (!seen) return;
    let id = 0;
    const t0 = performance.now();
    const loop = (now: number) => {
      const u = ((now - t0) % period) / period;
      // Forward over 55%, hold, back over 25%, hold.
      const p = u < 0.55 ? aeInOut(u / 0.55) : u < 0.7 ? 1 : u < 0.95 ? 1 - aeInOut((u - 0.7) / 0.25) : 0;
      renderRef.current(p);
      id = requestAnimationFrame(loop);
    };
    id = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(id);
  }, [scrolling, progress, reduced, still, period, seen]);
  return reduced;
}

/**
 * The kit's scoped CSS: one focus ring (2px, offset 3px, sand on night and
 * umber on haze), tabular figures, the press, and transitions off for
 * anything a frame loop writes.
 */
const AE_CSS =
  ".ae-scope{--ae-ring:" + AE_SAND + "}" +
  ".ae-scope[data-ae-ground=haze],.ae-scope [data-ae-ground=haze]{--ae-ring:" + AE_UMBER + "}" +
  ".ae-scope :focus-visible{outline:2px solid var(--ae-ring)!important;outline-offset:3px!important}" +
  ".ae-scope .ae-num{font-variant-numeric:tabular-nums}" +
  ".ae-scope [data-ae-live]{transition:none!important}" +
  ".ae-scope .ae-press{transition:transform 180ms cubic-bezier(.2,.8,.2,1),background-color 180ms,color 180ms,border-color 180ms}" +
  ".ae-scope .ae-press:active{transform:scale(.97)}" +
  ".ae-scope .ae-arr{transition:transform 240ms cubic-bezier(.2,.8,.2,1)}.ae-scope a:hover .ae-arr,.ae-scope button:hover .ae-arr{transform:translateX(3px)}" +
  "@media (prefers-reduced-motion:reduce){.ae-scope .ae-press,.ae-scope .ae-arr{transition:none}}";

interface AeProgramSpec {
  vert: string;
  frag: string;
  /** Transform-feedback outputs, in buffer order (interleaved off: one buffer each). */
  feedback?: string[];
}

interface AeProgram {
  prog: WebGLProgram;
  loc: (name: string) => WebGLUniformLocation | null;
}

/**
 * Compile and link every program, then hand them to `done` (null, and a logged
 * reason, when any fails or the context is lost). Nothing asks whether a
 * compile worked until the driver says it has finished
 * (KHR_parallel_shader_compile, polled once a frame), so a heavy shader never
 * blocks the page. Without the extension it links at once. Returns a cancel.
 */
function aeLink(gl: WebGL2RenderingContext, specs: AeProgramSpec[], done: (progs: AeProgram[] | null) => void, label = "Aeolian"): () => void {
  const built: { prog: WebGLProgram; vs: WebGLShader; fs: WebGLShader }[] = [];
  for (const s of specs) {
    const prog = gl.createProgram();
    const vs = gl.createShader(gl.VERTEX_SHADER);
    const fs = gl.createShader(gl.FRAGMENT_SHADER);
    if (!prog || !vs || !fs) {
      done(null);
      return () => {};
    }
    gl.shaderSource(vs, s.vert);
    gl.shaderSource(fs, s.frag);
    gl.compileShader(vs);
    gl.compileShader(fs);
    gl.attachShader(prog, vs);
    gl.attachShader(prog, fs);
    if (s.feedback) gl.transformFeedbackVaryings(prog, s.feedback, gl.SEPARATE_ATTRIBS);
    gl.linkProgram(prog);
    built.push({ prog, vs, fs });
  }
  const finish = () => {
    if (gl.isContextLost()) return done(null);
    for (const b of built) {
      if (!gl.getProgramParameter(b.prog, gl.LINK_STATUS)) {
        console.warn(label + ": a WebGL2 program failed, showing the CSS ground instead.", gl.getShaderInfoLog(b.fs) || gl.getShaderInfoLog(b.vs) || gl.getProgramInfoLog(b.prog));
        return done(null);
      }
    }
    done(
      built.map((b) => {
        const cache = new Map<string, WebGLUniformLocation | null>();
        return {
          prog: b.prog,
          loc: (n: string) => {
            if (!cache.has(n)) cache.set(n, gl.getUniformLocation(b.prog, n));
            return cache.get(n) ?? null;
          },
        };
      }),
    );
  };
  const ext = gl.getExtension("KHR_parallel_shader_compile") as { COMPLETION_STATUS_KHR: number } | null;
  if (!ext) {
    finish();
    return () => {};
  }
  let raf = 0;
  const poll = () => {
    raf = 0;
    if (gl.isContextLost() || built.every((b) => gl.getProgramParameter(b.prog, ext.COMPLETION_STATUS_KHR))) finish();
    else raf = requestAnimationFrame(poll);
  };
  raf = requestAnimationFrame(poll);
  return () => cancelAnimationFrame(raf);
}

/** Shared GLSL: hashes and value noise. */
const AE_NOISE_GLSL = [
  "float aeHash(vec2 p){ p = fract(p * vec2(443.897, 441.423)); p += dot(p, p.yx + 19.19); return fract((p.x + p.y) * p.x); }",
  "float aeHash3(vec3 p){ p = fract(p * 0.1031); p += dot(p, p.zyx + 31.32); return fract((p.x + p.y) * p.z); }",
  "float aeNoise2(vec2 x){",
  "  vec2 i = floor(x), f = fract(x);",
  "  f = f * f * (3.0 - 2.0 * f);",
  "  return mix(mix(aeHash(i), aeHash(i + vec2(1, 0)), f.x), mix(aeHash(i + vec2(0, 1)), aeHash(i + vec2(1, 1)), f.x), f.y);",
  "}",
  "float aeNoise3(vec3 x){",
  "  vec3 i = floor(x), f = fract(x);",
  "  f = f * f * (3.0 - 2.0 * f);",
  "  return mix(mix(mix(aeHash3(i), aeHash3(i + vec3(1, 0, 0)), f.x), mix(aeHash3(i + vec3(0, 1, 0)), aeHash3(i + vec3(1, 1, 0)), f.x), f.y),",
  "             mix(mix(aeHash3(i + vec3(0, 0, 1)), aeHash3(i + vec3(1, 0, 1)), f.x), mix(aeHash3(i + vec3(0, 1, 1)), aeHash3(i + vec3(1, 1, 1)), f.x), f.y), f.z);",
  "}",
  "float aeFbm2(vec2 p){ float s = 0.0, a = 0.5; for (int i = 0; i < 4; i++){ s += a * aeNoise2(p); p = p * 2.03 + 17.1; a *= 0.5; } return s; }",
  "vec3 aeAces(vec3 x){ return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0); }",
].join("\n");

/**
 * The page's wind. `angle` is the way it blows TOWARD, in degrees on the
 * ground: 0 = to the right (east), 90 = away from you (north), 180 = left,
 * 270 = toward you. `speed` is in km/h. The dune stage, the grains, the dust
 * of every dissolve, the nav's vane and the footer all read this one store,
 * so turning the rose in the hero turns every grain on the page. It lives on
 * globalThis, so entries pasted as separate files still share one wind.
 */
const aeWind: { angle: number; speed: number; subs: Set<() => void> } = ((globalThis as unknown as { __aeolianWind?: { angle: number; speed: number; subs: Set<() => void> } }).__aeolianWind ??= { angle: 55, speed: 30, subs: new Set() });

/**
 * The wind as a direction on a flat screen: x right, y down. Wind blowing
 * away from you reads as rising, toward you as falling, a little flattened
 * (the ground is seen at a low angle). Unit length.
 */
function aeWindScreen(angle = aeWind.angle): [number, number] {
  const a = (angle * Math.PI) / 180;
  const x = Math.cos(a);
  const y = -Math.sin(a) * 0.55;
  const l = Math.hypot(x, y) || 1;
  return [x / l, y / l];
}

interface AeRaster {
  canvas: HTMLCanvasElement;
  /** The element's layout size in CSS pixels (unscaled by any transform above it). */
  w: number;
  h: number;
  /** Resolves once inline SVGs (decoded as images) are painted in too. */
  ready: Promise<void>;
}

const AE_STRETCH: Record<string, string> = {
  "50%": "ultra-condensed",
  "62.5%": "extra-condensed",
  "75%": "condensed",
  "87.5%": "semi-condensed",
  "100%": "normal",
  "112.5%": "semi-expanded",
  "125%": "expanded",
  "150%": "extra-expanded",
  "200%": "ultra-expanded",
};

function aeAlpha(color: string): number {
  if (!color || color === "transparent") return 0;
  const m = color.match(/rgba?\(([^)]+)\)/);
  if (!m || !m[1]) return 1;
  const parts = m[1].split(/[\s,/]+/).filter(Boolean);
  return parts.length > 3 ? parseFloat(parts[3] ?? "1") : 1;
}

function aeRadius(v: string, w: number, h: number): number {
  const n = parseFloat(v) || 0;
  return v.trim().endsWith("%") ? (n / 100) * Math.min(w, h) : n;
}

function aeRoundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: [number, number, number, number]) {
  const m = Math.min(w, h) / 2;
  const [a, b, c, d] = r.map((v) => Math.max(0, Math.min(v, m))) as [number, number, number, number];
  ctx.beginPath();
  ctx.moveTo(x + a, y);
  ctx.lineTo(x + w - b, y);
  ctx.arcTo(x + w, y, x + w, y + b, b);
  ctx.lineTo(x + w, y + h - c);
  ctx.arcTo(x + w, y + h, x + w - c, y + h, c);
  ctx.lineTo(x + d, y + h);
  ctx.arcTo(x, y + h, x, y + h - d, d);
  ctx.lineTo(x, y + a);
  ctx.arcTo(x, y, x + a, y, a);
  ctx.closePath();
}

/**
 * Paint `el` into a canvas at `res` pixels per CSS pixel: background colours,
 * borders and radii, overflow clips, child canvases, inline SVGs, input
 * values, and text, one character at a time at the exact place the browser
 * laid it out (read with Range rects), in the element's own font. That is
 * enough for anything built from flat fills and type, which is what every
 * Aeolian surface is. Everything is read synchronously at the call, so the
 * snapshot is of the element as it is now even if React redraws it a moment
 * later. The element's own opacity is ignored (a hidden element can still be
 * painted, ready to be built out of sand); nodes marked data-ae-skip are left out.
 */
function aeRasterize(el: HTMLElement, res: number): AeRaster | null {
  const R = el.getBoundingClientRect();
  const w = el.offsetWidth;
  const h = el.offsetHeight;
  if (!w || !h || !R.width) return null;
  const s = R.width / w;
  const canvas = document.createElement("canvas");
  canvas.width = Math.max(1, Math.ceil(w * res));
  canvas.height = Math.max(1, Math.ceil(h * res));
  const ctx = canvas.getContext("2d");
  if (!ctx) return null;
  ctx.scale(res, res);
  const X = (x: number) => (x - R.left) / s;
  const Y = (y: number) => (y - R.top) / s;
  const range = document.createRange();
  const svgs: Promise<void>[] = [];

  const text = (t: Text, cs: CSSStyleDeclaration, alpha: number) => {
    const data = t.data;
    if (!data.trim()) return;
    ctx.globalAlpha = alpha * aeAlpha(cs.color);
    ctx.fillStyle = cs.color;
    ctx.font = cs.fontStyle + " " + cs.fontWeight + " " + cs.fontSize + " " + cs.fontFamily;
    const c2 = ctx as unknown as { fontStretch?: string; letterSpacing?: string };
    if ("fontStretch" in c2) c2.fontStretch = AE_STRETCH[cs.fontStretch] ?? "normal";
    if ("letterSpacing" in c2) c2.letterSpacing = "0px";
    ctx.textBaseline = "alphabetic";
    const m = ctx.measureText("Hg");
    const asc = m.fontBoundingBoxAscent || parseFloat(cs.fontSize) * 0.8;
    const desc = m.fontBoundingBoxDescent || parseFloat(cs.fontSize) * 0.2;
    const upper = cs.textTransform === "uppercase";
    for (let i = 0; i < data.length; i++) {
      const code = data.charCodeAt(i);
      const n = code >= 0xd800 && code <= 0xdbff ? 2 : 1;
      let ch = data.slice(i, i + n);
      if (/\s/.test(ch)) continue;
      range.setStart(t, i);
      range.setEnd(t, i + n);
      i += n - 1;
      const rect = range.getClientRects()[0];
      if (!rect || !rect.width) continue;
      if (upper) ch = ch.toUpperCase();
      const rh = rect.height / s;
      ctx.fillText(ch, X(rect.left), Y(rect.top) + (rh - (asc + desc)) / 2 + asc);
    }
  };

  const paint = (node: Element, alpha: number, root: boolean) => {
    if (!root && node.hasAttribute("data-ae-skip")) return;
    const cs = getComputedStyle(node);
    if (cs.display === "none" || (!root && cs.visibility === "hidden")) return;
    const a = root ? alpha : alpha * (parseFloat(cs.opacity) || 0);
    if (a < 0.01) return;
    const r = node.getBoundingClientRect();
    const x = X(r.left);
    const y = Y(r.top);
    const bw = r.width / s;
    const bh = r.height / s;
    const radii: [number, number, number, number] = [
      aeRadius(cs.borderTopLeftRadius, bw, bh),
      aeRadius(cs.borderTopRightRadius, bw, bh),
      aeRadius(cs.borderBottomRightRadius, bw, bh),
      aeRadius(cs.borderBottomLeftRadius, bw, bh),
    ];
    if (node instanceof SVGSVGElement) {
      const clone = node.cloneNode(true) as SVGSVGElement;
      clone.setAttribute("width", String(bw));
      clone.setAttribute("height", String(bh));
      clone.setAttribute("color", cs.color);
      clone.style.color = cs.color;
      const img = new Image();
      const src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(new XMLSerializer().serializeToString(clone));
      svgs.push(
        new Promise<void>((done) => {
          img.onload = () => {
            ctx.save();
            ctx.globalAlpha = a;
            ctx.drawImage(img, x, y, bw, bh);
            ctx.restore();
            done();
          };
          img.onerror = () => done();
          img.src = src;
        }),
      );
      return;
    }
    ctx.save();
    if (aeAlpha(cs.backgroundColor) > 0) {
      ctx.globalAlpha = a * aeAlpha(cs.backgroundColor);
      ctx.fillStyle = cs.backgroundColor;
      aeRoundRect(ctx, x, y, bw, bh, radii);
      ctx.fill();
    }
    const bt = parseFloat(cs.borderTopWidth) || 0;
    const br = parseFloat(cs.borderRightWidth) || 0;
    const bb = parseFloat(cs.borderBottomWidth) || 0;
    const bl = parseFloat(cs.borderLeftWidth) || 0;
    if (bt || br || bb || bl) {
      const uniform = bt === br && br === bb && bb === bl && cs.borderTopColor === cs.borderBottomColor && cs.borderLeftColor === cs.borderRightColor && cs.borderTopColor === cs.borderLeftColor;
      if (uniform && cs.borderTopStyle !== "none") {
        ctx.globalAlpha = a * aeAlpha(cs.borderTopColor);
        ctx.strokeStyle = cs.borderTopColor;
        ctx.lineWidth = bt;
        aeRoundRect(ctx, x + bt / 2, y + bt / 2, bw - bt, bh - bt, radii.map((v) => Math.max(0, v - bt / 2)) as [number, number, number, number]);
        ctx.stroke();
      } else {
        const side = (on: number, color: string, style: string, sx: number, sy: number, sw: number, sh: number) => {
          if (!on || style === "none" || aeAlpha(color) === 0) return;
          ctx.globalAlpha = a * aeAlpha(color);
          ctx.fillStyle = color;
          ctx.fillRect(sx, sy, sw, sh);
        };
        side(bt, cs.borderTopColor, cs.borderTopStyle, x, y, bw, bt);
        side(bb, cs.borderBottomColor, cs.borderBottomStyle, x, y + bh - bb, bw, bb);
        side(bl, cs.borderLeftColor, cs.borderLeftStyle, x, y, bl, bh);
        side(br, cs.borderRightColor, cs.borderRightStyle, x + bw - br, y, br, bh);
      }
    }
    if (node instanceof HTMLCanvasElement) {
      try {
        ctx.globalAlpha = a;
        ctx.drawImage(node, x, y, bw, bh);
      } catch {
        // A canvas we may not read (tainted): leave its box empty.
      }
    }
    if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement) {
      const isSelect = node instanceof HTMLSelectElement;
      // Secrets never reach the sand: passwords, one-time codes and card fields paint as dots.
      const secret = node instanceof HTMLInputElement && (node.type === "password" || /^(one-time-code|cc-)/.test(node.autocomplete));
      const value = isSelect ? (node.selectedOptions[0]?.text ?? "") : secret ? "•".repeat(node.value.length) : node.value;
      const shown = value || (isSelect ? "" : node.placeholder);
      if (shown && !(node instanceof HTMLInputElement && /^(checkbox|radio|range|color|file|hidden)$/.test(node.type))) {
        ctx.globalAlpha = a * (value ? 1 : 0.5);
        ctx.fillStyle = cs.color;
        ctx.font = cs.fontStyle + " " + cs.fontWeight + " " + cs.fontSize + " " + cs.fontFamily;
        ctx.textBaseline = "middle";
        const pl = (parseFloat(cs.paddingLeft) || 0) + bl;
        const lineY = node instanceof HTMLTextAreaElement ? y + (parseFloat(cs.paddingTop) || 0) + bt + (parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.3) / 2 : y + bh / 2;
        ctx.fillText(shown, x + pl, lineY, Math.max(1, bw - pl * 2));
      }
    }
    if (cs.overflow !== "visible" && cs.overflow !== "") {
      aeRoundRect(ctx, x, y, bw, bh, radii);
      ctx.clip();
    }
    for (const c of Array.from(node.childNodes)) {
      if (c.nodeType === 3) text(c as Text, cs, a);
      else if (c.nodeType === 1) paint(c as Element, a, false);
    }
    ctx.restore();
  };

  paint(el, 1, true);
  range.detach();
  return { canvas, w, h, ready: Promise.all(svgs).then(() => undefined) };
}

interface AeBurstOptions {
  /** The whole burst, in ms (the element is gone a little past half way). */
  duration?: number;
  /** Blow this way instead of the page's wind (degrees, same compass as the wind store). */
  angle?: number;
  /** How far the grains fly, 0.3..2. */
  reach?: number;
}

/** A burst you drive yourself (with the scroll): `set("scatter", 0..1)` blows it away, `set("gather", 0..1)` builds it. */
interface AeScrub {
  set: (mode: "scatter" | "gather", c: number) => void;
  /** Throw the snapshot away (the element changed); the next set() paints it again. */
  refresh: () => void;
  drop: () => void;
}

interface AeDust {
  /** Paint the element, hide it, and blow it away grain by grain. Resolves once it has fully let go (the grains fly on a little longer). */
  scatter: (el: HTMLElement, o?: AeBurstOptions) => Promise<void>;
  /** Build an element (laid out, typically hidden with opacity 0) out of sand blown in on the wind, then show it. */
  gather: (el: HTMLElement, o?: AeBurstOptions) => Promise<void>;
  scrub: (el: HTMLElement, o?: Omit<AeBurstOptions, "duration">) => AeScrub;
}

/** Release window and flight time, as fractions of a burst. */
const AE_RELEASE = 0.55;

const AE_FLIGHT = 0.45;

const AE_DUST_COMMON = [
  "uniform vec2 uView;",
  "uniform vec2 uOrigin;",
  "uniform vec2 uSize;",
  "uniform float uCell;",
  "uniform float uC;",
  "uniform float uGather;",
  "uniform vec2 uWind;",
  "uniform float uReach;",
  "uniform float uSeed;",
  "uniform float uFlight;",
  AE_NOISE_GLSL,
  /** When the grain at `pos` (element px) lets go, 0..1: windward first, along a ragged, patchy front. */
  "float aeRelease(vec2 pos){",
  "  vec2 cell = floor(pos / uCell);",
  "  float half_ = 0.5 * (abs(uWind.x) * uSize.x + abs(uWind.y) * uSize.y) + 1.0;",
  "  float lin = 0.5 + 0.5 * dot(pos - 0.5 * uSize, uWind) / half_;",
  "  float n = aeFbm2(pos / 46.0 + uSeed * 13.0);",
  "  float g = aeHash(cell + uSeed * 71.0);",
  "  return clamp(lin * 0.74 + (n - 0.5) * 0.42 + (g - 0.5) * 0.07 + 0.08, 0.0, 1.0);",
  "}",
  "vec2 aeClip(vec2 px){ return vec2(px.x / uView.x * 2.0 - 1.0, 1.0 - px.y / uView.y * 2.0); }",
].join("\n");

const AE_GRAIN_VERT = [
  "#version 300 es",
  "precision highp float;",
  "uniform sampler2D uTex;",
  "uniform int uCols;",
  "uniform float uRes;",
  "uniform float uDpr;",
  AE_DUST_COMMON,
  "out vec4 vCol;",
  "void main(){",
  "  vCol = vec4(0.0);",
  "  gl_Position = vec4(2.0, 2.0, 0.0, 1.0);",
  "  gl_PointSize = 1.0;",
  "  vec2 cell = vec2(float(gl_VertexID % uCols), float(gl_VertexID / uCols));",
  "  vec2 pos = (cell + 0.5) * uCell;",
  "  if (pos.y > uSize.y || pos.x > uSize.x) return;",
  "  vec4 tex = texelFetch(uTex, ivec2(pos * uRes), 0);",
  "  if (tex.a < 0.04) return;",
  "  float tr = aeRelease(pos) * " + AE_RELEASE.toFixed(3) + ";",
  "  float tau = (uC - tr) / " + AE_FLIGHT.toFixed(3) + ";",
  "  if (tau <= 0.0 || tau >= 1.0) return;",
  "  if (uGather > 0.5) tau = 1.0 - tau;",
  "  vec4 r = vec4(aeHash(cell + 1.7 + uSeed), aeHash(cell * 1.31 + 9.2), aeHash(cell * 0.77 + 4.1), aeHash(cell * 2.13 + 7.7));",
  "  vec2 d = uWind;",
  "  vec2 nrm = vec2(-d.y, d.x);",
  "  float t = tau * uFlight;",
  "  float tp = mix(0.08, 0.5, r.x * r.x);",
  "  vec2 U = d * uReach * (720.0 + 0.6 * max(uSize.x, uSize.y));",
  "  vec2 v0 = d * 90.0 * r.y + vec2(0.0, -(110.0 + 220.0 * r.z) * uReach);",
  "  const float G = 640.0;",
  "  float e = 1.0 - exp(-t / tp);",
  "  vec2 off = U * t + (v0 - U) * tp * e + vec2(0.0, G * tp * t - G * tp * tp * e);",
  // Eddies: a slow wander across the wind that grows as the grain travels.
  "  off += nrm * (aeNoise2(vec2(t * 1.6 + r.w * 7.0, r.y * 13.0)) - 0.5) * 120.0 * uReach * tau;",
  // Built from sand: the same flight run backward, blown in from upwind and settling from above.
  "  if (uGather > 0.5) off = vec2(-off.x, -abs(off.y) * 0.6);",
  "  vec2 px = uOrigin + pos + off;",
  "  gl_Position = vec4(aeClip(px), 0.0, 1.0);",
  "  gl_PointSize = max(1.0, uCell * uDpr * (1.18 - 0.55 * tau));",
  "  vec3 sand = mix(vec3(0.867, 0.667, 0.447), vec3(0.722, 0.455, 0.247), r.z);",
  "  vec3 base = tex.rgb / max(tex.a, 0.001);",
  "  vec3 col = mix(base, sand, smoothstep(0.04, 0.55, tau) * 0.78);",
  "  float a = tex.a * (1.0 - smoothstep(0.55, 1.0, tau)) * (0.75 + 0.25 * r.w);",
  "  vCol = vec4(col * a, a);",
  "}",
].join("\n");

const AE_GRAIN_FRAG = [
  "#version 300 es",
  "precision mediump float;",
  "in vec4 vCol;",
  "out vec4 o;",
  "void main(){",
  "  float d = length(gl_PointCoord - 0.5);",
  "  o = vCol * smoothstep(0.52, 0.28, d);",
  "}",
].join("\n");

const AE_SLAB_VERT = [
  "#version 300 es",
  "precision highp float;",
  "uniform vec2 uView;",
  "uniform vec2 uOrigin;",
  "uniform vec2 uSize;",
  "out vec2 vUv;",
  "void main(){",
  "  int i = gl_VertexID;",
  "  vec2 uv = vec2(float(i == 1 || i == 4 || i == 5), float(i == 2 || i == 3 || i == 5));",
  "  vUv = uv;",
  "  vec2 px = uOrigin + uv * uSize;",
  "  gl_Position = vec4(px.x / uView.x * 2.0 - 1.0, 1.0 - px.y / uView.y * 2.0, 0.0, 1.0);",
  "}",
].join("\n");

const AE_SLAB_FRAG = [
  "#version 300 es",
  "precision highp float;",
  "uniform sampler2D uTex;",
  AE_DUST_COMMON,
  "in vec2 vUv;",
  "out vec4 o;",
  "void main(){",
  "  vec2 pos = vUv * uSize;",
  "  vec4 tex = texture(uTex, vUv);",
  "  float tr = aeRelease(pos) * " + AE_RELEASE.toFixed(3) + ";",
  // Scatter: still here until its grain lets go. Gather: here once its grain has landed.
  "  float k = uGather > 0.5 ? step(tr + " + AE_FLIGHT.toFixed(3) + ", uC) : step(uC, tr);",
  "  if (k < 0.5) discard;",
  // A thin rim of loose sand along the front.
  "  float front = uGather > 0.5 ? uC - tr - " + AE_FLIGHT.toFixed(3) + " : tr - uC;",
  "  float rim = 1.0 - smoothstep(0.0, 0.035, front);",
  "  vec3 base = tex.rgb / max(tex.a, 0.001);",
  "  vec3 col = mix(base, vec3(0.867, 0.667, 0.447), rim * 0.55);",
  "  o = vec4(col * tex.a, tex.a);",
  "}",
].join("\n");

interface AeBurst {
  el: HTMLElement;
  tex: WebGLTexture | null;
  raster: AeRaster | null;
  mode: "scatter" | "gather";
  c: number;
  t0: number;
  dur: number;
  angle: number | undefined;
  reach: number;
  seed: number;
  /** Driven by its caller (scroll), not by the clock. */
  scrubbed: boolean;
  /** Superseded by a newer burst on the same element: only its grains still fly. */
  ghost: boolean;
  /** Where the element last was on the layer (CSS px), kept for when it leaves the DOM mid-flight. */
  ox: number;
  oy: number;
  stale: boolean;
  released: boolean;
  settle: (() => void) | null;
  finish: (() => void) | null;
}

/**
 * The dust layer's engine: one WebGL2 canvas laid over a whole page (or one
 * asset), drawing only while something is turning to sand. Each burst is a
 * snapshot texture drawn twice: a crisp slab that erodes along the wind, and
 * one grain per cell flown on a stateless path (so a burst can be played
 * forward, backward, or scrubbed by the scroll). Without WebGL2, or with
 * reduced motion, every burst is a 220ms fade.
 */
function aeDustEngine() {
  let canvas: HTMLCanvasElement | null = null;
  let gl: WebGL2RenderingContext | null = null;
  let progs: AeProgram[] | null = null;
  let failed = false;
  let raf = 0;
  let cancelLink: (() => void) | null = null;
  const bursts: AeBurst[] = [];
  const waiting: (() => void)[] = [];
  let seed = 1;

  const fade = (el: HTMLElement, show: boolean) =>
    new Promise<void>((done) => {
      el.style.transition = "opacity 220ms ease";
      el.style.opacity = show ? "" : "0";
      window.setTimeout(() => {
        el.style.transition = "";
        done();
      }, 230);
    });

  const ready = () => !!(gl && progs && !failed && !aeReducedSnap());

  const upload = (b: AeBurst) => {
    if (!gl) return;
    const res = Math.min(2, Math.max(1, window.devicePixelRatio || 1));
    b.raster = aeRasterize(b.el, res);
    b.stale = false;
    if (!b.raster) return;
    if (!b.tex) b.tex = gl.createTexture();
    const put = () => {
      if (!gl || !b.tex || !b.raster) return;
      gl.bindTexture(gl.TEXTURE_2D, b.tex);
      gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
      try {
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, b.raster.canvas);
      } catch {
        // A tainted snapshot (some browsers taint SVG with foreignObject): draw nothing for it.
        gl.deleteTexture(b.tex);
        b.tex = null;
        return;
      }
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    };
    put();
    // Inline SVGs land a moment later; upload again when they have.
    const r = b.raster;
    void r.ready.then(() => {
      if (b.raster === r) put();
    });
  };

  const free = (b: AeBurst) => {
    const i = bursts.indexOf(b);
    if (i >= 0) bursts.splice(i, 1);
    if (gl && b.tex) gl.deleteTexture(b.tex);
    b.tex = null;
  };

  const frame = (now: number) => {
    raf = 0;
    if (!canvas || !gl || !progs) return;
    const [grain, slab] = progs as [AeProgram, AeProgram];
    // A bleed box is display:none while idle, so its overhang never adds page scroll.
    const bleedBox = canvas.dataset.aeBox ? canvas.parentElement : null;
    if (bleedBox && bleedBox.style.display) bleedBox.style.display = "";
    let cssH = canvas.offsetHeight;
    if (!canvas.dataset.aeBox) {
      const host = canvas.parentElement?.parentElement;
      const sp = aeScrollParent(canvas);
      const viewH = sp ? sp.clientHeight : window.innerHeight;
      const hostH = host ? host.offsetHeight : viewH;
      cssH = Math.max(1, Math.min(viewH, hostH));
      if (canvas.style.height !== cssH + "px") canvas.style.height = cssH + "px";
    }
    cssH = Math.max(1, cssH);
    const C = canvas.getBoundingClientRect();
    const cssW = canvas.offsetWidth || 1;
    const sc = C.width / cssW || 1;
    const dpr = Math.min(2, window.devicePixelRatio || 1);
    const W = Math.max(1, Math.round(cssW * dpr));
    const H = Math.max(1, Math.round(cssH * dpr));
    if (canvas.width !== W || canvas.height !== H) {
      canvas.width = W;
      canvas.height = H;
    }
    gl.viewport(0, 0, W, H);
    gl.clearColor(0, 0, 0, 0);
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.enable(gl.BLEND);
    gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
    let live = false;
    for (const b of bursts.slice()) {
      if (!b.scrubbed) b.c = aeClamp((now - b.t0) / b.dur);
      if (!b.scrubbed && !b.released && b.c >= (b.mode === "scatter" ? AE_RELEASE : 1)) {
        b.released = true;
        b.settle?.();
      }
      const shown = b.mode === "scatter" ? b.c <= 0 : b.c >= 1;
      if (b.scrubbed) {
        b.el.style.opacity = shown ? "" : "0";
        if (shown) continue;
        if (b.stale || !b.raster) upload(b);
      }
      if (!b.scrubbed && b.c >= 1) {
        if (b.mode === "gather" && !b.ghost) b.el.style.opacity = "";
        b.finish?.();
        free(b);
        continue;
      }
      live = live || !b.scrubbed;
      if (!b.raster || !b.tex) continue;
      // An element replaced mid-flight (a route opening, a form sent) keeps its grains where it was.
      if (b.el.isConnected) {
        const R = b.el.getBoundingClientRect();
        b.ox = (R.left - C.left) / sc;
        b.oy = (R.top - C.top) / sc;
      }
      const { ox, oy } = b;
      const area = b.raster.w * b.raster.h;
      const cell = Math.max(1.5, Math.sqrt(area / 80000));
      const cols = Math.ceil(b.raster.w / cell);
      const rows = Math.ceil(b.raster.h / cell);
      const wind = aeWindScreen(b.angle ?? aeWind.angle);
      gl.activeTexture(gl.TEXTURE0);
      gl.bindTexture(gl.TEXTURE_2D, b.tex);
      for (const p of [slab, grain]) {
        gl.useProgram(p.prog);
        gl.uniform1i(p.loc("uTex"), 0);
        gl.uniform2f(p.loc("uView"), cssW, cssH);
        gl.uniform2f(p.loc("uOrigin"), ox, oy);
        gl.uniform2f(p.loc("uSize"), b.raster.w, b.raster.h);
        gl.uniform1f(p.loc("uCell"), cell);
        gl.uniform1f(p.loc("uC"), b.c);
        gl.uniform1f(p.loc("uGather"), b.mode === "gather" ? 1 : 0);
        gl.uniform2f(p.loc("uWind"), wind[0], wind[1]);
        gl.uniform1f(p.loc("uReach"), b.reach);
        gl.uniform1f(p.loc("uSeed"), b.seed);
        // Seconds of flight a burst gives its grains (a scrubbed one takes a nominal 1.5 s burst).
        gl.uniform1f(p.loc("uFlight"), ((b.dur || 1500) / 1000) * AE_FLIGHT);
      }
      if (!b.ghost) {
        gl.useProgram(slab.prog);
        gl.drawArrays(gl.TRIANGLES, 0, 6);
      }
      gl.useProgram(grain.prog);
      gl.uniform1i(grain.loc("uCols"), cols);
      gl.uniform1f(grain.loc("uRes"), b.raster.canvas.width / b.raster.w);
      gl.uniform1f(grain.loc("uDpr"), dpr);
      gl.drawArrays(gl.POINTS, 0, cols * rows);
    }
    if (live) raf = requestAnimationFrame(frame);
    else if (bleedBox && !bursts.length) bleedBox.style.display = "none";
  };

  const kick = () => {
    if (!raf && canvas && gl && progs) raf = requestAnimationFrame(frame);
  };

  const begin = (el: HTMLElement, mode: "scatter" | "gather", o: AeBurstOptions, scrubbed: boolean): AeBurst => {
    for (const old of bursts.filter((x) => x.el === el && !x.ghost)) {
      old.settle?.();
      // A scatter whose grains are still in the air keeps flying while the element builds again.
      if (old.mode === "scatter" && !old.scrubbed && !scrubbed && mode === "gather") {
        old.ghost = true;
        continue;
      }
      if (old.mode === "gather") el.style.opacity = "";
      old.finish?.();
      free(old);
    }
    const b: AeBurst = {
      el,
      tex: null,
      raster: null,
      mode,
      c: 0,
      t0: performance.now(),
      dur: o.duration ?? 1500,
      angle: o.angle,
      reach: aeClamp(o.reach ?? 1, 0.3, 2),
      seed: (seed = (seed * 1.618) % 7.3) + 0.37,
      scrubbed,
      ghost: false,
      ox: 0,
      oy: 0,
      stale: true,
      released: false,
      settle: null,
      finish: null,
    };
    bursts.push(b);
    return b;
  };

  const timed = (el: HTMLElement, mode: "scatter" | "gather", o: AeBurstOptions = {}) =>
    new Promise<void>((resolve) => {
      const go = () => {
        if (!ready()) {
          if (mode === "gather") el.style.opacity = "0";
          void fade(el, mode === "gather").then(resolve);
          return;
        }
        const b = begin(el, mode, o, false);
        upload(b);
        if (!b.raster) {
          free(b);
          el.style.opacity = mode === "gather" ? "" : "0";
          resolve();
          return;
        }
        el.style.opacity = "0";
        let said = false;
        const say = () => {
          if (!said) {
            said = true;
            resolve();
          }
        };
        if (mode === "scatter") b.settle = say;
        else b.finish = say;
        kick();
      };
      // The layer may still be compiling: wait for it (briefly) rather than fading.
      if (!ready() && !failed && !aeReducedSnap() && canvas) {
        let ran = false;
        const once = () => {
          if (ran) return;
          ran = true;
          go();
        };
        waiting.push(once);
        window.setTimeout(once, 600);
      } else go();
    });

  return {
    attach(c: HTMLCanvasElement) {
      canvas = c;
      const g = c.getContext("webgl2", { alpha: true, premultipliedAlpha: true, antialias: false });
      if (!g) {
        failed = true;
        waiting.splice(0).forEach((f) => f());
        return;
      }
      gl = g;
      cancelLink = aeLink(
        g,
        [
          { vert: AE_GRAIN_VERT, frag: AE_GRAIN_FRAG },
          { vert: AE_SLAB_VERT, frag: AE_SLAB_FRAG },
        ],
        (p) => {
          cancelLink = null;
          progs = p;
          failed = !p;
          waiting.splice(0).forEach((f) => f());
          kick();
        },
        "Aeolian dust",
      );
      // Never keep a click waiting on a slow compile.
      window.setTimeout(() => waiting.splice(0).forEach((f) => f()), 700);
    },
    lost() {
      failed = true;
      progs = null;
      for (const b of bursts.slice()) {
        b.el.style.opacity = b.mode === "scatter" && !b.scrubbed ? "0" : "";
        b.settle?.();
        b.finish?.();
        b.tex = null;
        bursts.splice(bursts.indexOf(b), 1);
      }
    },
    detach() {
      cancelLink?.();
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
      for (const b of bursts.slice()) {
        if (b.mode === "gather" || b.scrubbed) b.el.style.opacity = "";
        b.settle?.();
        b.finish?.();
        free(b);
      }
      if (gl && progs) progs.forEach((p) => gl?.deleteProgram(p.prog));
      gl = null;
      progs = null;
      canvas = null;
    },
    api: {
      scatter: (el, o) => timed(el, "scatter", o),
      gather: (el, o) => timed(el, "gather", o),
      scrub: (el, o = {}) => {
        let b: AeBurst | null = null;
        let ro: ResizeObserver | null = null;
        return {
          set(mode, c) {
            const k = aeClamp(c);
            if (b && bursts.includes(b) && b.mode === mode && b.c === k) return;
            if ((!b || !bursts.includes(b)) && ((mode === "scatter" && k <= 0) || (mode === "gather" && k >= 1))) {
              el.style.opacity = "";
              return;
            }
            if (!ready()) {
              el.style.opacity = (mode === "scatter" ? k < 0.5 : k > 0.5) ? "" : "0";
              return;
            }
            if (!b || !bursts.includes(b)) {
              b = begin(el, mode, o, true);
              if (!ro && typeof ResizeObserver !== "undefined") {
                ro = new ResizeObserver(() => {
                  if (b) b.stale = true;
                });
                ro.observe(el);
              }
            }
            b.mode = mode;
            b.c = k;
            if (raf) return;
            raf = requestAnimationFrame(frame);
          },
          refresh() {
            if (b) b.stale = true;
          },
          drop() {
            ro?.disconnect();
            if (b) {
              el.style.opacity = "";
              free(b);
              b = null;
              kick();
            }
          },
        };
      },
    } satisfies AeDust,
  };
}

const AeDustContext = createContext<AeDust | null>(null);

/**
 * The layer's canvas: stuck to the top of the view inside its host, as tall as
 * the view (or the host, if shorter). With `bleed`, a box that reaches that
 * many px past the host instead (twice that sideways), for small hosts such
 * as a lone button, so the grains have room to fly.
 */
function AeDustCanvas({ engine, bleed }: { engine: ReturnType<typeof aeDustEngine>; bleed?: number }) {
  const ref = useRef<HTMLCanvasElement>(null);
  useEffect(() => {
    const c = ref.current;
    if (!c) return;
    engine.attach(c);
    const lost = (e: Event) => {
      e.preventDefault();
      engine.lost();
    };
    c.addEventListener("webglcontextlost", lost);
    return () => {
      c.removeEventListener("webglcontextlost", lost);
      engine.detach();
    };
  }, [engine]);
  if (bleed)
    return (
      <span aria-hidden data-ae-skip="" className="pointer-events-none absolute z-[60] block" style={{ inset: -bleed + "px " + -bleed * 2 + "px", display: "none" }}>
        <canvas ref={ref} data-ae-box="1" className="absolute inset-0 block h-full w-full" />
      </span>
    );
  return (
    <div aria-hidden data-ae-skip="" className="pointer-events-none sticky top-0 z-[60] h-0 w-full">
      <canvas ref={ref} className="absolute left-0 top-0 w-full" style={{ height: 0 }} />
    </div>
  );
}

/**
 * The dust for this part of the page: the page's layer when there is one
 * (an <AeDustContext> above), otherwise a layer of this asset's own. Render
 * `layer` first inside your (relatively positioned) root and wrap the rest in
 * `provide`, so everything inside shares it.
 */
function useAeDust(bleed?: number): { dust: AeDust; layer: ReactNode; provide: (children: ReactNode) => ReactNode } {
  const outer = useContext(AeDustContext);
  const [own] = useState(() => (outer ? null : aeDustEngine()));
  const dust = outer ?? (own as ReturnType<typeof aeDustEngine>).api;
  return {
    dust,
    layer: own ? <AeDustCanvas engine={own} bleed={bleed} /> : null,
    provide: (children) => (own ? <AeDustContext.Provider value={dust}>{children}</AeDustContext.Provider> : children),
  };
}

/** "*word*" marks the accent: set in the italic serif, in sand. "\n" breaks a line. */
function aeAccent(text: string, accent: string): ReactNode[] {
  return text.split("\n").map((line, i) => (
    <span key={i} className="block">
      {line.split("*").map((part, j) =>
        j % 2 ? (
          <em key={j} style={{ fontFamily: AE_SERIF, fontStyle: "italic", fontWeight: 400, fontStretch: "100%", letterSpacing: "-0.01em", color: accent }}>
            {part}
          </em>
        ) : (
          part
        ),
      )}
    </span>
  ));
}

export interface ErodingHeadlineProps {
  eyebrow?: string;
  /** The statement: "\n" breaks a line, "*word*" is the accent. */
  text?: string;
  sub?: string;
  ground?: AeGround;
  /** "scroll": built and blown away by the page's scroll. "auto": a slow loop (previews, embeds). */
  mode?: AeDriveMode;
  /** Hold one moment, 0..1 (0.5 = fully built). */
  progress?: number;
  className?: string;
  style?: CSSProperties;
}

/**
 * ErodingHeadline — a big statement that the wind builds out of sand as the
 * scroll brings it in (p 0.08–0.38), holds, and blows away downwind as it
 * leaves (p 0.62–0.94). Scroll back and it rebuilds. The sand flies with the
 * page's wind.
 */
export function ErodingHeadline({
  eyebrow = "The camp",
  text = "Every grain here\narrived on the *wind*.",
  sub = "A barchan is a dune shaped like a crescent, its horns pointing downwind. It walks: sand climbs its gentle back and slides down its steep face, a few metres a year. Our eleven tents follow one across the sand sea.",
  ground = "haze",
  mode = "scroll",
  progress,
  className = "",
  style,
}: ErodingHeadlineProps) {
  const rootRef = useRef<HTMLElement>(null);
  const lineRef = useRef<HTMLHeadingElement>(null);
  const { dust, layer, provide } = useAeDust();
  const scrub = useRef<AeScrub | null>(null);
  useEffect(() => {
    const el = lineRef.current;
    if (!el) return;
    const s = dust.scrub(el, { reach: 1.2 });
    scrub.current = s;
    return () => {
      s.drop();
      scrub.current = null;
    };
  }, [dust]);
  // New words or a new ground: snapshot the heading again so the sand spells what is shown.
  useEffect(() => {
    scrub.current?.refresh();
  }, [text, ground]);
  useAeDrive(rootRef, { mode, progress, still: 0.5, period: 13000, measure: aePass }, (p) => {
    const s = scrub.current;
    if (!s) return;
    if (p < 0.5) s.set("gather", aeRange(p, 0.08, 0.38));
    else s.set("scatter", aeRange(p, 0.62, 0.94));
  });
  const night = ground === "night";
  const ink = night ? AE_HAZE : AE_UMBER;
  return (
    <section
      ref={rootRef}
      data-ae-ground={ground}
      className={"ae-scope @container relative w-full " + className}
      style={{ background: night ? AE_NIGHT : AE_HAZE, color: ink, fontFamily: AE_DISPLAY, ...style }}
    >
      <style>{AE_CSS}</style>
      {layer}
      {provide(
        <div className="flex min-h-[86svh] flex-col justify-center px-5 py-[18cqh] @3xl:px-[7cqw]">
          <p className="mb-8 flex items-center gap-3 text-[11px] uppercase tracking-[0.16em]" style={{ fontFamily: AE_MONO, opacity: 0.72 }}>
            <span aria-hidden className="inline-block h-px w-8" style={{ background: "currentColor" }} />
            {eyebrow}
          </p>
          <h2 ref={lineRef} className="m-0 max-w-[16ch] text-balance font-light leading-[1.02] tracking-[-0.03em]" style={{ fontSize: "clamp(44px, 8.4cqw, 148px)", fontStretch: "112.5%", fontWeight: 320 }}>
            {aeAccent(text, night ? AE_SAND : AE_DUNE)}
          </h2>
          {sub ? (
            <p className="mt-10 max-w-[38rem] text-[16.5px] leading-[1.6] @3xl:ml-[34cqw]" style={{ fontStretch: "100%", opacity: 0.78 }}>
              {sub}
            </p>
          ) : null}
        </div>,
      )}
    </section>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this text effect

A statement that only holds together while you read it. As it scrolls in, sand blows in from upwind and settles into the words, windward side first; it holds while it is in the middle of the view; and as it scrolls on, it erodes from the same side and blows away downwind. Scroll back and the wind runs backwards. Mark one word with asterisks for the italic serif accent. Works on a light or a dark ground.

Curator’s note

Free. The headline is snapshotted once and scrubbed through the dust layer: the scroll position is the burst's clock, so it gathers between 8% and 38% of the section's pass, holds, and scatters between 62% and 94%, and scrolling back runs it backwards. The accent word, marked with asterisks, is set in Instrument Serif italic. Every Aeolian entry is cut byte for byte from one site (Barchan, a made-up camp that moves with the dunes) and shares its light and its wind: sand, umber and haze with one ember accent, Archivo set wide and light, Instrument Serif italic for one word, and a single page-wide wind that every engine reads.

Pairs well with

Featured
NewPro
NewPro

Barchan Site

site templates
NewPro