npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

Readme

Motes

CI Coverage 100%

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 wasm

That 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