Dust
A lightweight, canvas-based particle effects engine. Dust owns the simulation — you own the pixels.
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.
Physics / visuals split
Dust never touches color, shape, rotation, or opacity. That's entirely the renderer's job.
Frame-rate independent
Speed and gravity are defined against a fixed reference rate, not the display's actual refresh rate.
Scoped to any element
Run against the whole window, or hand Dust a root and it sizes and tracks that
element instead.
Pooled particles
Dead particles are recycled, not reallocated — bursts stay cheap even under heavy reuse.
Optional fps cap
Throttle simulation/render frequency for cost, without changing how fast anything actually moves.
Five built-ins, one pattern
Confetti, snow, heart, text/emoji, and image renderers — all worked examples for your own.
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
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.
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. |
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.
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,
});
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 */ });
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. |
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.
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;
}
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.
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.
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.
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.
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.
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.
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.
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. |