@bcwatson22/motes
v0.2.2
Published
A particle field for a canvas in 4.3KB gzipped. Simulation in Rust, drawing in JS, no wasm-bindgen.
Maintainers
Readme
Motes
A drifting particle field for a canvas, in 4.3KB gzipped. The simulation is written in Rust and compiled to WebAssembly; the drawing stays in TypeScript. Built for engaging.engineering, where it replaced a general-purpose particle engine and took 14% off the site's client JavaScript.
To use it, run npm i @bcwatson22/motes — there is no asset to host and no path to configure, because the compiled module is inlined into the package.
import { createField } from '@bcwatson22/motes';
const field = await createField(document.querySelector('canvas'), {
color: '#ffffff',
});
// on unmount
field.destroy();Options
Changing settings while it runs
createField resolves to { update, destroy }.
field.update({ speed: 1.5, color: 'var(--brand-blue)' });update merges into the current settings, so anything you leave out stays as
it was, and a value passed as undefined is ignored rather than blanking a
default — which is what a caller spreading optional props usually means.
Everything but count applies on the very next frame with no respawn,
because the simulation reads these values every tick rather than baking them
into each particle. count is the exception: it decides how many particles
exist, so changing it spawns the shortfall or drops the surplus.
That makes update the right tool for a control someone drags. Destroying and
recreating the field on every input event restarts the animation on every pixel
of the drag; this does not.
destroy cancels the animation frame and removes the window listeners. Call it on unmount.
The field follows the window on its own: resizing reflows it, carrying the particles already on the canvas into the new box in proportion and spawning whatever the new area calls for. There is no need to debounce that or to recreate the field — it is a multiply per particle. The canvas is sized from its own clientWidth and clientHeight with the backing store scaled by devicePixelRatio, so give it dimensions in CSS and it will be sharp on a retina display.
Usage with React
The package ships no React binding, and deliberately: the whole interface is one function that takes a canvas, and wrapping it costs about forty lines. Those forty lines are below rather than in the dependency tree, because a React entry point would double the surface area and the release burden of a package this size.
This is what the site it was built for actually runs.
'use client';
import { createField, type Field } from '@bcwatson22/motes';
import { useEffect, useRef } from 'react';
const ParticlesCanvas = ({ color = '#ffffff' }: { color?: string }) => {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!ref.current) return;
/* These two cover different races. `field` is what the cleanup destroys
when the component unmounts normally; `cancelled` is what stops a field
that arrives after the cleanup has already run. */
let field: Field | undefined;
let cancelled = false;
createField(ref.current, { color })
.then((created) => {
/* Unmounted while the module was still instantiating. Without this the
field runs on with nothing holding a reference to stop it. */
if (cancelled) {
created.destroy();
return;
}
/* Kept so the cleanup below has something to destroy. Assigned in
one closure and read in another, which is why it can look unused. */
field = created;
})
/* Not worth an error boundary: the page is correct without a decorative
background. */
.catch(() => {});
return () => {
cancelled = true;
field?.destroy();
};
}, [color]);
/* Decoration, so there is nothing here to announce. */
return <canvas ref={ref} aria-hidden="true" />;
};
export { ParticlesCanvas };The two variables are the part worth copying, because they cover different races and neither covers the other:
Drop the assignment and the second case leaks: the field starts its animation loop and the component goes away with nothing holding a reference to stop it. Drop the flag and the first case leaks the same way. Both are needed.
Using a ref instead
A useRef works too, and is equivalent for this component:
const fieldRef = useRef<Field | null>(null);
useEffect(() => {
if (!ref.current) return;
let cancelled = false;
createField(ref.current, { color })
.then((created) => {
if (cancelled) {
created.destroy();
return;
}
fieldRef.current = created;
})
.catch(() => {});
return () => {
cancelled = true;
fieldRef.current?.destroy();
/* Cleared, unlike the `let`. A ref outlives the effect, so a stale handle
would survive into the next run. */
fieldRef.current = null;
};
}, [color]);Note it still needs cancelled — a ref does nothing about the pre-resolution
race — and it adds an obligation to null the ref on the way out. So for a
component that only creates and destroys, the plain let is less to get wrong.
The ref earns its keep the moment something outside the effect needs the field: a control that changes its options, a button that pauses it, anything that has to reach the handle from an event. Then a local variable is not enough, because nothing outside the effect can see it.
The canvas needs dimensions from CSS. position: fixed; inset: 0 for a
full-page background, or any sized box for a contained one; the field reads
clientWidth and clientHeight and scales its backing store to match.
Deferring it
A decorative background should not compete with the page for the main thread
while that page is still painting. The site gates the canvas behind
requestIdleCallback, so both the work and the module land after the page is
interactive:
'use client';
import dynamic from 'next/dynamic';
import { useEffect, useState } from 'react';
const ParticlesCanvas = dynamic(() => import('./ParticlesCanvas'), {
ssr: false,
});
const Particles = () => {
const [isReady, setIsReady] = useState(false);
useEffect(() => {
/* Safari only shipped requestIdleCallback in 18.4, hence the fallback. The
timeout is the backstop for a browser that never goes idle. */
if (typeof window.requestIdleCallback === 'function') {
const handle = window.requestIdleCallback(() => setIsReady(true), {
timeout: 2000,
});
return () => window.cancelIdleCallback(handle);
}
const handle = window.setTimeout(() => setIsReady(true), 200);
return () => window.clearTimeout(handle);
}, []);
return isReady ? <ParticlesCanvas /> : null;
};
export { Particles };Following the colour scheme
color is read once per field, so changing it means recreating one — which
useEffect already does if you put it in the dependencies. Subscribe to the
media query rather than reading it once, or the field will keep whichever scheme
was in force at mount:
const query = '(prefers-color-scheme: dark)';
const subscribe = (onChange: () => void) => {
const list = window.matchMedia(query);
list.addEventListener('change', onChange);
return () => list.removeEventListener('change', onChange);
};
const isDark = useSyncExternalStore(
subscribe,
() => window.matchMedia(query).matches,
/* Server snapshot. Never reaches the screen — the canvas is painted after
mount — so it only has to be stable. */
() => true,
);Reduced motion needs no equivalent: the package honours it itself, and keeps following it while the page is open. See Accessibility.
Stack
Rust
WebAssembly
TypeScript
Vitest
Some numbers, honestly
The simulation is not why this is small. Benchmarked against the same loop in hand-written JavaScript, at 296 particles:
That is a real speedup on 0.005% of a 60fps frame budget. The bottleneck is the few hundred arc() calls, and those are identical either way. If you are weighing this against a couple of hundred lines of your own JavaScript, choose it for the size and for not writing it — not for the arithmetic.
The module is inlined as base64, which costs about 680 bytes gzipped over fetching it separately. That trade is right at this size and would be indefensible at 200KB.
For the avoidance of the usual ambiguity about what a size claim covers:
The headline number is the last one, because it is the one that reaches a browser. Inlined base64 does not compress as well as the raw module it encodes, which is why the middle row is more than double the first.
Two things that will surprise you
The particle count is not the number you pass. It is scaled by canvas area against a 1920×1080 reference, so a 1280×800 canvas gets about half of it. This matches the convention the effect was ported from, and means a field looks about as dense on a phone as on a desktop rather than becoming soup.
A dark colour at the default opacity looks grey. Brand blue at 0.3 over a near-white page composites to 14% saturation, against the colour's own 73%. On a light background raise opacity to somewhere near 0.55; the bubble follows, being derived as twice the resting value.
Accessibility
The field honours prefers-reduced-motion by default. Where someone has
asked their system for less motion, it draws a single frame and never starts the
animation loop — the particles are there, they are simply still. The guidance is
to remove the motion rather than the content, and an empty canvas is a missing
feature rather than a considerate one.
It subscribes rather than reading the preference once, so changing the setting with the page open stops or starts the field without a reload.
Pass respectReducedMotion: false to opt out. There are cases where an
animation carries meaning and removing it removes information — but a drifting
background is not one of them, so the default is on and the escape hatch is
explicit.
The canvas itself carries no information, so give it aria-hidden="true" and
keep it out of the accessibility tree:
<canvas aria-hidden="true"></canvas>Content Security Policy
Instantiating WebAssembly is a form of code generation, so a page with a CSP needs to allow it:
script-src 'self' 'wasm-unsafe-eval';'wasm-unsafe-eval' rather than 'unsafe-eval' — the narrow grant permits WebAssembly without permitting eval() across the whole page.
Development
To get it running locally, run pnpm i and then pnpm verify to run lint, format, types, coverage and the build — the same set CI runs.
Changing the simulation needs a Rust toolchain with the wasm target:
rustup target add wasm32-unknown-unknown
pnpm wasmThat rebuilds src/wasm.ts — commit the result. It is generated and committed so nobody installing this package needs Rust, and CI fails if it has drifted from the crate.
Licence
MIT
