Skip to content

Sand Dissolve Button

A pill button that turns to sand when pressed: it erodes from its windward edge, the grains blow off downwind, and it rebuilds itself out of sand as its answer.

WebGL20 deps1 file · 42 KB TSXreduced-motion aware

FreeAeolianminimalplayful
Preview

Props

No runtime dependencies
"use client";

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

/**
 * SAND DISSOLVE BUTTON.
 * A pill button that turns to sand when pressed: it erodes from its windward
 * edge, the grains blow off downwind, and it rebuilds itself out of sand as
 * its answer.
 *
 * 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". onPress is a function, so render
 * it from a client component (a server page.tsx cannot pass functions).
 *
 * WIND. Every Aeolian entry reads one page-wide wind (aeWind on globalThis),
 * so separately pasted entries still turn together.
 */

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

/** 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 one filled accent: the low sun. */
const AE_EMBER = "#E0602C";

/**
 * 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';

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

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

/**
 * 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;
}

/**
 * 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}}";

/** The kit's arrow: slides on hover. */
function AeArrow({ size = 14 }: { size?: number }) {
  return (
    <svg className="ae-arr" width={size} height={size} viewBox="0 0 14 14" aria-hidden>
      <path d="M2 7h9.5M7.8 3.4L11.5 7l-3.7 3.6" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

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),
  };
}

const DB_TONES = {
  ember: { bg: AE_EMBER, ink: "#1A0E07", line: "transparent" },
  umber: { bg: AE_UMBER, ink: AE_HAZE, line: "transparent" },
  ghost: { bg: "transparent", ink: "currentColor", line: "currentColor" },
} as const;

export interface SandDissolveButtonProps {
  label?: string;
  /** What the button says once it has re-formed. */
  doneLabel?: string;
  /** Your action. The button blows away while it runs and re-forms with doneLabel when it resolves (or with label again if it throws). */
  onPress?: () => Promise<void> | void;
  tone?: keyof typeof DB_TONES;
  /** Blow this way instead of the page's wind (degrees: 0 right, 90 away, 180 left, 270 toward you). */
  angle?: number;
  /** Go back to the first label after this many ms (0 = stay done). */
  resetAfter?: number;
  disabled?: boolean;
  className?: string;
  style?: CSSProperties;
}

/**
 * SandDissolveButton — a pill that turns to sand when pressed: it erodes
 * from its windward edge, the grains blow off downwind, and it builds itself
 * back out of sand blown in from upwind, now saying what happened. The dust is
 * a real snapshot of the button (fill, type, icon), so any label works.
 */
export function SandDissolveButton({ label = "Reserve a night", doneLabel = "Reserved", onPress, tone = "ember", angle, resetAfter = 0, disabled = false, className = "", style }: SandDissolveButtonProps) {
  const ref = useRef<HTMLButtonElement>(null);
  const { dust, layer, provide } = useAeDust(150);
  const [state, setState] = useState<"idle" | "busy" | "done">("idle");
  const shown = useRef<"idle" | "done">("idle");
  const opts = { duration: 1300, angle, reach: 0.8 };

  // Whenever the label changes under the dust, build the new one out of sand.
  useLayoutEffect(() => {
    const el = ref.current;
    if (!el || state === "busy") return;
    if (shown.current === state) return;
    shown.current = state;
    void dust.gather(el, opts);
    if (state === "done" && resetAfter > 0) {
      const t = window.setTimeout(() => {
        const b = ref.current;
        if (!b) return;
        void dust.scatter(b, opts).then(() => setState("idle"));
      }, resetAfter);
      return () => window.clearTimeout(t);
    }
    // opts is rebuilt each render from stable props.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [state, dust, resetAfter]);

  const press = async () => {
    const el = ref.current;
    if (!el || state !== "idle") return;
    setState("busy");
    const gone = dust.scatter(el, opts);
    let ok = true;
    try {
      await onPress?.();
    } catch {
      ok = false;
    }
    await gone;
    if (!ok) shown.current = "done";
    setState(ok ? "done" : "idle");
  };

  const t = DB_TONES[tone];
  const text = state === "done" ? doneLabel : label;
  return (
    <span className={"ae-scope relative inline-block " + className} style={style}>
      <style>{AE_CSS}</style>
      {layer}
      {provide(
        <button
          ref={ref}
          type="button"
          onClick={press}
          disabled={disabled}
          aria-busy={state === "busy"}
          aria-live="polite"
          className="ae-press inline-flex h-12 items-center justify-center gap-2.5 whitespace-nowrap rounded-full px-6 text-[14.5px] font-medium disabled:cursor-not-allowed disabled:opacity-50"
          style={{ background: t.bg, color: t.ink, border: "1px solid " + t.line, fontFamily: AE_DISPLAY, fontStretch: "100%" }}
        >
          {state === "done" ? (
            <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden>
              <path d="M2.5 7.4 5.6 10.3 11.5 3.8" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
            </svg>
          ) : null}
          {text}
          {state === "done" ? null : <AeArrow />}
        </button>,
      )}
    </span>
  );
}

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

Details

About this component

Press it and the button turns to sand: it erodes from the edge the wind hits first and a ragged front of loose grains runs across it. Each grain then flies as a particle would in that wind: light ones are picked up by it quickly, heavy ones lag behind and fall, and the sand thins out downwind. While your action runs it is gone; when it finishes, the button builds itself back out of sand blown in from upwind, now saying what happened (with a tick). The wind is the page's wind unless you give it its own angle. Three tones: ember, umber and a ghost outline.

Curator’s note

Free, and the kit's primitive: it ships the whole dust layer (a DOM rasterizer and a two-program WebGL2 engine), so useAeDust() gives you scatter() and gather() for any element. The grains are a real snapshot of the button (its fill, type and icon), so any label works, and each flies the exact path of a particle under linear drag and gravity in the page's wind (a response time set by grain size, a launch kick, a little eddy wander). onPress runs while it is gone; it comes back as doneLabel when that resolves, or as the label again if it throws. 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

NewPro
New

Eroding Headline

text effects
Featured
NewPro