Effects & motion

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

Using the catalog 2 min read

TL;DR · it's not magic, it's eight helpers

  • Every element carries its own effects. Nothing external to add.
  • Interactive components share one tiny helper, vs-fx.js, that travels in the same copy.
  • One motion gate, prefersReducedMotion(), decides whether anything moves.

Animations inline their loop directly; components import the few helpers they need. 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. This is it, running on this page:

Move your pointer near the row. Press one for the ripple.

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

A mash of clicks. Six ripples, tops.
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:

  • Runs only in viewport + tab visible
  • dt clamped, no giant jumps
  • Idempotent start() / stop()
  • Debounced resize via onResize
  • Full teardown on unmount

On unmount the rAF is cancelled, observers disconnected, timers cleared and 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

"It animates every time… except the first."

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. One step, every frame:

  1. Splat
  2. Divergence
  3. Jacobi pressure
  4. Gradient subtract
  5. 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.