first commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
michilis
2026-08-20 22:41:25 +02:00
co-authored by Cursor
commit aa1771ea20
136 changed files with 27069 additions and 0 deletions
+484
View File
@@ -0,0 +1,484 @@
/**
* ColorBends: the animated field behind the hero.
*
* A port of React Bits' ColorBends (`@react-bits/ColorBends-JS-CSS`). Upstream is a
* React component that asks three.js for an orthographic camera, a scene, and a
* PlaneGeometry, so it can draw one full-screen quad with one ShaderMaterial. That is
* ~170KB of renderer, plus React, for a job raw WebGL does in the few dozen lines
* below — on a site that ships neither.
*
* The fragment shader is upstream's, unchanged apart from the precision header that
* three would otherwise prepend. The uniforms, their defaults, and the pointer easing
* all match it too, so the parameters from the React Bits playground mean here exactly
* what they mean there.
*
* What is new is the housekeeping a background on this site needs and a 1080x1080 demo
* box does not: the loop stops when the hero scrolls away, `prefers-reduced-motion`
* gets a single still frame instead of an animation, and everything is releasable in
* one call because the router swaps this page out from under us.
*/
/** Upstream's uniform array length. More colours than this are dropped. */
const MAX_COLORS = 8;
/** Ceiling on the backing store, upstream's `setPixelRatio(min(dpr, 2))`. */
const MAX_DPR = 2;
/**
* How fast the pointer catches up, per second. Upstream's `pointerSmoothRef`: the
* field leans toward the cursor rather than snapping to it.
*/
const POINTER_EASE = 8;
/**
* Longest frame the pointer easing will act on. A tab that was in the background for a
* minute comes back with one enormous delta, and without this the lean would jump.
*/
const MAX_FRAME = 0.1;
export interface ColorBendsOptions {
/** Degrees. Direction the bands travel in. */
rotation?: number;
/** Degrees per second added to `rotation`, so the field turns as it flows. */
autoRotate?: number;
speed?: number;
/** Hex strings. Empty paints upstream's RGB fallback instead. */
colors?: string[];
/** Off makes the canvas opaque black behind the bands. */
transparent?: boolean;
scale?: number;
frequency?: number;
warpStrength?: number;
/** How far the field leans toward the pointer. 0 ignores it. */
mouseInfluence?: number;
/** How far the field slides under the pointer. 0 ignores it. */
parallax?: number;
noise?: number;
iterations?: number;
intensity?: number;
bandWidth?: number;
}
/** Everything created here, releasable in one call. */
export interface ColorBendsHandle {
destroy(): void;
}
const VERT = `
attribute vec2 aPosition;
varying vec2 vUv;
void main() {
vUv = aPosition * 0.5 + 0.5;
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`;
/*
* Upstream's fragment shader, verbatim below the precision header. `uTransparent`
* premultiplies the alpha into the colour on the way out, which is what lets the
* canvas composite over the page background without a dark fringe.
*/
const FRAG = `
#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
#define MAX_COLORS ${MAX_COLORS}
uniform vec2 uCanvas;
uniform float uTime;
uniform float uSpeed;
uniform vec2 uRot;
uniform int uColorCount;
uniform vec3 uColors[MAX_COLORS];
uniform int uTransparent;
uniform float uScale;
uniform float uFrequency;
uniform float uWarpStrength;
uniform vec2 uPointer; // in NDC [-1,1]
uniform float uMouseInfluence;
uniform float uParallax;
uniform float uNoise;
uniform int uIterations;
uniform float uIntensity;
uniform float uBandWidth;
varying vec2 vUv;
void main() {
float t = uTime * uSpeed;
vec2 p = vUv * 2.0 - 1.0;
p += uPointer * uParallax * 0.1;
vec2 rp = vec2(p.x * uRot.x - p.y * uRot.y, p.x * uRot.y + p.y * uRot.x);
vec2 q = vec2(rp.x * (uCanvas.x / uCanvas.y), rp.y);
q /= max(uScale, 0.0001);
q /= 0.5 + 0.2 * dot(q, q);
q += 0.2 * cos(t) - 7.56;
vec2 toward = (uPointer - rp);
q += toward * uMouseInfluence * 0.2;
for (int j = 0; j < 5; j++) {
if (j >= uIterations - 1) break;
vec2 rr = sin(1.5 * (q.yx * uFrequency) + 2.0 * cos(q * uFrequency));
q += (rr - q) * 0.15;
}
vec3 col = vec3(0.0);
float a = 1.0;
if (uColorCount > 0) {
vec2 s = q;
vec3 sumCol = vec3(0.0);
float cover = 0.0;
for (int i = 0; i < MAX_COLORS; ++i) {
if (i >= uColorCount) break;
s -= 0.01;
vec2 r = sin(1.5 * (s.yx * uFrequency) + 2.0 * cos(s * uFrequency));
float m0 = length(r + sin(5.0 * r.y * uFrequency - 3.0 * t + float(i)) / 4.0);
float kBelow = clamp(uWarpStrength, 0.0, 1.0);
float kMix = pow(kBelow, 0.3); // strong response across 0..1
float gain = 1.0 + max(uWarpStrength - 1.0, 0.0); // allow >1 to amplify displacement
vec2 disp = (r - s) * kBelow;
vec2 warped = s + disp * gain;
float m1 = length(warped + sin(5.0 * warped.y * uFrequency - 3.0 * t + float(i)) / 4.0);
float m = mix(m0, m1, kMix);
float w = 1.0 - exp(-uBandWidth / exp(uBandWidth * m));
sumCol += uColors[i] * w;
cover = max(cover, w);
}
col = clamp(sumCol, 0.0, 1.0);
a = uTransparent > 0 ? cover : 1.0;
} else {
vec2 s = q;
for (int k = 0; k < 3; ++k) {
s -= 0.01;
vec2 r = sin(1.5 * (s.yx * uFrequency) + 2.0 * cos(s * uFrequency));
float m0 = length(r + sin(5.0 * r.y * uFrequency - 3.0 * t + float(k)) / 4.0);
float kBelow = clamp(uWarpStrength, 0.0, 1.0);
float kMix = pow(kBelow, 0.3);
float gain = 1.0 + max(uWarpStrength - 1.0, 0.0);
vec2 disp = (r - s) * kBelow;
vec2 warped = s + disp * gain;
float m1 = length(warped + sin(5.0 * warped.y * uFrequency - 3.0 * t + float(k)) / 4.0);
float m = mix(m0, m1, kMix);
col[k] = 1.0 - exp(-uBandWidth / exp(uBandWidth * m));
}
a = uTransparent > 0 ? max(max(col.r, col.g), col.b) : 1.0;
}
col *= uIntensity;
if (uNoise > 0.0001) {
float n = fract(sin(dot(gl_FragCoord.xy + vec2(uTime), vec2(12.9898, 78.233))) * 43758.5453123);
col += (n - 0.5) * uNoise;
col = clamp(col, 0.0, 1.0);
}
vec3 rgb = (uTransparent > 0) ? col * a : col;
gl_FragColor = vec4(rgb, a);
}
`;
function compile(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | null {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
// A driver that will not take the shader is not worth a broken page: the caller
// bails out and the CSS underneath the canvas stays on screen.
gl.deleteShader(shader);
return null;
}
return shader;
}
function link(gl: WebGLRenderingContext): WebGLProgram | null {
const vert = compile(gl, gl.VERTEX_SHADER, VERT);
const frag = compile(gl, gl.FRAGMENT_SHADER, FRAG);
if (!vert || !frag) return null;
const program = gl.createProgram();
if (!program) return null;
gl.attachShader(program, vert);
gl.attachShader(program, frag);
gl.linkProgram(program);
// The shaders are attached to the program and no longer needed on their own.
gl.deleteShader(vert);
gl.deleteShader(frag);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
gl.deleteProgram(program);
return null;
}
return program;
}
/** `#rgb` or `#rrggbb` to the 0..1 triple the shader wants. Unreadable hex reads black. */
function toRgb(hex: string): [number, number, number] {
const h = hex.replace('#', '').trim();
const full = h.length === 3 ? `${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}` : h;
const n = Number.parseInt(full.slice(0, 6), 16);
if (!Number.isFinite(n)) return [0, 0, 0];
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}
/**
* Start the field on `canvas`. Returns a handle whose `destroy` releases the context,
* the observers and the listeners; call it before the element goes away.
*
* Returns null when there is no WebGL to be had, which is the caller's cue to leave
* whatever is painted underneath the canvas on screen.
*/
export function createColorBends(
canvas: HTMLCanvasElement,
options: ColorBendsOptions = {},
): ColorBendsHandle | null {
const {
rotation = 0,
autoRotate = 0,
speed = 0.2,
colors = [],
transparent = true,
scale = 1,
frequency = 1,
warpStrength = 1,
mouseInfluence = 1,
parallax = 0.5,
noise = 0.15,
iterations = 1,
intensity = 1.5,
bandWidth = 6,
} = options;
const context = canvas.getContext('webgl', {
alpha: true,
antialias: false,
depth: false,
stencil: false,
// The shader hands back colour already multiplied by coverage, which is what the
// compositor expects when it blends this canvas over the page.
premultipliedAlpha: true,
powerPreference: 'high-performance',
});
if (!context) return null;
/*
* Bound again under a non-nullable type. `destroy` below is a hoisted declaration, so
* TypeScript treats it as created before the check above and will not carry the
* narrowing into it; this way there is nothing to narrow.
*/
const gl: WebGLRenderingContext = context;
const program = link(gl);
if (!program) return null;
// One quad as two triangles, in clip space. The vertex shader reads texture
// coordinates straight off these, so there is nothing else in the buffer.
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
gl.useProgram(program);
const position = gl.getAttribLocation(program, 'aPosition');
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
const u = (name: string) => gl.getUniformLocation(program, name);
const uCanvas = u('uCanvas');
const uTime = u('uTime');
const uRot = u('uRot');
const uPointer = u('uPointer');
// Everything that never changes after setup is written once, here.
gl.uniform1f(u('uSpeed'), speed);
gl.uniform1f(u('uScale'), scale);
gl.uniform1f(u('uFrequency'), frequency);
gl.uniform1f(u('uWarpStrength'), warpStrength);
gl.uniform1f(u('uMouseInfluence'), mouseInfluence);
gl.uniform1f(u('uParallax'), parallax);
gl.uniform1f(u('uNoise'), noise);
gl.uniform1i(u('uIterations'), iterations);
gl.uniform1f(u('uIntensity'), intensity);
gl.uniform1f(u('uBandWidth'), bandWidth);
gl.uniform1i(u('uTransparent'), transparent ? 1 : 0);
const palette = colors.filter(Boolean).slice(0, MAX_COLORS);
const flat = new Float32Array(MAX_COLORS * 3);
palette.forEach((hex, i) => flat.set(toRgb(hex), i * 3));
gl.uniform3fv(u('uColors[0]'), flat);
gl.uniform1i(u('uColorCount'), palette.length);
gl.clearColor(0, 0, 0, transparent ? 0 : 1);
/* ---------- size ---------- */
let width = 0;
let height = 0;
let ratio = 0;
const resize = (): void => {
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
// Upstream measures the container; here the canvas is the layer, stretched by CSS.
const w = canvas.clientWidth || 1;
const h = canvas.clientHeight || 1;
// Dragged to a denser screen counts as a resize even at the same CSS size.
if (w === width && h === height && dpr === ratio) return;
width = w;
height = h;
ratio = dpr;
canvas.width = Math.max(1, Math.round(w * dpr));
canvas.height = Math.max(1, Math.round(h * dpr));
gl.viewport(0, 0, canvas.width, canvas.height);
// The shader only wants the aspect ratio, so CSS pixels are the right unit and
// the field does not reshape itself when it moves to a denser screen.
gl.uniform2f(uCanvas, w, h);
};
/* ---------- pointer ---------- */
const target = { x: 0, y: 0 };
const current = { x: 0, y: 0 };
const tracksPointer = mouseInfluence !== 0 || parallax !== 0;
/*
* Listened for on the window rather than on the canvas. The canvas sits behind the
* hero's own text and takes no pointer events of its own, so it would never hear a
* move; the rect maths is upstream's either way.
*/
const onPointerMove = (event: PointerEvent): void => {
const rect = canvas.getBoundingClientRect();
target.x = ((event.clientX - rect.left) / (rect.width || 1)) * 2 - 1;
target.y = -(((event.clientY - rect.top) / (rect.height || 1)) * 2 - 1);
};
/* ---------- the loop ---------- */
let frame: number | null = null;
let last = 0;
let elapsed = 0;
const draw = (): void => {
const deg = (rotation % 360) + autoRotate * elapsed;
const rad = (deg * Math.PI) / 180;
gl.uniform1f(uTime, elapsed);
gl.uniform2f(uRot, Math.cos(rad), Math.sin(rad));
gl.uniform2f(uPointer, current.x, current.y);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 3);
};
const loop = (now: number): void => {
const dt = Math.min((now - last) / 1000, MAX_FRAME);
last = now;
elapsed += dt;
const amount = Math.min(1, dt * POINTER_EASE);
current.x += (target.x - current.x) * amount;
current.y += (target.y - current.y) * amount;
resize();
draw();
frame = requestAnimationFrame(loop);
};
const start = (): void => {
if (frame !== null) return;
last = performance.now();
frame = requestAnimationFrame(loop);
};
const stop = (): void => {
if (frame === null) return;
cancelAnimationFrame(frame);
frame = null;
};
/* ---------- lifecycle ---------- */
const still = window.matchMedia('(prefers-reduced-motion: reduce)');
let visible = true;
/*
* Reduced motion gets the field, not the animation: one still frame at t=0, centred
* pointer, redrawn only when the element is resized. Reading the preference live,
* rather than once at startup, is what the rest of the site's motion does too.
*/
const applyMotion = (): void => {
if (still.matches) {
stop();
window.removeEventListener('pointermove', onPointerMove);
elapsed = 0;
current.x = 0;
current.y = 0;
target.x = 0;
target.y = 0;
resize();
draw();
return;
}
if (tracksPointer) {
window.addEventListener('pointermove', onPointerMove, { passive: true });
}
if (visible) start();
};
let observer: IntersectionObserver | null = null;
if (typeof IntersectionObserver !== 'undefined') {
// A hero that has been scrolled past has no business holding the GPU awake.
observer = new IntersectionObserver((entries) => {
visible = entries.some((entry) => entry.isIntersecting);
if (still.matches) return;
if (visible) start();
else stop();
});
observer.observe(canvas);
}
let resizer: ResizeObserver | null = null;
if (typeof ResizeObserver !== 'undefined') {
resizer = new ResizeObserver(() => {
resize();
// While paused there is no loop to pick the new size up on the next frame.
if (frame === null) draw();
});
resizer.observe(canvas);
} else {
window.addEventListener('resize', resize);
}
const onContextLost = (event: Event): void => {
// Without this the context is gone for good and the canvas stays blank.
event.preventDefault();
stop();
};
const onContextRestored = (): void => {
// The program and buffers died with the context. The page will outlive this
// component, so hand the work back to the caller rather than rebuilding here.
destroy();
};
resize();
canvas.addEventListener('webglcontextlost', onContextLost);
canvas.addEventListener('webglcontextrestored', onContextRestored);
still.addEventListener('change', applyMotion);
applyMotion();
function destroy(): void {
stop();
observer?.disconnect();
resizer?.disconnect();
if (!resizer) window.removeEventListener('resize', resize);
window.removeEventListener('pointermove', onPointerMove);
canvas.removeEventListener('webglcontextlost', onContextLost);
canvas.removeEventListener('webglcontextrestored', onContextRestored);
still.removeEventListener('change', applyMotion);
gl.deleteProgram(program);
gl.deleteBuffer(buffer);
// Frees the drawing buffer now instead of whenever the canvas is collected: the
// router leaves a trail of these otherwise, and browsers cap live contexts.
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
return { destroy };
}
+358
View File
@@ -0,0 +1,358 @@
/**
* The site's one motion utility. No animation library: everything visual is CSS,
* and this file only decides *when* a CSS class goes on.
*
* Four jobs:
* 1. `initReveal` watches `[data-reveal]` elements and adds `.in` at 15% visibility,
* once, with a stagger so a row of cards arrives as a sequence.
* 2. `onReady` runs an island's setup on first paint and again after a view
* transition, because a module script that has already run is never re-executed
* by the router (astro/dist/transitions/swap-functions.js marks it as done).
* 3. `countUp` and `swapText` animate a number into place.
* 4. `enterStagger` gives island-rendered rows the same entrance as prerendered ones.
*
* Every one of them checks `prefers-reduced-motion` first and lands on the final
* state immediately when it is set. The CSS carries the same gate, so a preference
* changed mid-visit is honoured too.
*/
import { intlTag } from '../i18n/config';
import { useI18n } from '../i18n/client';
/** Delay between items in a staggered group. `--stagger` in global.css. */
const STEP_MS = 50;
/** Nothing waits longer than this to arrive, however long the list is. `--stagger-cap`. */
const CAP_MS = 400;
/** Fraction of an element that has to be on screen before it counts as arrived. */
const RATIO = 0.15;
export function prefersReducedMotion(): boolean {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
/** The stagger delay for item `index` of a group, capped so the tail is not left waiting. */
export function staggerDelay(index: number, step = STEP_MS, cap = CAP_MS): number {
return Math.min(index * step, cap);
}
/**
* `smooth` unless the reader asked for less motion, in which case scrolling jumps.
* A programmatic smooth scroll is movement like any other.
*/
export function scrollBehavior(): ScrollBehavior {
return prefersReducedMotion() ? 'auto' : 'smooth';
}
/* ---------- reveal on scroll ---------- */
let observer: IntersectionObserver | null = null;
/**
* True once the router has swapped a page in, so an island can tell a full page load
* from an arrival by view transition. The hero uses it: its entrance is a greeting
* for someone who has just loaded the site, not something to replay every time they
* come back to the home page.
*/
let arrived = false;
// Guarded: lib/skeleton.ts imports from here and is used in build-time frontmatter,
// so this module is evaluated on the server as well as in the browser.
if (typeof document !== 'undefined') {
document.addEventListener('astro:after-swap', () => {
arrived = true;
});
}
export function arrivedByRouter(): boolean {
return arrived;
}
function reveal(el: HTMLElement, delay: number, instant = false): void {
if (instant) {
// Straight to the final state with no transition to run through on the way.
el.style.transition = 'none';
el.classList.add('in');
void el.offsetWidth;
el.style.transition = '';
el.dispatchEvent(new CustomEvent('reveal'));
return;
}
if (delay > 0) el.style.setProperty('--reveal-delay', `${delay}ms`);
el.classList.add('in');
// Islands hang count-ups and bar fills off this rather than running their own
// observer. `whenRevealed` below covers the case where the listener is late.
el.dispatchEvent(new CustomEvent('reveal'));
}
function onIntersect(entries: IntersectionObserverEntry[], obs: IntersectionObserver): void {
const viewport = window.innerHeight || 1;
// An element taller than the viewport can never reach 15% of itself on screen, so
// it also counts as arrived once 15% of a screenful of it is showing.
const arrived = entries.filter(
(entry) =>
entry.isIntersecting &&
(entry.intersectionRatio >= RATIO || entry.intersectionRect.height >= viewport * RATIO),
);
// Reading order, so a grid row lights up left to right rather than in whatever
// order the observer happened to queue them.
arrived.sort(
(a, b) =>
a.boundingClientRect.top - b.boundingClientRect.top ||
a.boundingClientRect.left - b.boundingClientRect.left,
);
arrived.forEach((entry, index) => {
const el = entry.target as HTMLElement;
obs.unobserve(el);
const explicit = el.dataset['revealDelay'];
// No explicit delay means "stagger with whatever else arrived in this batch",
// which is what keeps a long grid from dumping a whole screen at once and keeps
// a row scrolled into view later from waiting on the index of the row above it.
reveal(el, explicit === undefined ? staggerDelay(index) : Number(explicit));
});
}
/** Start watching every unrevealed `[data-reveal]` under `root`. Safe to call twice. */
export function initReveal(root: ParentNode = document): void {
const targets = [...root.querySelectorAll<HTMLElement>('[data-reveal]:not(.in)')];
if (targets.length === 0) return;
if (prefersReducedMotion() || typeof IntersectionObserver === 'undefined') {
for (const el of targets) reveal(el, 0, true);
return;
}
observer ??= new IntersectionObserver(onIntersect, { threshold: [0, RATIO] });
for (const el of targets) {
// `data-reveal="load"` is a first-impression entrance: it plays on a full page
// load and never again, so coming back to the page through the router does not
// replay it.
if (el.dataset['reveal'] === 'load' && arrived) {
reveal(el, 0, true);
continue;
}
observer.observe(el);
}
}
/** Drop the observer before the router swaps the body out from under it. */
export function resetReveal(): void {
observer?.disconnect();
observer = null;
}
/**
* Run `callback` when `el` has arrived, now if it already has.
*
* Islands cannot rely on being listening before the observer fires, so the check for
* `.in` is the important half of this.
*/
export function whenRevealed(el: Element, callback: () => void): void {
if (el.classList.contains('in')) {
callback();
return;
}
el.addEventListener('reveal', callback, { once: true });
}
/* ---------- shared element page transitions ---------- */
/**
* Hand the card being clicked the view transition names that its twins in the mint
* page header already carry, so the icon and the name morph across the navigation
* instead of cross-fading with the rest of the page.
*
* Only the clicked card gets them. A named element is captured and composited on its
* own, and /mints has 58 cards: naming them all up front would mean 116 separate
* textures per navigation to animate 2 of them. The names are cleared again after the
* swap, and if the reader cancels the navigation the stale names go with the page.
*
* Capture phase, so this runs before the router's own click handler starts the
* transition and takes its snapshot of the outgoing page.
*/
export function armSharedTransitions(): void {
if (typeof document === 'undefined') return;
// Only ever the cards' own two elements. The mint page header carries its half of
// the pair as a permanent inline style, and clearing that would take the morph's
// other end with it.
const clear = (): void => {
for (const el of document.querySelectorAll<HTMLElement>('[data-vt-icon], [data-vt-name]')) {
el.style.removeProperty('view-transition-name');
}
};
document.addEventListener(
'click',
(event) => {
const card = (event.target as HTMLElement | null)?.closest<HTMLElement>('[data-mint-card]');
if (!card) return;
clear();
const icon = card.querySelector<HTMLElement>('[data-vt-icon]');
const name = card.querySelector<HTMLElement>('[data-vt-name]');
if (icon?.dataset['vtIcon']) icon.style.setProperty('view-transition-name', icon.dataset['vtIcon']);
if (name?.dataset['vtName']) name.style.setProperty('view-transition-name', name.dataset['vtName']);
},
true,
);
document.addEventListener('astro:after-swap', clear);
}
/* ---------- island lifecycle ---------- */
/**
* Run an island's setup on this page, and again on every page the router swaps in.
*
* The router never re-executes a module script it has already run, so an island that
* only does its work at module scope is dead after the first navigation. It does fire
* `astro:page-load` on every arrival, including the first, but on the first it waits
* for `window.load`, which is far too late to be the only trigger. Hence both.
*/
export function onReady(setup: () => void): void {
let done = false;
const run = (): void => {
if (done) return;
done = true;
setup();
};
// Module scripts are deferred, so the document is parsed by the time this runs.
run();
document.addEventListener('astro:after-swap', () => {
done = false;
});
document.addEventListener('astro:page-load', run);
}
/** Register a teardown to run just before the router replaces this page. */
export function onLeave(teardown: () => void): void {
document.addEventListener('astro:before-swap', teardown, { once: true });
}
/* ---------- numbers ---------- */
const frames = new WeakMap<Element, number>();
/** Stop an in-flight count, so a live update never fights the count-up. */
export function cancelCount(el: Element): void {
const running = frames.get(el);
if (running !== undefined) {
cancelAnimationFrame(running);
frames.delete(el);
if (el instanceof HTMLElement) el.style.minWidth = '';
}
}
/** Decelerating, so the number lands rather than stopping dead. */
const easeOut = (t: number): number => 1 - Math.pow(1 - t, 3);
export interface CountUpOptions {
/** Decimal places. 0 for counts, 1 for a rating. */
decimals?: number;
duration?: number;
/** Overrides the default locale formatting (used for thousands separators). */
format?: (value: number) => string;
}
/**
* Count `el` from zero up to `to`.
*
* Interrupting an in-flight count cancels it rather than stacking a second one, and
* with reduced motion set the final value is written straight away.
*/
export function countUp(el: HTMLElement, to: number, options: CountUpOptions = {}): void {
const { decimals = 0, duration = 350, format } = options;
/*
* The locale comes from the page, not from the browser's own default: a Dutch
* reader with an en-US machine is reading a Dutch page, and 4,6 counting up to 4.6
* would be the number changing notation halfway through the animation.
*/
const locale = intlTag(useI18n().locale);
const render = (value: number): string =>
format ? format(value) : value.toLocaleString(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
cancelCount(el);
if (prefersReducedMotion() || !Number.isFinite(to)) {
el.textContent = render(to);
return;
}
/*
* Hold the width the final value needs before counting up to it.
*
* "1,247" is wider than "4", and without this the numbers either side of it in the
* pulse strip would shuffle for the length of the count. Tabular figures fix the
* digits, not how many of them there are.
*/
el.textContent = render(to);
el.style.minWidth = `${el.getBoundingClientRect().width}px`;
const start = performance.now();
const step = (now: number): void => {
const progress = Math.min(1, (now - start) / duration);
el.textContent = render(to * easeOut(progress));
if (progress < 1) {
frames.set(el, requestAnimationFrame(step));
} else {
frames.delete(el);
el.textContent = render(to);
el.style.minWidth = '';
}
};
frames.set(el, requestAnimationFrame(step));
}
/**
* Replace text with a dip and a 4px rise, for a value that changed under the reader
* rather than one arriving for the first time.
*/
export function swapText(el: HTMLElement, text: string): void {
// A count-up still running on this element would keep writing over the new value.
cancelCount(el);
if (el.textContent === text) return;
if (prefersReducedMotion()) {
el.textContent = text;
return;
}
el.classList.remove('num-swap');
// Reading offsetWidth restarts the animation when a value changes twice quickly.
void el.offsetWidth;
el.classList.add('num-swap');
window.setTimeout(() => {
el.textContent = text;
}, 100);
el.addEventListener('animationend', () => el.classList.remove('num-swap'), { once: true });
}
/* ---------- island rendered rows ---------- */
/**
* Give freshly rendered rows the same entrance prerendered ones get.
*
* The class is removed when the animation ends so a later re-render can re-apply it,
* and rows already on screen are not re-animated by a filter change they survived.
*/
export function enterStagger(elements: Iterable<HTMLElement>, step = STEP_MS): void {
if (prefersReducedMotion()) return;
let index = 0;
for (const el of elements) {
el.style.setProperty('--reveal-delay', `${staggerDelay(index, step)}ms`);
el.classList.add('entering');
el.addEventListener(
'animationend',
() => {
el.classList.remove('entering');
el.style.removeProperty('--reveal-delay');
},
{ once: true },
);
index++;
}
}