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
Props
"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.