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

waitstate

v0.1.2

Published

Loading states, not spinners: 452 canvas loaders across three engines, sharing one state list, one glass lens and one animation loop. Zero dependencies.

Readme

waitstate

Loading states, not spinners.

452 canvas loaders across three engines - canvas 2D, WebGL shaders and a 3D orb - all speaking the same list of 11 states, sharing one glass lens, one animation loop and zero dependencies.

Gallery, live controls and exporter: https://waitstate.vercel.app

import { createOrb } from 'waitstate';

const loader = createOrb(document.querySelector('#loader'), {
  skin: 'dots',
  state: 'thinking',
  size: 64,
});

loader.setState('done');   // smooth transition, no remount
loader.destroy();

Contents


Install

npm i waitstate
import { createOrb, mountVisual, mountGLVisual, config } from 'waitstate';

Straight from a CDN, no build step:

<script type="module">
  import { mountAll } from 'https://cdn.jsdelivr.net/npm/waitstate/+esm';
  mountAll();
</script>

Or the classic way, with a waitstate global:

<script src="https://cdn.jsdelivr.net/npm/waitstate/lib/waitstate.global.js"></script>
<script>waitstate.createOrb(document.querySelector('#loader'), { skin: 'dots' })</script>

| Entry point | What is inside | Size | | --- | --- | --- | | waitstate | canvas 2D + orb + WebGL, glass lens rendered by the shader | 39 kB gzip | | waitstate/core | canvas 2D + orb only, glass lens rendered in 2D | 22 kB gzip |

Importing the full entry does one extra thing: it takes over the glass state in the 2D and orb engines, so all three engines refract through the same shader. Without WebGL2 the GL variants fall back to their 2D counterparts on their own.


Quick start

Three engines, three functions, one shape of API:

import { mountVisual, createOrb, mountGLVisual } from 'waitstate';

// canvas 2D: flat, cheap, good at small sizes
mountVisual(el, { visual: 'fibonacci', state: 'thinking' });

// orb: 3D point cloud, wireframes and solids
createOrb(el, { skin: 'dots', state: 'scanning' });

// WebGL: shader fields, blobs, math scenes
mountGLVisual(el, { visual: 'plasma', state: 'glass' });

The canvas takes its size from CSS unless you pass size, and its ink colour from the CSS color property, so a loader inherits the colour of the text around it:

<canvas id="loader" style="width: 48px; height: 48px; color: #6ee7ff"></canvas>

Data attributes

For pages without a build step. Tag the canvases, call mountAll() once, and every tagged canvas in the tree starts:

<canvas data-orb-skin="dots" data-orb-state="thinking" style="width:64px;height:64px"></canvas>
<canvas data-gl-visual="plasma" data-gl-state="glass" data-gl-react></canvas>

<script type="module">
  import { mountAll } from 'https://cdn.jsdelivr.net/npm/waitstate/+esm';
  mountAll();
</script>

| Engine | Variant | State | Glass | Pointer | Extra | | --- | --- | --- | --- | --- | --- | | orb and 3D solids | data-orb-skin | data-orb-state | data-orb-glass | data-orb-react | data-orb-density, data-orb-size | | canvas 2D | data-loader-visual | data-loader-state | data-loader-glass | data-loader-react | data-loader-size | | WebGL | data-gl-visual | data-gl-state | data-gl-glass | data-gl-react | data-gl-shape="rect", data-gl-size |

Flags are on when present or set to 1 or true. mountAll(root) mounts all three engines; autoMount(root) and autoMountGL(root) do one group each. Every one returns { handles, destroy() }.


Picking a variant

The registries are plain objects, so you can list them, filter them by category or feed them to your own picker:

import { visuals, orbSkins, glVisuals } from 'waitstate';

Object.entries(orbSkins)
  .filter(([, def]) => def.cat === '3d')
  .forEach(([key, def]) => console.log(key, def.name, def.tech));

| Registry | Category | Count | What it is | | --- | --- | --- | --- | | visuals | flat | 51 | canvas 2D: rings, bars, swarms, comets | | visuals | flat-math | 52 | canvas 2D: attractors, knots, spirals, primes | | orbSkins | dots | 50 | orb built from points | | orbSkins | lines | 50 | orb built from lines, meshes, chords | | orbSkins | 3d | 66 | 33 polyhedra and bodies of revolution, as wireframes and filled | | orbSkins | sea | 14 | anemones, fur, kelp: filaments drifting like a reef | | glVisuals | gl | 50 | shader fields: plasma, voronoi, raymarched orbs | | glVisuals | gl-math | 56 | shader math: fractals, harmonics, flows | | glVisuals | blobs | 55 | metaballs, drops, merging bodies | | glVisuals | volume | 8 | volumetric fibres, lit and defocused: anemone, dandelion, ember |

Every entry carries name, tech and cat, which is what the gallery renders.


States

The same 11 states exist in all three engines, so switching a loader from thinking to done looks consistent whatever it is drawn with. Transitions are interpolated, so setState never cuts.

| State | Reads as | | --- | --- | | idle | waiting, a barely visible breath | | sensing | rings spreading from the equator | | thinking | the surface ripples irregularly | | processing | rhythmic pull toward the core | | scanning | a scanning plane sweeps the body | | streaming | a wave runs top to bottom | | responding | a calm breath of the whole body | | error | shaking, detuned structure | | done | the body settles into a perfect shape | | glass | the body sits under a glass lens | | morph | sphere, cube, octahedron, on a loop |

import { states, loopSpec } from 'waitstate';

Object.keys(states);          // the list above
loopSpec('glass');            // { period: 20.94, exact: true } - one full rotation

The handle

Every mount function returns the same handle:

const loader = createOrb(el, { skin: 'wire' });

loader.setState('processing');  // smooth transition
loader.setGlass(1);             // glass on this instance; null = follow the state
loader.setReact(1);             // pointer reaction here; null = follow the global switch
loader.setPaused(true);         // leave the last frame, drop out of the loop
loader.draw(t);                 // render one frame by hand, outside the loop
loader.destroy();               // always call this when the element goes away
loader.state;                   // current state key

draw(t) is what makes offline rendering work: it does not need the animation loop, so it produces frames in a hidden tab or in an offscreen canvas.


Mount options

mountVisual(canvas, { visual, state, glass, react, size, pixelRatio, color });
createOrb(canvas,   { skin, state, glass, react, size, density, pixelRatio, color });
mountGLVisual(canvas, { visual, state, glass, react, size, shape, corner, superSample });

| Option | Default | What it does | | --- | --- | --- | | visual / skin | 'fibonacci' / 'dots' | key from the registry | | state | 'glass' | starting state | | glass | null | 1 forces the lens on this instance, null follows the state and the global switch | | react | null | 1 makes this instance answer the pointer, null follows the global switch | | size | from CSS | number = square in px, { w, h } = rectangle | | pixelRatio | screen DPR | raise it to render the same scene at higher resolution | | density (orb) | scales with size | number of points | | shape (GL) | 'circle' | 'rect' spreads the scene over the whole rectangle and makes the glass a rounded plate | | corner (GL) | 0.12 | corner radius of that plate | | superSample (GL) | config.superSample | extra antialiasing at 4x the pixels |


Config reference

One object, read every frame. Change a field and it applies immediately, with no remounting. configDefaults holds a copy of the starting values, so you can show or undo whatever was changed.

import { config, configDefaults } from 'waitstate';

config.speed = 1.6;
Object.assign(config.glass, { curvature: 0.8, fisheye: 0.9 });
Object.assign(config.react, { force: 1, push: 0.35 });

Motion and rendering

| Field | Default | Range | What it does | | --- | --- | --- | --- | | speed | 1 | 0 - 3 | tempo of every animation on the page. Time is accumulated, so changing it mid-flight does not cut the phase. 0 freezes everything | | dof | 0 | 0 - 1 | depth of field on the orb and the 3D solids. The silhouette blurs while the middle stays sharp, which reads as lens defocus | | superSample | 1 | 1 - 2 | extra supersampling for WebGL variants, read when an instance mounts. The canvas already renders at screen density, so 1 is sharp on retina and 2 only buys smoother edges at 4x the pixels | | fps | 30 | 15 - 60 | frame budget per instance. A loader reads as motion, not as a 60 Hz animation, so 30 looks the same and costs half | | fpsSmall | 20 | 10 - 60 | frame budget for instances below smallSize | | smallSize | 90 | px | the size at which an instance counts as small |

Glass

The lens used by the glass state and by the global override. Grouped the way liquid glass tools group it: refraction, edge, surface, highlights.

| Field | Default | What it does | | --- | --- | --- | | force | 0 | 0 = glass only in the glass state, 1 = glass over everything regardless of state. The per instance glass option wins over this | | strength | 1 | overall bending power | | curvature | 0.48 | lens curvature, 0 is flat, 0.9 is heavy | | fisheye | 0.25 | extra bulge in the middle | | aberration | 0 | chromatic dispersion at the rim. Off by default: on round shapes it shows up as a pink thread, the purple fringing of a cheap lens | | edgeBend | 0.55 | where the bending starts, as a fraction of the radius | | edgeWidth | 0.2 | width of the edge band | | frost | 0.5 | haze inside the glass, growing toward the edge | | specular | 1.4 | sharp highlight | | sheen | 1 | soft sheen along the whole edge | | thickness | 4 | width of the highlight | | angle | 124 | light angle in degrees | | glow | 0.5 | glow around the rim | | plate | 1 | shared opacity of all highlights | | radius | 0.47 | lens radius as a fraction of the canvas side, 2D fallback path |

Pointer

Particles answer the pointer: the cursor pushes them away, holding can pull them in, a click or a tap sends a wave. The content moves, not the canvas.

| Field | Default | What it does | | --- | --- | --- | | force | 0 | 0 = only instances mounted with react: 1, 1 = every instance reacts | | push | 0.22 | how far particles flee the cursor, as a fraction of the canvas side | | pull | 0 | attraction while the pointer is held. 0 means holding changes nothing | | burst | 0.5 | wave sent on click or tap, fading over about 0.3 s | | grow | 0.9 | how much particles grow right next to the cursor | | radius | 0.6 | radius of the field, in canvas sides | | glide | 0.16 | seconds for the field to catch up with the cursor. Smoothing is time based, so 30 and 60 fps feel the same | | spring | 0.5 | how particles return: 0 stiff, 1 soft with a bounce | | spacing | 1 | minimum gap between particles while held, in dot diameters. 0 lets them overlap |

Each particle has its own spring, so it glides to its target and settles instead of snapping. While held, particles pack around the cursor without overlapping: they stop at the edge of a small core and a spatial grid pushes the rest apart, checking nine neighbouring cells instead of every pair.


Recipes

Glass on everything, without touching each instance

config.glass.force = 1;

A loader that follows the cursor

config.react.force = 1;
Object.assign(config.react, { push: 0.35, burst: 0.8 });

Half speed for a calmer page

config.speed = 0.5;

A crisp still for a hero image

const inst = createOrb(offscreen, { skin: 'facets', size: 512, pixelRatio: 4 });
inst.setPaused(true);
inst.draw(1.4);          // one deterministic frame

Stop everything while a modal is open

const group = mountAll();
group.handles.forEach(h => h.setPaused(true));

Frameworks

React

import { useEffect, useRef } from 'react';
import { createOrb } from 'waitstate';

export function Loader({ state = 'thinking' }) {
  const ref = useRef(null);
  const loader = useRef(null);

  useEffect(() => {
    loader.current = createOrb(ref.current, { skin: 'dots', size: 64 });
    return () => loader.current.destroy();
  }, []);

  useEffect(() => { loader.current?.setState(state); }, [state]);

  return <canvas ref={ref} style={{ width: 64, height: 64 }} />;
}

Astro and other SSR

The module guards every browser API, so importing it on the server is safe. Mount inside a client-side script:

<canvas id="loader" style="width:56px;height:56px"></canvas>

<script>
  import { createOrb } from 'waitstate';
  createOrb(document.getElementById('loader'), { skin: 'wire', state: 'sensing' });
</script>

Backgrounds

WebGL variants can fill a rectangle instead of a circle, which turns them into section or card backgrounds:

mountGLVisual(canvas, {
  visual: 'plasma',
  size: { w: 1920, h: 1080 },
  shape: 'rect',      // scene fills the frame, glass becomes a rounded plate
  corner: 0.12,       // corner radius as a fraction of the shorter half side
  state: 'glass',
});

In rect mode the vignette fades toward the edge of the frame rather than radially, and the glass refracts along the sides with the normal rotating in the corners.


Extending

Registries are open. Anything you register behaves like a built in variant, including states, glass and export.

A canvas 2D variant. create(S) receives the canvas side and returns the draw function; keep per instance state in the closure.

import { registerVisual } from 'waitstate';

registerVisual('pulse', {
  name: 'Pulse', tech: 'One dot', label: 'Waiting', cat: 'flat',
  create(S) {
    const c = S / 2;
    return (ctx, t, ink) => {
      const r = S * 0.2 * (1 + Math.sin(t * 3) * 0.3);
      ctx.beginPath();
      ctx.arc(c, c, r, 0, Math.PI * 2);
      ctx.fillStyle = ink(0.8);        // ink(alpha) -> the CSS colour
      ctx.fill();
    };
  },
});

An orb skin. make(N) returns points on the unit sphere plus optional lines, faces and sway; draw gets the projected points as [x, y, depth, brightness]. sway(i, x, y, z, t, out) lets a skin move its own points over time, which is how the underwater filaments drift.

import { registerOrbSkin } from 'waitstate';

registerOrbSkin('ring', {
  name: 'Ring', tech: 'One equator', cat: 'lines',
  make() {
    const pts = [], line = [];
    for (let i = 0; i < 48; i++) {
      const a = (i / 48) * Math.PI * 2;
      line.push(pts.push([Math.cos(a), 0, Math.sin(a)]) - 1);
    }
    return { pts, lines: [line] };
  },
  draw(ctx, proj, o) {
    for (const q of o.sorted()) ctx.fillRect(q[0], q[1], 2, 2);
  },
});

A state. fx drives the 2D and WebGL engines, fn(p, i, t) deforms the orb and returns [radiusMultiplier, brightnessBonus].

import { registerState } from 'waitstate';

registerState('alert', {
  label: 'Alert', hint: 'Sharp double pulse.', spin: 1.1, w: [4.4],
  fx: { speed: 1.8, gain: 1.1, scale: 0.98, warp: 0.004, sweep: 0, pulse: 0.35, morph: 0, glass: 0 },
  fn: (p, i, t) => { const b = Math.sin(t * 4.4); return [1 + b * 0.08, b * 0.3]; },
});

A shader. Write scene(vec2 p, float t) returning coverage in 0..1; the prelude gives you noise, fbm, fill, band, smin and the shared glass model.

import { registerGLVisual } from 'waitstate';

registerGLVisual('bars', {
  name: 'Bars', tech: 'Vertical bars', label: 'Loading', fallback: 'bars', cat: 'gl',
  glsl: `
float scene(vec2 p, float t){
  float x = floor((p.x + 1.0) * 4.0);
  float h = 0.35 + 0.3 * sin(t * 2.0 + x);
  return fill(abs(p.y) - h) * fill(abs(fract((p.x + 1.0) * 4.0) - 0.5) - 0.3);
}`,
});

A shader that brings its own colour. Set color: true and write sceneColor(vec2 p, float t) returning vec4(rgb, coverage) with rgb already premultiplied. The volume variants work this way: they integrate a density field along the view ray, so the colour has to vary inside the body and cannot come from a single ink value. Such variants ignore the CSS color property - it is only used for the glass highlights.

The volume shaders also carry two things usually reserved for offline renderers, both done in the single pass the kit allows:

  • Light scattering. Each sample is dimmed by how much of the body sits between it and the light. That path is not marched a second time - for a field whose density falls off with radius, the chord from the sample to the surface is a good stand-in, and it costs one square root instead of a nested loop. The result is a lit side and a shadow side that stay put while the body turns, because the light lives in view space and only the density field rotates.
  • Depth of field. Samples away from the focal plane are read at a lower noise frequency, with less contrast and a wider falloff at the fibre tip. That is what a lens does to fine detail: it spreads it and takes its contrast away. Costs nothing but three interpolations per sample.
registerGLVisual('duo', {
  name: 'Duo', tech: 'Two-tone disc', fallback: 'ring', cat: 'gl', color: true,
  glsl: `
vec4 sceneColor(vec2 p, float t){
  float a = fill(length(p) - 0.5);
  vec3 c = mix(vec3(0.3, 0.8, 1.0), vec3(1.0, 0.4, 0.8), p.y * 0.5 + 0.5);
  return vec4(c * a, a);
}`,
});

Two optional fields on a GL definition raise its frame budget above the shared one, for variants whose motion is slow enough that 30 fps reads as stepping:

| Field | Default | What it does | | --- | --- | --- | | fps | config.fps (30) | frame budget for this variant at normal size | | fpsSmall | config.fpsSmall (20) | frame budget below config.smallSize |

The volume variants set fps: 60, fpsSmall: 40. Use this sparingly: it is the one place in the kit where a variant can decide to cost twice as much.


Performance

The whole page shares one requestAnimationFrame loop, and the loop only computes what is visible:

  • instances outside the viewport (with a 120 px margin) are skipped,
  • the loop stops completely when the tab is hidden or the window loses focus,
  • each instance has its own frame budget: config.fps, or config.fpsSmall below config.smallSize,
  • handle.setPaused(true) drops an instance out of the loop while keeping its last frame on screen,
  • stats() returns { ticking, visible, running } for a quick look at what is actually running.

WebGL variants share a single context for the whole document (browsers cap it around 16), render to an offscreen buffer and blit the result. A shader program is compiled once per variant, no matter how many instances use it.


Browser support

Modern browsers with canvas 2D. WebGL variants need WebGL2 and fall back to their 2D counterparts when it is missing, so nothing breaks without it. prefers-reduced-motion: reduce is respected: instances draw a single frame and never enter the loop.


Development

The repository also holds the gallery you see at waitstate.vercel.app.

npm install
npm run dev         # gallery at localhost:3000
npm run build       # gallery build, output in dist/
npm run build:lib   # library build, output in lib/

| Layer | Files | Dependencies | | --- | --- | --- | | Kit | src/loader-kit.js, src/loader-kit-gl.js | none, plain ES modules | | Export | src/export-core.js | mp4-muxer, fix-webm-duration, @jsquash/avif, all loaded lazily | | Gallery | src/app/*, src/components/ui/* | React 19, Tailwind 4, shadcn/ui, DialKit |

The gallery exports any variant to PNG, WebP, AVIF and MP4, with aspect presets, a freeze frame for stills and a computed seamless loop for video. It renders a separate instance at the target resolution rather than scaling the preview.

License

MIT