Introduction

Dust

A lightweight, canvas-based particle effects engine. Dust owns the simulation — you own the pixels.

Package @gottheflag/dust
Version 0.1.0-beta.1
Modules ESM + CJS
License Apache-2.0

Most particle libraries bundle simulation and visuals into one opinionated shape. Dust splits them: a small physics engine handles position, velocity, gravity, lifetime, and pooling — a Renderer you supply (or pick from the built-ins) decides what a particle actually looks like. Confetti, snow, heart, text, emoji, and images all ship as ordinary renderers, built on the exact same interface you'd use for your own.

01

Physics / visuals split

Dust never touches color, shape, rotation, or opacity. That's entirely the renderer's job.

02

Frame-rate independent

Speed and gravity are defined against a fixed reference rate, not the display's actual refresh rate.

03

Scoped to any element

Run against the whole window, or hand Dust a root and it sizes and tracks that element instead.

04

Pooled particles

Dead particles are recycled, not reallocated — bursts stay cheap even under heavy reuse.

05

Optional fps cap

Throttle simulation/render frequency for cost, without changing how fast anything actually moves.

06

Five built-ins, one pattern

Confetti, snow, heart, text/emoji, and image renderers — all worked examples for your own.

Start

Installation

Dust has one runtime dependency (@gottheflag/lifecycle), targets ES2022, and ships both ESM and CJS builds with full type declarations.

npm install @gottheflag/dust
pnpm add @gottheflag/dust
yarn add @gottheflag/dust
bun add @gottheflag/dust
Start

Quick start

Core engine and effects are separate subpaths — keeps the core import lean if you only need the physics and want to write your own renderer.

import { Dust } from "@gottheflag/dust";
import { ConfettiRenderer } from "@gottheflag/dust/effects";

const dust = new Dust({
  renderer: new ConfettiRenderer(),
});

document.addEventListener("click", (e) => {
  dust.launch({
    origin: { x: e.clientX, y: e.clientY },
    count: 40,
  });
});

That's the whole setup — Dust fills the window with a canvas and starts animating on the first launch(). No manual render loop, no manual resize handling.

Core

Dust

One instance per canvas. Owns the simulation loop, the particle pool, and a Renderer. Create it once, call launch() as many times as you like.

new Dust(options: {
  root?: HTMLElement;   // defaults to the window
  renderer: Renderer;
  fps?: number;          // caps update/render frequency
})
Option Default Description
root window Element the canvas is scoped to. See Root & origin.
renderer required Controls how particles are drawn. See Renderer.
fps uncapped Caps how often the loop actually does work. See fps.

Methods

Method Description
launch(options?) Spawns a burst of particles and starts (or continues) the simulation.
on(event, listener) Subscribe to "start", "update", or "end". Returns an unsubscribe function.
destroy() Stops the loop, clears particles and the pool, tears down the renderer and the canvas.
Multiple launches

Calling launch() while Dust is already running is safe and expected — new particles join the existing simulation rather than starting a second loop. "start" only fires on a genuine idle → active transition, and "end" only fires once every particle from every overlapping launch() call has died.

Core

launch(options)

Spawns count particles at origin, moving outward according to angle, spread, and speed, pulled by gravity, alive for duration milliseconds.

Option Default Description
count 30 Particles to spawn.
origin canvas center Spawn point, { x, y }.
speed 4 Base speed, in px/frame at a 60fps reference rate.
angle 0 Direction in degrees. 0 = right, 90 = down, 180 = left, 270 = up.
spread 360 Width of the angular cone particles are spread across, centered on angle.
gravity 0.1 Downward pull applied each frame.
duration 1000 Lifetime in milliseconds.
dust.launch({
  origin: { x: 200, y: 400 },
  angle: 270,       // straight up
  spread: 40,        // narrow cone
  speed: 6,
  gravity: 0.15,
  count: 60,
  duration: 1500,
});
Core

Events

dust.on("start", () => { /* first launch from idle */ });
dust.on("update", (dt) => { /* every simulated frame, dt in seconds */ });
dust.on("end", () => { /* back to idle — every particle has died */ });
Core

Renderer

The interface every visual effect implements — built-in or your own.

interface Renderer<T = unknown> {
  init?(particle: Particle<T>): void;
  render(
    ctx: CanvasRenderingContext2D,
    particle: Particle<T>,
    dt: number,
  ): void;
  destroy?(): void;
}
Member Called Description
init once, on (re)spawn Set up particle.data — color, size, rotation, anything the effect needs.
render every frame, per particle Draw based on particle.x/y and particle.data.
destroy once, on dust.destroy() Clean up anything allocated outside particle data.
Ownership boundary

Dust owns physics. Renderers own everything visual. Never write to particle.x, y, vx, vy, gravity, or life from a renderer — Dust manages those every frame.

Core

Particle

The simulation's own shape. data is fully yours, typed by your renderer's generic parameter.

interface Particle<T = unknown> {
  x: number; y: number;
  vx: number; vy: number;
  gravity: number;
  life: number;   // ms remaining
  data: T;
}
Physics & layout

Speed, gravity, spread

All physics is computed against real elapsed time, then scaled by a fixed 60fps reference — so speed and gravity mean the same thing whether the display runs at 60Hz or 144Hz, and whether or not you've capped fps.

speed is a base magnitude in px/frame at the reference rate — each particle gets a random 50–100% of it, for natural variation. angle sets the direction in degrees; spread widens that into a cone, centered on angle, rather than acting like a radius or a second speed control. gravity is a constant downward acceleration applied every frame, also reference-rate scaled.

Physics & layout

Root & origin

By default Dust operates against the window: the canvas fills the viewport, and origin defaults to the window's center.

Pass root to scope everything to one element instead — the canvas sizes to that element (tracked live via ResizeObserver), and default origin becomes that element's own center, in its local coordinate space.

const dust = new Dust({
  root: document.querySelector("#hero")!,
  renderer: new SnowRenderer(),
});

Resizes — of the window, or of a custom root — repaint immediately instead of leaving a blank canvas visible until the next animation frame.

Physics & layout

fps

An engine-level cap on how often the simulation/render loop actually does work — independent of particle speed.

new Dust({ renderer, fps: 24 });

The loop still ticks at the display's native refresh rate, but the expensive part — updating physics and redrawing every particle — only runs at the capped rate; skipped ticks do a negligible timestamp check and return. Motion covers the same real-world distance in the same real-world time either way, just with fewer intermediate frames — leave it unset to run uncapped.

Effects

Built-in effects

Five renderers, importable from the /effects subpath, all built on the exact same Renderer interface documented above.

import {
  ConfettiRenderer,
  SnowRenderer,
  HeartRenderer,
  TextRenderer,
  ImageRenderer,
} from "@gottheflag/dust/effects";

ConfettiRenderer

Rotating rectangles in a random color palette.

SnowRenderer

Soft circles that sway side to side as they fall.

HeartRenderer

Rotating heart shapes, random colors.

TextRenderer

Any strings you supply — plain text or emoji.

ImageRenderer

Your own images, aspect ratio preserved.

Fade-out

Every built-in fades over its last ~250ms of life instead of popping out of existence, via a shared fadeAlpha() helper — reuse it in your own renderers.

ConfettiRenderer effect

new ConfettiRenderer(colors?: string[])

Takes an optional color palette; defaults to a six-color set. Each particle gets a random color, size, and rotation speed.

SnowRenderer effect

new SnowRenderer()

No options — radius, opacity, and a sway phase/speed/amplitude are randomized per particle. The sway is drawn purely at render time; it doesn't touch the underlying physics.

dust.launch({ angle: 90, spread: 30, speed: 1, gravity: 0.02, duration: 6000 });

HeartRenderer effect

new HeartRenderer(colors?: string[])

Same shape as Confetti — optional palette, random size and rotation — drawn as a bezier heart path instead of a rectangle.

TextRenderer effect

new TextRenderer(options?: {
  texts?: string[];   // plain text or emoji
  colors?: string[];
  font?: string;
  minSize?: number;
  maxSize?: number;
})

Covers both text and emoji effects with one renderer — fillText renders emoji glyphs natively. Each distinct (text, color, size) combination is rasterized once to an offscreen canvas and reused via drawImage, since color emoji glyphs are expensive to redraw from scratch every particle, every frame.

new TextRenderer({ texts: ["🎉", "+1", "GG"], colors: ["#FFD166", "#06D6A0"] });

ImageRenderer effect

new ImageRenderer(options: {
  images: (string | HTMLImageElement)[];
  minSize?: number;
  maxSize?: number;
})

Accepts URLs or already-constructed HTMLImageElements. Images load asynchronously, so a particle simply skips its draw for any frame where its image isn't decoded yet — no errors, no placeholder flash.

Guides

Building a custom renderer

Any object satisfying Renderer<T> works with new Dust({ renderer }) — it doesn't need to live inside this package.

import { Particle, Renderer } from "@gottheflag/dust";

interface SparkleData {
  radius: number;
}

export class SparkleRenderer implements Renderer<SparkleData> {
  init(particle: Particle<SparkleData>): void {
    particle.data.radius = 2 + Math.random() * 3;
  }

  render(ctx: CanvasRenderingContext2D, particle: Particle<SparkleData>): void {
    ctx.save();
    ctx.globalAlpha = fadeAlpha(particle.life);
    ctx.fillStyle = "#fff";
    ctx.beginPath();
    ctx.arc(particle.x, particle.y, particle.data.radius, 0, Math.PI * 2);
    ctx.fill();
    ctx.restore();
  }
}

Patterns worth copying from the built-ins

Random pick per particle — Confetti, Heart, and Text all take a colors (or texts) array and pick randomly in init. Cheap, and gives visual variety for free.

Options object once there's more than a color list — Text and Image take a constructor options object rather than positional arguments, once there's more than one or two knobs.

Cache expensive draws — Text rasterizes to an offscreen canvas once per distinct look and blits it afterward. Do this for anything expensive to draw repeatedly: complex paths, gradients, color emoji.

Async-safe drawing — Image checks image.complete before drawing, since images load asynchronously. If something your renderer needs isn't ready, skip that frame's draw rather than throwing.

Guides

Particle data & pooling

Dust pools dead particles instead of reallocating on every burst.

Every particle field is explicitly overwritten in launch() before use — including data, which is cleared in place (not reallocated) between reuses, so a renderer never sees stale fields left over from a previous effect or a previous burst. You don't need to write any reset logic yourself; it happens automatically before init runs.

Reference

Exported types

Export From Description
Dust @gottheflag/dust Class. The engine.
Renderer<T> @gottheflag/dust Type. Visual contract for particles.
Particle<T> @gottheflag/dust Type. Simulation state for one particle.
Position @gottheflag/dust Type. { x: number; y: number }.
LaunchOptions @gottheflag/dust Type. Options accepted by launch().
ConfettiRenderer / ConfettiData @gottheflag/dust/effects Class + data type.
SnowRenderer / SnowData @gottheflag/dust/effects Class + data type.
HeartRenderer / HeartData @gottheflag/dust/effects Class + data type.
TextRenderer / TextData / TextRendererOptions @gottheflag/dust/effects Class + data + options types.
ImageRenderer / ImageData / ImageRendererOptions @gottheflag/dust/effects Class + data + options types.