@@ -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 };
|
||||
}
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user