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.
dtis 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.