Effects & motion

The effects baked into every element — proximity glow, press ripple, the gated animation loop, reduced motion.

Every element carries its own effects — nothing external to add. Interactive components share one tiny helper file, vs-fx.js, that arrives in the same copy; animations inline their loop directly. Here is what each behavior does and how to keep it working when you edit the file.

attachGlow — proximity glow

The border light that follows your cursor before hover ever fires. Interactive components import it from vs-fx.js:

import { attachGlow } from './vs-fx.js';
attachGlow(el, 200, () => this.disabled);  // (element, radius, disabled?)

It writes --gx, --gy and --glow on the element with style.setProperty — no framework, no re-render, so a cursor sweeping across fifty buttons stays cheap. One module-level pointer engine serves every instance, coalesced into a single rAF, with rects cached and offscreen instances skipped by a shared IntersectionObserver.

The .fx-glow layer it lights is defined by FX_CSS, a style string also exported from vs-fx.js and injected into the element’s shadow root — no separate stylesheet.

pressRipple — ripple plus tilt

import { pressRipple } from './vs-fx.js';
// on pointerdown: spawn a droplet at the press point, tilt toward it
pressRipple(host, ripples, e, { tilt: true, max: 6 });

It spawns a droplet at the exact press point and tilts the element toward it. Ripples are capped (max) so a mash of clicks cannot pile up nodes. Under reduced motion the whole thing no-ops.

useAnimLoop

The canonical rAF loop, used by nearly every animation.

const { start, stop } = useAnimLoop(hostRef, (t, dt) => {
  draw(t, dt);
}, {
  maxDt: 100,
  onResize: (w, h) => resizeCanvas(w, h),
});

What it guarantees:

  • Frames run only while the host is in the viewport and the tab is visible.
  • dt is clamped, so a pause never produces a giant jump.
  • start() / stop() are idempotent.
  • Resize is observed and debounced, reported through onResize.
  • On unmount: rAF cancelled, observers disconnected, timers cleared, listeners removed.

prefersReducedMotion

The single motion gate. Always use it — never call matchMedia directly in a component.

import { prefersReducedMotion } from './lib/motion';

if (prefersReducedMotion()) {
  drawStaticFrame();
} else {
  start();
}

It returns true for the OS setting or <html data-motion="static">, which lets a page freeze every effect at once (the catalog uses it for previews and screenshots).

countUp

Number tween for stats and counters.

const stop = countUp(1280, (v) => (display.value = Math.round(v)), {
  duration: 1400,
  from: 0,
});

Finite rAF tween, reduced-motion aware (it jumps straight to the target), and returns a cancel function to call on unmount.

doubleRaf

Waits for the browser to paint the current state before flipping to the next one — the fix for a transition that “doesn’t animate the first time”.

el.classList.add('is-collapsed');
doubleRaf(() => el.classList.remove('is-collapsed'));

ensureFont

Loads a display family once per page instead of per section, deduplicating the <link> across every component that asks for it.

ensureFont('Clash Display', 'Satoshi');

If you serve your own fonts, delete the call and let the component inherit --font-sans.

createFluidSim

The shared WebGL Navier–Stokes solver behind the fluid animations: splat → divergence → Jacobi pressure → gradient subtract → advection. It exposes the velocity and ink fields and paints nothing on its own — each animation supplies its own display pass, which is why several fluid effects can look completely different while sharing one solver.