/** * 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('[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('[data-vt-icon], [data-vt-name]')) { el.style.removeProperty('view-transition-name'); } }; document.addEventListener( 'click', (event) => { const card = (event.target as HTMLElement | null)?.closest('[data-mint-card]'); if (!card) return; clear(); const icon = card.querySelector('[data-vt-icon]'); const name = card.querySelector('[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(); /** 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, 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++; } }