@zakkster/lite-confetti
v1.28.0
Published
Deterministic confetti engine with OKLCH colors, 5 shapes, named presets, palette import, pointer-follow spray, reduced-motion support, and timeline composability.
Downloads
1,086
Maintainers
Readme
@zakkster/lite-confetti
Deterministic confetti engine with OKLCH colors, 5 built-in shapes plus custom
registerShape() shapes (vector or image sprite), per-particle multi-shape mixing,
tunable flutter, lateral wind, turbulence + gust (living-air forces), a vortex/attractor
point force, a full bounding box (floor, walls, ceiling) with bounce, settle-and-pile,
zero-GC motion trails, color-over-life ramps, spawn emitter shapes (line / ring / box),
staggered emission (a burst cascades in over a ms window), velocity-aligned orientation
(pieces bank broadside to their flight, like leaves), tunable tumble speed (slow drift to
reverse spin), size-over-life ramps (shrink-out / bloom), tunable wobble speed (lazy flutter
to fast shimmer), birth-opacity ramps (materialize-in), and reduced-motion support.
The confetti library that canvas-confetti wishes it was.
Why lite-confetti?
| Feature | lite-confetti | canvas-confetti | react-confetti | party.js | |---|---|---|---|---| | Deterministic (seeded) | Yes | No | No | No | | OKLCH colors | Yes | No | No | No | | Reduced motion | Yes (auto) | No | No | No | | Shapes | 5 built-in + custom (registerShape: vector or sprite) | 2 | 2 | 3 | | Spray mode | Yes | No | No | No | | Shared ticker | Yes | Own RAF | Own RAF | Own RAF | | SoA flat arrays | Yes | No | No | No | | Timeline composable | Yes | No | No | No | | Zero-GC hot path | Yes | No | No | No | | ResizeObserver | Yes | window.resize | No | No | | Bundle size | < 4KB | ~6KB | ~5KB | ~8KB |
Installation
npm install @zakkster/lite-confettiQuick Start
One-Liner (Fire and Forget)
import { confetti } from '@zakkster/lite-confetti';
// Creates overlay canvas, fires burst, cleans up automatically
confetti();Full Control
import { createConfetti } from '@zakkster/lite-confetti';
const c = createConfetti(overlayCanvas, { seed: 42 });
c.burst({ count: 80, spread: 1.2, shape: 'star' });
c.burst({ x: 200, y: 100, shape: 'emoji', emoji: '🎊', count: 30 });
// Later
c.seed(42); // reset for deterministic replay
c.destroy();Full Options Reference
Burst Options
Every parameter is optional. Sensible defaults produce a beautiful upward confetti burst.
| Option | Type | Default | Description |
|---|---|---|---|
| x | number | canvas center | Burst origin X position (CSS pixels) |
| y | number | canvas height × 0.33 | Burst origin Y position (CSS pixels) |
| count | number | 80 | Number of particles to spawn |
| spread | number | 1.2 | Emission cone width in radians (π = half-circle) |
| speed | number | 400 | Initial particle speed center (px/s) |
| speedVariance | number | 200 | Speed randomness range. Actual speed: speed ± speedVariance |
| gravity | number | 600 | Downward acceleration in px/s². Higher = falls faster. |
| wind | number | 0 | Lateral acceleration in px/s² — the sideways mirror of gravity. Positive drifts right, negative left. Opt-in; 0 = straight down. See Wind. |
| floor | number | Infinity | Settle-boundary Y in CSS px. Particles that reach it land on the line instead of falling forever. Opt-in; Infinity = no floor. See Floor. |
| bounce | number | 0 | Restitution 0–1 on any boundary contact (floor and walls alike): 0 rests (pile-up), 1 is perfectly elastic. Shared by the whole bounding box. |
| settle | number | 0 | Rest-speed threshold in px/s. A piece whose post-bounce speed drops below it freezes on the floor and piles up (keeps aging + fades). Opt-in; 0 = off. Needs a floor. See Settle & pile. |
| friction | number | 0 | Tangential floor drag 0–1. On each floor-contact frame, the horizontal velocity is bled by 1 - friction (the complement to bounce, which reflects the vertical component): 0 = frictionless (default), 1 = full grip (horizontal stop on contact), 0.2 = a long skid. Opt-in; needs a floor. A physics knob (it moves the position fingerprint). See Friction. |
| wallFriction | number | 0 | Tangential drag 0–1 on the box's three non-floor edges — the tangential twin of friction (which covers the floor) and the analog to bounce's one shared restitution. On a non-floor-edge contact it bleeds the tangential velocity (the ceiling damps horizontal vx, each wall damps vertical vy — always the component bounce does not reflect there, so it never cancels the rebound). Opt-in; needs a box. A physics knob (it moves the position fingerprint). See Friction. |
| wallLeft | number | -Infinity | Left wall X in CSS px — the X-min edge of the bounding box. Particles reaching it clamp and reflect vx. Opt-in; -Infinity = no wall. |
| wallRight | number | Infinity | Right wall X in CSS px — the X-max edge. Opt-in; Infinity = no wall. |
| ceiling | number | -Infinity | Ceiling Y in CSS px — the Y-min edge, the mirror of floor. Particles rising past it clamp and reflect vy. Opt-in; -Infinity = no ceiling. |
| drag | number | 0.98 | Per-frame velocity retention, clamped to 0–1. 0.98 = 2% speed loss per frame. |
| sizeMin | number | 5 | Minimum particle width in CSS pixels |
| sizeMax | number | 12 | Maximum particle width in CSS pixels |
| lifeMin | number | 1.5 | Minimum particle lifetime in seconds |
| lifeMax | number | 3.0 | Maximum particle lifetime in seconds |
| shape | string | 'rect' | 'rect', 'circle', 'star', 'triangle', 'emoji', or a name from registerShape(). Unknown names fall back to 'rect'. |
| shapes | string[] | — | Mix multiple shapes in one burst, chosen per particle. Repetition weights the mix. Overrides shape; unknown names are dropped. See Mixing shapes. |
| emoji | string | party popper | Emoji character (only used when shape is 'emoji') |
| flutter | number | 1 | Tumble depth, 0–1. 1 = full wobble (classic), 0 = rigid. Affects scale only, never position. |
| sway | number | 0 | Horizontal drift, 0–1. 0 = straight fall; higher values sway side-to-side like real paper. |
| swayRate | number | 1 | Sway swing frequency — the speed knob to sway's depth (the mirror sway:swayRate :: flutter:flutterRate). 3 swings three times as fast, 0.3 a long lazy drift, 0 freezes the phase at each piece's random birth tilt (a per-particle constant lean, not inert — contrast gustRate:0, which collapses to sin(0) = 0), a negative reverses the swing. Coerced with num (signed — a frequency has a direction): non-finite → 1 (off); no upper cap. Inert when sway is 0 (read only inside the sway != 0 branch, so a sway-off burst is byte-identical for any value). A physics knob (it moves the position fingerprint) that reads tilt/tilt0 read-only and writes nothing but x, so turbulence and flutterRate stay byte-identical. Honored by burst() and spray(); zero-rng; inert under reduced motion. |
| turbulence | number | 0 | Per-particle rotating acceleration in px/s² — organic wander, so a burst fans out and mills. Opt-in; 0 = none. Draws no rng. See Living air. |
| gust | number | 0 | Global oscillating horizontal acceleration in px/s² layered on wind — the whole burst swells side to side in ~3s waves. Opt-in; 0 = none. See Living air. |
| gustRate | number | GUST_HZ | Gust swell frequency — the speed knob to gust's depth (the mirror gust:gustRate :: flutter:flutterRate). 6 triples the swell rate (a fast breeze), 0.5 a long ocean roll, 0 freezes the phase (inert — the gust force collapses to sin(0) = 0), a negative reverses the phase (the breeze leans the other way first). Default GUST_HZ (= TAU/3, today's baked ~3s swell). Read only inside the gust != 0 branch, so a gust-off burst is byte-identical for any value. Coerced with num (signed — a frequency has a direction): non-finite → the default (off); no upper cap. A physics knob (it moves the position fingerprint). Honored by burst() and spray(); zero-rng; inert under reduced motion. See Living air. |
| attract | number | 0 | Vortex radial spring strength (1/s², scaled by distance): + pulls toward the center, − repels. Opt-in; 0 = none. See Vortex. |
| swirl | number | 0 | Vortex tangential strength (1/s²): spins particles around the center; sign = spin direction. Opt-in; 0 = none. See Vortex. |
| attractX | number | burst x | Vortex center X (CSS px). Defaults to the burst origin. |
| attractY | number | burst y | Vortex center Y (CSS px). Defaults to the burst origin. |
| trail | number | capacity | Per-particle trail length 0..capacity — how many recent positions this burst's ribbon spans. Needs a construction trail budget; ignored without one. 0 opts a burst out. See Motion trails. |
| colors | Array | 7 OKLCH defaults | Array of OKLCH objects { l, c, h } or CSS strings |
| lifeColors | Array | — | Multi-stop OKLCH life ramp (≥ 2 stops, birth-color first). The body sweeps it as each piece ages (sparks cooling white→red); the trail stays the flat colors pick. Opt-in; invalid falls back to flat. See Color over life. |
| emit | string | — | Spawn-origin shape: 'line' (horizontal curtain), 'ring' (firework shell, velocity radial-outward), or 'box' (square area), sized by emitSize. Default: a single point. Opt-in; unknown / emitSize ≤ 0 = point spawn. See Emitter shapes. |
| emitSize | number | — | Emitter extent in px: line half-length / ring radius / box square half-extent. Needs a shape in emit; ≤ 0 or non-finite = point spawn. |
| stagger | number | — | Staggered-emission window in ms: spread the count births evenly over it (piece i wakes at stagger·i/count), so the burst cascades in instead of appearing at once. Burst-only (a spray already emits over time; it ignores stagger). Opt-in; off / ≤ 0 / non-finite = synchronous spawn, byte-identical. See Staggered emission. |
| align | number | 0 | Velocity-align blend 0..1: rotate each piece broadside to its live velocity (its flat face meets the airflow, like a leaf), 0 = pure random spin, 1 = fully locked. Coerced to [0, 1]. A pure orientation overlay — the seeded position stream is byte-identical off or on. Honored by burst() and spray(). See Velocity-aligned orientation. |
| spinRate | number | 1 | Tumble-speed multiplier on the seeded random spin: 1 = as seeded, 0 = rigid at the random birth tilt, 0.3 = slow drift, 2 = double, negative = reverse. Coerced with num (non-finite → 1; 0 and negatives are valid). A pure render-orientation overlay — the seeded position stream is byte-identical off or on, even with turbulence armed. Honored by burst() and spray(). See Tumble speed. |
| spinDrag | number | 1 | Angular-velocity retention in 0–1 — the angular mirror of the linear drag. Each frame, before the spin advance, spinV *= spinDrag, so the tumble decays exactly as drag decays translation. 1 = off (tumble forever, today's look), 0.95 = settle to a lazy drift, 0 = freeze at the birth angle. Coerced with clamp01 (like drag/bounce): a negative clamps to 0 (freeze — a retention has no direction, unlike spinRate's reverse); non-finite → 1. A contraction (\|spinV\| never grows; no accel cap). Damps spinV only, never tiltV. A hybrid physics knob: with turbulence off it moves only the render rotation (position byte-identical), with turbulence on the curl reads the slower spin and it moves the position fingerprint — not a pure render overlay. Honored by burst() and spray(). See Spin drag. |
| scaleTo | number | 1 | Size-over-life target: lerp each piece's rendered size from scaleFrom at birth to scaleTo at death (isotropic, both axes). 0.2 shrinks out, 2 grows/blooms, 0 vanishes at death; 1 = constant size. Coerced with nonneg: a negative clamps to 0 (a size has no direction — not a mirror flip, not a fallback to 1); non-finite → 1. A pure render-scale overlay folded into flutter's ctx.scale — pool.w/pool.h untouched, so the seeded position stream is byte-identical off or on; the trail keeps its birth width. Honored by burst() and spray(). See Size over life. |
| scaleFrom | number | 1 | Size-over-life origin: the birth endpoint of the ramp scaleTo targets, turning the one-endpoint 1 → scaleTo ramp into a two-endpoint scaleFrom → scaleTo envelope — s = scaleFrom + (scaleTo − scaleFrom) × (1 − lifeT), isotropic. 0.2 blooms up from a fifth-size, 2 starts double and settles, 1 = today's look (the ramp starts at birth size); scaleFrom == scaleTo is a constant size multiplier. Coerced with nonneg: a negative clamps to 0 (born invisible — a size has no direction, not a fallback to 1); non-finite → 1. Folded into the same single ctx.scale as scaleTo — pool.w/pool.h untouched, so the seeded position stream is byte-identical off or on; the trail keeps its birth width. Honored by burst() and spray(). See Size over life. |
| flutterRate | number | 1 | Tumble-wobble speed multiplier on the seeded flutter (the speed knob to flutter's depth): 1 = as seeded, 0 = frozen at the random birth tilt, 0.3 = slow lazy flutter, 2 = fast shimmer, negative = reversed. Coerced with num (0 and negatives valid; non-finite → 1). Inert when flutter is 0 (a zero-depth wobble has no speed). A pure render-phase overlay about a birth pivot that never touches pool.tilt — the seeded position stream is byte-identical off, on, or on-with-turbulence. Honored by burst() and spray(). See Wobble speed. |
| fadeIn | number | 0 | Birth-opacity ramp: fade each piece up from transparent over the first fadeIn fraction of its life (0.4 = ease in over the first 40%, 1 = ramp across the whole life; 0 = instant-on). The mirror of the hardcoded death fade-out, multiplying the same alpha. Coerced with clamp01 (non-finite/negative → 0 off, > 1 → 1). A pure render overlay on ctx.globalAlpha that never touches pool.x/y/vx/vy — the seeded position stream is byte-identical off or on (and rotate/scale/stroke/color too). The trail materializes in with the body. Honored by burst() and spray(); inert under reduced motion. See Birth opacity. |
| fadeOut | number | 0.3 | Death-opacity window: the fraction of life over which each piece dissolves out at the END. 0.6 fades over the last 60% (a long gentle dissolve), 0.1 a quick blink-out, 0 a hard cut (full opacity then gone), 1 across the whole life; default 0.3 = today's exact look. The mirror of fadeIn, multiplying the same alpha to compose the full opacity envelope. Coerced with clamp01 against a Math.fround(0.3) sentinel (non-finite/undefined → the 0.3 default; negative → 0 = hard cut, not a fallback to the default; > 1 → 1). A pure render overlay on ctx.globalAlpha that never touches pool.x/y/vx/vy — the seeded position stream is byte-identical at the default or off (and rotate/scale/stroke/color too). The trail dissolves out with the body. Honored by burst() and spray(); inert under reduced motion. Reuses fadeIn's determinism probe with no harness change. |
| angle | number | -Math.PI / 2 | Center angle of emission cone in radians. -π/2 = upward. |
| onComplete | Function | — | Called when all burst particles have died |
Inputs fail closed. Every numeric option is sanitised before it can reach a particle: a non-finite value (NaN/Infinity, or a stray non-number) coerces to its default, drag clamps to 0–1, and a null/empty colors falls back to the defaults. A call-time typo degrades gracefully — it never throws mid-animation or paints a NaN position — the same fail-closed stance as an unknown shape name falling back to 'rect'. (registerShape is the one exception: a bad shape definition throws, because it is setup, not a per-call tunable.)
Spray Options
Spray accepts all burst options plus:
| Option | Type | Default | Description |
|---|---|---|---|
| duration | number | 1000 | Spray duration in milliseconds |
| rate | number | 5 | Particles spawned per frame |
| followPointer | boolean | false | Stream follows the live pointer inside this canvas (see below) |
createConfetti Options
| Option | Type | Default | Description |
|---|---|---|---|
| seed | number | Date.now() | RNG seed for deterministic output |
| maxParticles | number | 500 | Pool size (fixed ring buffer). When full a new spawn is dropped — existing pieces live out their life + fade — rather than overwriting a live one (v1.26.0). A full continuous spray wants maxParticles ≥ rate × 60 × avgLife; below that, excess spawns drop gracefully (never a pop). 500 fits typical bursts. |
| respectReducedMotion | boolean | true | Honor prefers-reduced-motion: reduce |
| trail | number | 0 | Motion-trail capacity: ring-buffer depth for the per-particle ribbon. 0 = off (no buffers). Sized once here (zero-GC); capped at 64. See Motion trails. |
Sizing the pool for sprays. The pool is a fixed ring buffer of maxParticles slots. A one-shot burst() just needs maxParticles ≥ count. A continuous spray() keeps a steady-state population alive of roughly rate × 60 × avgLife (with avgLife = (lifeMin + lifeMax) / 2) — size the pool to that for a fully dense stream. Below it, the pool saturates and each new spawn is dropped (existing pieces finish their life + fade), so a long spray thins gracefully instead of cutting live pieces off mid-flight (fixed in v1.26.0). This is the zero-GC counterpart to object-per-particle engines: they never drop a piece but allocate without bound; lite-confetti stays at 0 B/frame and caps the live set, so you pick the ceiling. Example: spray({ rate: 15, lifeMin: 1.2, lifeMax: 2.5 }) wants ≈ 15 × 60 × 1.85 ≈ 1665 slots for zero drops.
Particle Physics Pipeline
Every frame, each alive particle runs through this pipeline:
-- SPAWN origin = EMIT ? point-on(line | ring | box, emitSize) : (x, y) (opt-in, at BIRTH; ring also fires velocity radially outward)
0a. UNBORN? if STAGGER delayed this piece's birth, skip steps 0b–13 entirely (opt-in; frozen + invisible, life not yet counting, until its delay elapses)
0b. FROZEN? if the piece has SETTLED (landed on the floor), skip steps 1–13 entirely (opt-in; it still ages, fades, and draws)
1. GRAVITY vy += gravity × dt (downward acceleration)
2. WIND vx += wind × dt (opt-in lateral acceleration)
3. TURBULENCE vx += cos(p) × turb × dt, vy += sin(p) × turb × dt (opt-in, p = tilt+spin phase)
4. GUST vx += sin(elapsed × GUST_HZ) × gust × dt (opt-in, global oscillating wind)
5. VORTEX vx += (attract·rx − swirl·ry) × dt, vy += (attract·ry + swirl·rx) × dt (opt-in, r = center − pos; capped)
6. DRAG vx *= drag, vy *= drag (air resistance)
7. POSITION x += vx × dt, y += vy × dt
8. FLOOR if y > floor: y = floor, vy = −vy × bounce (opt-in, box Y-max)
└─ SETTLE: if |vy| < settle, freeze the piece here (landed = true) (opt-in; bounce-then-rest)
9. CEILING if y < ceiling: y = ceiling, vy = −vy × bounce (opt-in, box Y-min)
10. SWAY x += sin(tiltPhase) × sway × dt (opt-in horizontal drift)
11. WALLS if x < wallLeft: x = wallLeft, vx = −vx × bounce (opt-in, box X-min)
if x > wallRight: x = wallRight, vx = −vx × bounce (opt-in, box X-max)
12. SPIN rotation += spinVelocity × dt
13. TILT tiltPhase += tiltSpeed × dt
14. OPACITY fade to 0 in last 30% of life
15. TRAIL record (x,y) in the ring → stroke the ribbon through recent positions (opt-in RENDER overlay — world-space stroke, touches no physics state)
16. RENDER translate → rotate → flutter-scale → draw shape
└─ COLOR: body fillStyle = lifeColors ? ramp[lifeFraction] : flat colors[i] (opt-in; pure color overlay)Steps 1–13 are physics (they mutate x/y/vx/vy); steps 14–16 are rendering. trail (step 15) is the first purely-render feature: it reads the position but draws a stroked polyline in world space (never translate), so it cannot move the determinism fingerprint — every committed physics hash is preserved at any trail depth. lifeColors (the COLOR sub-step of 16) is the second: it only chooses which pre-baked color string the body paints, touching no position, so it too preserves every physics fingerprint. Step 0 is the first behaviour feature: once a piece has settled it is landed, so the whole physics block is skipped and it lies frozen — but it keeps ageing and drawing, so nothing else about the pipeline changes. The SPAWN line runs once at birth, before the per-frame pipeline: emit (the first emission-geometry feature) chooses the origin — a point, or a point on a line / ring / box — and for a ring also aims the launch velocity radially outward. With emit off it is exactly the point (x, y), so the seeded stream is untouched.
Rotation & 3D Tumbling
Each particle has two rotational properties:
Spin — continuous rotation around the particle's center. Angular velocity is randomized at spawn: (rng.next() - 0.5) * 10 radians/second. This produces particles spinning between -5 and +5 rad/s — some clockwise, some counterclockwise, all at different speeds.
Tilt — a wobble phase that drives a cosine-based X-scale oscillation. The flutter option sets its depth: wobbleScale = 1 − flutter × 0.5 × (1 − |cos(tiltPhase)|). At flutter: 1 (the default) this is the classic 0.5 + |cos| × 0.5, making particles "flip" like a thin piece of paper turning in space; at flutter: 0 they stay rigid. Tilt speed is randomized between 1 and 5 rad/s per particle. Because flutter scales only, it never moves a particle — output stays byte-identical under a fixed seed regardless of its value.
Sway — the opt-in sway option adds a horizontal drift driven by the same tilt phase, so pieces drift side-to-side as they fall (real confetti rarely falls straight). It defaults to 0, which keeps the exact straight-fall positions of earlier versions.
The combination of spin rotation + flutter wobble (+ optional sway) produces the realistic confetti tumbling you see in the real world.
Wind / lateral drift
wind (added in v1.5.0) is a lateral acceleration in px/s² — the sideways mirror of gravity. Where sway oscillates around the fall line and nets to zero, wind is a sustained force: the whole burst drifts. Because it shares gravity's units, gravity (down) and wind (across) read as one 2D force vector.
c.burst({ wind: 300 }); // a burst slanting to the right
c.burst({ wind: -200, gravity: 250 }); // drifting left as it falls
c.burst({ ...presets.snow, wind: 60 }); // snow on a gentle breezeWind is applied before drag, so — exactly like gravity's terminal fall speed — a particle approaches a terminal lateral velocity rather than accelerating forever. It defaults to 0 and the integrator skips the term entirely at 0, so a default burst does no extra work and its seeded positions stay byte-identical (the committed determinism fingerprint is preserved). A windy burst draws no random values, so it too replays identically under a fixed seed. Wind has no effect under reduced motion (the static render has no velocity to push).
Floor / settle & bounce
Where gravity and wind set the force, floor (added in v1.6.0) sets the boundary: a settle line, given as an absolute Y in CSS px, that a falling particle lands on instead of dropping forever. bounce is its restitution — 0 rests the piece on the line (confetti piles up), 1 reflects its vertical velocity perfectly (a lively rebound), anything between damps out.
c.burst({ y: 0, floor: innerHeight - 20 }); // confetti settles at the bottom
c.burst({ y: 0, floor: innerHeight - 20, bounce: 0.5 }); // …and bounces on the way down
c.spray({ floor: 400, bounce: 0.3, duration: 1500 }); // a spray that piles up on a shelfOn contact the particle is clamped onto the floor and its vy is reflected, scaled by bounce. Restitution is clamped to 0–1 so a rebound can never add energy, and drag still damps vy every frame, so even bounce: 1 loses energy and eventually rests — never a runaway. floor defaults to Infinity, and the integrator's collision branch (if (y > floor)) can never fire at that default, so a floor-less burst does no extra work and its seeded positions stay byte-identical (the committed determinism fingerprint is preserved). Like wind, the collision draws no random values, so a floored burst replays identically under a fixed seed. Floor has no effect under reduced motion (the static render does no integration to collide).
Bounding box
wallLeft, wallRight, and ceiling (added in v1.7.0) are the three remaining edges that complete floor into a full axis-aligned bounding box — an X-min wall, an X-max wall, and a Y-min ceiling (the mirror of the floor Y-max). Each is an absolute CSS-px coordinate; a particle reaching an edge is clamped onto it and the perpendicular velocity component reflected. Restitution reuses the same bounce, so the whole box shares one bounciness.
const w = innerWidth, h = innerHeight;
c.burst({ x: w / 2, y: h / 2, ceiling: 0, floor: h, wallLeft: 0, wallRight: w, bounce: 0.4 });
// a burst that stays fully inside the viewport, bouncing off every edgeEach edge defaults to an infinity sentinel (-Infinity for wallLeft/ceiling, Infinity for wallRight), and its guard (x < wallLeft, x > wallRight, y < ceiling) can never fire at that default — so a box-less burst does no extra work and its seeded positions stay byte-identical. Both committed fingerprints — the default and the v1.6.0 floored — are preserved. The wall clamp runs after sway (the frame's last horizontal move), so a swaying particle is still contained. Like the floor, the box draws no random values (a boxed burst replays identically under a fixed seed), bounce stays clamped so no edge can add energy (an elastic particle in a tight box never escapes), a degenerate inverted box clamps deterministically without a NaN, and the box has no effect under reduced motion.
Living air
Every force so far is constant in time — a fixed gravity, a fixed wind — so a wide fall reads like parallel rain. turbulence and gust (added in v1.8.0) are the first time-varying forces, and they read as moving air:
turbulenceis a per-particle rotating acceleration (px/s²). Each particle's push direction curls at its own rate, so a burst fans out and mills instead of falling in lockstep. Unlikesway(which oscillates on one axis and nets to zero), turbulence drives both axes and genuinely spreads the pool.gustis a global, sinusoidally-oscillating horizontal acceleration (px/s²) layered onwind. The whole burst shares one phase, so it swells one way then the other in ~3s waves — a breeze gusting, not per-particle noise.
c.burst({ turbulence: 400, gust: 250 });
// confetti drifting on living air — each piece wandering, the whole pool breathing side to sideBoth draw zero random values: turbulence is a pure function of the seeded tumble phases the engine already advances, and gust of a shared elapsed-time clock — so a turbulent/gusty burst replays identically under a fixed seed (with its own committed fingerprints in the test suite), while the default 0 keeps every prior fingerprint (default, floored, and box) byte-for-byte unchanged. Both are accelerations applied before drag, exactly like wind, so they damp toward a terminal velocity and never run away; inside a bounding box the edge clamps still hold, so a turbulent burst stays contained. Garbage fails closed to 0; negatives are allowed (they flip direction). Neither has any effect under reduced motion.
Motion trails
Every feature above changes the physics. Trails (added in v1.9.0) are the first purely visual one: each particle leaves a fading ribbon through its recent positions, so a fast burst reads as motion streaks instead of hard dots. Trails are opt-in at construction, because the ribbon needs a fixed ring buffer of past positions — and a zero-GC engine can't grow one lazily, so its depth (the capacity) is set once when you create the instance:
const c = createConfetti(canvas, { trail: 16 }); // capacity: 16 samples of history per particle
c.burst({ count: 120, speed: 500 }); // every particle now trails by default
c.burst({ count: 40, trail: 6 }); // a shorter ribbon for this burst
c.burst({ count: 40, trail: 0 }); // …or none for this oneThe per-burst trail sets the ribbon length (0..capacity); omit it and a trail-capable instance trails at full capacity. On an instance created without a trail budget the per-burst option is simply ignored (fail-closed, no throw).
The ribbon is a single flat-alpha stroke — one uniform-opacity line through the particle's recent positions, so the whole streak stays clearly visible. (A per-segment taper to a transparent tail was tried in 1.9.0 and reverted in 1.10.0: it read as too faint.)
Trails are a pure render overlay. The ribbon is stroked in world space (moveTo/lineTo/stroke) — it never uses translate and never touches x/y/vx/vy, so it cannot perturb the determinism fingerprint: a trailed burst reproduces the exact same committed physics hash as an untrailed one, at any depth. The ribbon geometry is itself deterministic (its own committed strokeHash gate). Storage is a Float32Array ring buffer allocated once at construction (so trail: 0, the default, allocates nothing and is byte-identical to no trails), and recording + stroking are allocation-free on the hot path — verified at ~0 B/frame with a full trailed pool under the torture alloc gate. A garbage capacity fails closed to off or the 64-sample cap; on pool reuse a recycled slot's stale history can never leak (the live sample count resets at spawn). No effect under reduced motion.
Vortex
Every force so far is uniform in space (gravity, wind, gust) or per-particle random (turbulence). A vortex (added in v1.10.0) is the first force aimed at a place — a point that pulls, pushes, and spins the burst around itself, so confetti can collapse into a logo, drain into a hole, or spiral like a galaxy.
c.burst({ attract: 6, swirl: 4 }); // spiral inward around the burst origin
c.burst({ attract: -8, x: cx, y: cy }); // blow the burst apart from its center
c.burst({ swirl: 6, attractX: 400, attractY: 300 }); // orbit a fixed point, no net pullattractis a linear spring: the pull isattract × (center − pos), so it grows with distance and is zero at the center — no1/rsingularity, no NaN. A positiveattractis a damped oscillator that spirals inward (dragbleeds the energy); a negative one repels.swirladds the perpendicular (tangential) component, turning the pull into a spiral; its sign picks the spin direction. Together(attract, swirl)apply the matrix[[attract, −swirl], [swirl, attract]]to the radius vector.attractX/attractYset the center; they default to the burst origin, so a bareattract/swirlspins around where you fired.
Like every force, the vortex draws zero random values — it's a pure function of the particle's own position and the burst center — so a vortexed burst replays identically under a fixed seed (with its own committed fingerprints for attract-only, swirl-only, and both), while the default 0 keeps every prior fingerprint byte-for-byte unchanged. It's applied before drag, so it damps toward the center and never runs away; inside a bounding box the edge clamps still contain it. A negative attract is an unstable anti-spring, so a fail-closed acceleration cap guarantees a repeller can never drive a position to a non-finite value. Garbage fails closed (strengths → 0, center → the burst origin); negatives are valid. No effect under reduced motion.
Settle & pile
Every earlier feature changed how a particle moves or draws. Settle (added in v1.11.0) is the first one that changes how a particle ends: instead of bouncing on the floor forever, a piece comes to rest and piles up — snow settling, ticker-tape drifting on the ground.
c.burst({ floor: 520, bounce: 0.4, settle: 60 }); // fall, bounce a few times, then pile upsettleis a rest-speed threshold in px/s. Each frame a piece bounces on the floor it loses energy (tobounce< 1 anddrag); once the rebound is too weak to lift it — its post-bounce speed drops belowsettle— the piece freezes: velocity zeroed, pinned on the floor line, physics skipped. Withbounce = 0a piece rests on first contact; a higherbouncejust makes it bounce longer before it settles (dragstill bleeds energy each frame), and with nofloornothing settles at all.- It needs a
floor. Settle only ever fires as a piece lands on the floor, so with no floor set nothing settles (fail-closed). - A settled piece keeps ageing and fades in place, then its slot recycles — so the pile is a transient drift that builds and melts, and the fixed particle pool never fills up. (A permanent pile would saturate the pool and block new bursts, so it's deliberately not the default.)
- A frozen piece is truly still: its rotation is frozen too, and lateral forces (
wind,gust,sway) can't nudge it — a pile lies where it landed.
Like every knob, settle draws zero random values (a pure function of the piece's own post-bounce velocity), so a settling burst replays identically under a fixed seed with its own committed fingerprint, and the default 0 keeps every prior fingerprint byte-for-byte unchanged. A garbage threshold fails closed to 0 (off). No effect under reduced motion — the static render never integrates, so nothing lands.
Friction
settle freezes a piece the instant it comes to rest; friction (added in v1.21.0) is the softer, physical in-between — a piece that lands with sideways speed skids and slows along the floor instead of sliding forever. It's the first new physics knob since settle, and the tangential complement to bounce: on a floor contact, bounce reflects the vertical (normal) component while friction bleeds the horizontal (tangent) one.
c.burst({ floor: 520, wind: 700, friction: 0.85 }); // fire sideways, land, skid to a stopfrictionis a coefficient0–1. On every floor-contact frame the horizontal velocity is multiplied by1 - friction:0is frictionless (the default — today's exact behaviour),1is full grip (a horizontal stop on the first contact),0.2a long skid,0.8a short one. Withbounce = 0a piece is in contact every frame, sovxdecays geometrically to a stop; withbounce > 0it bleeds a little speed on each landing.- It needs a
floor. Friction only ever acts as a piece contacts the floor, so with no floor set nothing happens (fail-closed) — the same rule assettle. - It's a physics knob, not a render overlay. Unlike
align/scaleTo/fadeIn/fadeOut, friction changesvx→ position, so a friction burst has its own committed fingerprint; atfriction: 0every prior fingerprint reproduces byte-for-byte (the guard is a plain!= 0, and0is exactly representable, so nothing drifts when off). - Finite by construction. The factor
1 - frictionis always in[0, 1](a negativefrictionclamps to0, never an anti-friction speed-up), so it's a contraction — horizontal speed can only shrink, never grow, and no acceleration cap is needed. Zero random draws; no effect under reduced motion. wallFriction(v1.22.0) extends the same idea to the box's other three edges. On a contact with a wall or the ceiling it bleeds the tangential component by1 - wallFriction— the ceiling damps horizontalvx, each wall damps verticalvy, always the componentbouncedoes not reflect there, so it never cancels the rebound. It is one shared coefficient for the box's non-floor edges (the tangential analog tobounce's single restitution; the ceiling rides along becausebouncenever split the box edge-by-edge). Likefrictionit is a contraction (finite, no acceleration cap), needs a box or no edge fires, and moves the position fingerprint (its own committed hash), while the floor keeps its separatefriction. One subtlety: on a resting box (bounce: 0)wallFrictionis inert — once the normal velocity is killed a piece pins to the edge and the strict guard never re-fires, so there's no tangential speed left to bleed and the committed box fingerprint is byte-identical; the grip bites only once pieces ricochet (bounce > 0) or are re-driven into an edge by sustained wind.
c.burst({ ceiling: 40, wallLeft: 60, wallRight: 740, floor: 560, bounce: 0.6, wallFriction: 0.6, wind: 300 });Spin drag
drag has damped a piece's translation every frame since day one — but its spin was drawn once at birth and tumbled at that rate forever. spinDrag (added in v1.23.0) is the angular mirror: each frame, immediately before the spin advance, spinV *= spinDrag, so the tumble decays exactly as drag decays motion. It's the last unmirrored asymmetry in the integrator closed.
c.burst({ spinDrag: 0.9, turbulence: 300 }); // pieces tumble fast, then settle to a lazy driftspinDragis a retention0–1:1= off (tumble forever, today's exact look),0.95a gentle decay to a lazy drift,0an instant freeze at the birth angle. Coerced withclamp01likedrag/bounce— a negative clamps to0(freeze; a retention has no direction, unlikespinRate's reverse), non-finite →1,> 1→1(which would otherwise amplify spin and diverge). It's a contraction, so|spinV|can only shrink and no acceleration cap is needed. It dampsspinVonly, nevertiltV(the wobble phase feedsswayand the turbulence curl).- It's a hybrid physics knob, not a pure render overlay.
pool.spinis read in exactly two places — the render rotation and the turbulence curl (tilt*1.7 + spin). So withturbulenceoff a slower spin moves only the render rotation and the seeded position stream is byte-identical (onlyrotateHashmoves, its own committed fingerprint); withturbulenceon the curl reads the slower spin, so it moves positions too (a second committed fingerprint on the same turbulence baseline). AtspinDrag: 1every prior fingerprint —rotateHashincluded — reproduces byte-for-byte. Zero random draws; no effect under reduced motion (the static render never advances spin).
Color over life
Until now a piece was painted one flat color from birth to death. lifeColors (added in v1.12.0) lets the body of each piece sweep a multi-stop OKLCH ramp as it ages — a spark cooling white → orange → red, an ember dimming, a firework tail shifting hue.
c.burst({
lifeColors: [
{ l: 0.98, c: 0.02, h: 90 }, // birth: near-white
{ l: 0.72, c: 0.22, h: 60 }, // mid: gold
{ l: 0.40, c: 0.15, h: 30 }, // death: deep orange
],
});lifeColorsis an ordered list of ≥ 2 OKLCH stops, birth-color first, death-color last. Each piece's body color is read from the ramp by its life fraction (birth = first stop, death = last).- Baked once per burst. The ramp is interpolated in OKLCH into a small lookup table of CSS strings (
bakeCssGradient) when the burst fires, so the render loop is a pure array read — no per-frame color math, no allocation. - The trail stays flat. Only the body sweeps the ramp; the motion-trail ribbon keeps drawing the piece's flat
colorspick (the trail is a simple flat overlay). The palettecolorsis still picked per particle — it's the trail color, and the body color whenlifeColorsis off. - All pieces share one ramp. Variety comes from pieces being at different life phases, so a stream reads as a coherent gradient of ages (perfect for sparks).
lifeColors is a pure color overlay: it draws zero random values and moves no particle, so a lifeColors burst replays with the exact same positions as a plain one — every physics fingerprint is preserved byte-for-byte. An invalid or too-short ramp fails closed to the flat color. No effect under reduced motion — the static render paints the flat color.
Emitter shapes
Every burst so far spawned from the single point (x, y). emit (added in v1.13.0) distributes the spawn origin over a shape, sized by the single emitSize scalar — so confetti can rain from a line, expand from a shell, or fill an area.
// A firework shell: pieces fly radially OUTWARD from a ring.
c.burst({ x: 400, y: 300, emit: 'ring', emitSize: 140, speed: 6, count: 160 });
// A rain / snow curtain: spawn along a horizontal line across the top, fall under gravity.
c.spray({ x: 400, y: 0, emit: 'line', emitSize: 300, angle: Math.PI / 2, gravity: 0.4 });emit: 'line'— a horizontal segment centered on(x, y), half-lengthemitSize(a rain / snow curtain).emit: 'ring'— the circle of radiusemitSizearound(x, y). Each piece is fired radially outward from the centre, sospeedbecomes the shell expansion rate andspreadthe angular fuzz — a firework shell.emit: 'box'— the square[x ± emitSize, y ± emitSize](an area burst).- Line and box move only the origin — velocity stays governed by
angle/spread. The radial-outward coupling is ring-only.
emit is a pure origin choice: it is the first knob to draw a random value at spawn (the position along the shape), so it is opt-in by construction — with emit off, unknown, or emitSize ≤ 0, the burst spawns at the point and every committed fingerprint is byte-identical. Each shape has its own deterministic fingerprint when on. Fails closed to a point spawn on a bad shape or size; no effect under reduced motion.
Staggered emission
emit chose where a piece is born; stagger (added in v1.14.0) chooses when. A burst has always spawned its whole count at frame 0; stagger (a duration in ms) spreads those births evenly across the window, so a burst cascades / ripples in instead of appearing all at once.
// A 120-piece burst that pours in over 400ms instead of popping instantly.
c.burst({ x: 400, y: 300, count: 120, stagger: 400 });- Piece
iwakes atstagger · i / countms and then lives its full life from birth — so a late piece outlives the early ones by the width of the window. - Burst-only. This is the burst analog of a spray's
duration; aspray()already emits over time, so it ignoresstagger. - How it stays deterministic. All
countpieces still spawn at call time, drawing the identical rng sequence as a synchronous burst; each is stamped with a no-rng per-index delay, and an unborn piece is frozen and invisible until it elapses. So withstaggeroff (or≤ 0/ non-finite) the burst spawns synchronously and every committed fingerprint is byte-identical; on, it earns its own deterministic fingerprint purely from birth timing. Fails closed; no effect under reduced motion.
Velocity-aligned orientation
For fourteen releases a piece's rotation was only ever random tumble. align (added in v1.15.0, a 0..1 blend) rotates each piece broadside to its live velocity — its flat face square to the airflow, like a falling leaf — re-banking every frame as gravity, wind, or a vortex bend its path.
// Leaves banking into a gentle drift — face-first to wherever the air pushes them.
c.burst({ x: 400, y: 200, count: 80, gravity: 200, wind: 300, spread: 0.4, align: 1 });0is pure random spin (the classic look);1is fully velocity-locked; partial values blend the two along the shortest arc.- Live + broadside. The heading is recomputed from the current velocity each frame and offset by 90° so the broad face meets the direction of travel. Draws no rng.
- A pure orientation overlay.
alignchanges only rotation, never position — so the seeded position stream (and every committed fingerprint) is byte-identical whetheralignis off or on; only the rotation earns its own deterministic fingerprint. Honored by bothburst()andspray(). Fails closed (coerced to[0, 1]); no effect under reduced motion.
Tumble speed
align opened which way a piece faces; spinRate (added in v1.16.0) tunes how fast it tumbles. For fifteen releases the tumble rate was a fixed seeded random, so slow drifting petals, frozen rigid chips, and reverse tumble were all unreachable. spinRate is a plain multiplier on the accumulated tumble.
// Slow, lazy petals drifting down — a third of the seeded tumble rate.
c.burst({ spinRate: 0.3, gravity: 200 });1(default) is the seeded rate as-is;0is rigid — frozen at each piece's random birth tilt (varied, not axis-aligned);0.3is a lazy drift;2doubles the tumble; a negative value reverses it. Coerced withnum(0and negatives are valid; non-finite →1), neverclamp01— a rate multiplier is not a0..1blend.- A pure orientation overlay, turbulence-safe.
spinRateis a render-time angle scale: it scales only the accumulated tumble about the birth orientation and never touches the physics spin the turbulence phase reads. So it is fully decoupled fromturbulence, and the seeded position stream (and every committed fingerprint) is byte-identical whether off, on, or on-with-turbulence — only the rotation earns its own deterministic fingerprint. Composes withalign(the tumble scale runs first, thenalignblends toward the velocity heading). Draws no rng. Honored by bothburst()andspray(); inert under reduced motion.
Size over life
Every render axis had been opened except size: for sixteen releases a piece's size was fixed at birth (pool.w/pool.h drawn once and never changed), so a piece that shrinks away to nothing or an ember that blooms as it dies was unreachable. scaleTo (added in v1.17.0) lerps each piece's rendered size from 1.0 at birth to scaleTo at death, by the same age fraction the lifeColors ramp uses.
// Sparks that shrink out as they cool — scaleTo composes with the color ramp on one life fraction.
c.burst({ scaleTo: 0.1, gravity: 300, lifeColors: ['#fff', '#f80', '#a00'] });
// A bloom: pieces grow as they fade.
c.burst({ scaleTo: 2.5, gravity: 200, shape: 'circle' });
// Pop-in: born small, settle to full size as the birth fade eases in (v1.24.0).
c.burst({ scaleFrom: 0.2, scaleTo: 1.4, fadeIn: 0.3 });1(default) is constant size;0.2shrinks out,2grows/blooms,0vanishes at death.s = scaleFrom + (scaleTo − scaleFrom) × (1 − lifeT), reusing the life fraction already computed for the opacity fade and the color ramp. Coerced withnonneg: a negative clamps to0(a size has no direction — not a mirror flip, not a fallback to1;scaleTo: -2renders likescaleTo: 0), non-finite →1.scaleTo: 0is a legitimate value (the size analog ofspinRate: 0).scaleFrom(v1.24.0) is the birth endpoint — the mirrorscaleFrom:scaleTo :: fadeIn:fadeOutthat closes the render-scale axis. Default1leaves the ramp starting at birth size (today's exact look);scaleFrom: 0.2blooms up from a fifth-size,scaleFrom: 2starts double and settles, andscaleFrom == scaleTois an emergent constant multiplier. It supersedes the0018deferral note (which wrongly assumedsizeMin/sizeMaxalready covered a birth-size override — those set the constant birth size, they don't move the ramp's origin). Samenonnegcoercion (a negative is born invisible, not a fallback to1); folded into the same singlectx.scaleasscaleTo.- Isotropic, one
ctx.scale. The factor is applied to both axes and folded into flutter's single existingctx.scalecall — the X-wobble and the size ramp multiply on one transform, never a second call.pool.w/pool.hare never touched. - A pure render overlay. Scale never enters
ctx.translate, so the seeded position stream (and every committed fingerprint) is byte-identical whether off or on — a scaled burst reproduces the same-seed plain burst's position hash exactly (invisible to the rotation and color fingerprints too); only the size fold earns its own deterministic fingerprint. The trail ribbon keeps its birth width (the ramp scales the body, not the streak). Draws no rng. Honored by bothburst()andspray(); inert under reduced motion.
Wobble speed
spinRate tuned how fast a piece tumbles; flutterRate (added in v1.18.0) tunes how fast it wobbles — the speed knob to what flutter opened as depth. flutter sets the depth of the 3D-ish X-scale wobble (wobbleScale = 1 − flutter × 0.5 × (1 − |cos(tilt)|)), driven by the per-particle tilt phase the integrator advances every frame; but its speed (tiltV) was a fixed seeded random, so a slow lazy flutter, a wobble frozen at a chosen tilt, or a fast shimmer were all unreachable. flutterRate is a plain multiplier on the accumulated wobble phase about a stored birth pivot.
// A wobble frozen at each piece's own birth tilt — flutter must be on for a rate to matter.
c.burst({ flutterRate: 0, flutter: 1, gravity: 200 });
// A fast shimmer: the wobble advances twice as quickly.
c.burst({ flutterRate: 2, flutter: 1 });1(default) is the seeded rate as-is;0freezes the wobble at each piece's own random birth tilt (a varied constant per piece, not collapsed to one value);0.3is a slow lazy flutter;2is a fast shimmer; a negative value reverses the phase. Coerced withnum(0and negatives are valid; non-finite →1), neverclamp01— a rate multiplier is not a0..1blend.- Inert when
flutteris 0.flutter(depth) multiplies the whole(1 − |cos|)term; atflutter: 0the wobble is1regardless of the phase, so there is no speed to scale. So aflutterRatedemo always setsflutter: 1. - A pure render overlay, turbulence-safe.
flutterRateis a render-time phase scale about a birth pivottilt0: it scales only the accumulated wobble phase and never touchespool.tilt— the phase theturbulencecurl andswayread. So it is fully decoupled from both, and the seeded position stream (and every committed fingerprint) is byte-identical whether off, on, or on-with-turbulence — only the wobble earns its own deterministic fingerprint (reusing thescaleHashprobe, no new channel). Draws no rng. Honored by bothburst()andspray(); inert under reduced motion.
Birth opacity
For eighteen releases a piece's opacity had exactly one hardcoded behaviour: a death fade-out over the last 30% of life. fadeIn (added in v1.19.0) opens the render-opacity axis — an opt-in scalar that ramps a piece up from transparent over the first fadeIn fraction of its life, so it materializes in. It is the mirror of the death fade, on the same alpha scalar, by the same age fraction (1 − life/maxL) the death-fade and lifeColors already use.
// Materialize in over the first 40% of life, then the built-in death fade takes it out.
c.burst({ fadeIn: 0.4, gravity: 300 });
// Compose with size-over-life: a piece that fades in AND shrinks out.
c.burst({ fadeIn: 0.4, scaleTo: 0.2, gravity: 200 });0(default) is today's instant-on look;0.4eases in over the first 40% of life;1ramps across the whole life. Coerced withclamp01(non-finite / non-numeric / undefined / negative →0off;> 1→1), exactly likealign— a fraction of life, not a rate.- It multiplies the existing alpha. The birth fade-in and the hardcoded death fade-out act on the same
alpha: for a normal life they fall in disjoint windows (in near birth, out near death); for a very short life they overlap and correctly multiply. The death fade-out is unchanged. - The cleanest pure render overlay in the suite.
fadeInchanges onlyctx.globalAlpha— it never touchespool.x/y/vx/vyorctx.translate, draws no rng, and reads only the life fraction, so the seeded position stream (and the rotate/scale/stroke/color fingerprints) is byte-identical whether off or on; only the alpha earns its own deterministic fingerprint. UnlikespinRate/flutterRateit needs no birth pivot and no decoupling machinery — nothing downstream reads alpha. - The trail materializes in with the body. The ribbon already tracks the body
alpha(the death fade dims it too); foldingfadeInin before the trail block makes the streak fade up with the body for free — no independent trail opacity, and the trail geometry fingerprint is untouched. Honored by bothburst()andspray(); inert under reduced motion (the constant static opacity is untouched).
Death opacity
For nineteen releases the other half of the opacity envelope — the death fade — was a magic 0.3 baked into the render body (dissolve over the last 30% of life). fadeOut (added in v1.20.0) parameterizes it: the fraction of life over which a piece dissolves out at the end. Together with fadeIn it completes the opacity envelope — materialize in, hold, dissolve out — both multiplying one shared alpha.
// The full envelope: fade in over the first 20%, dissolve out over the last 90%.
c.burst({ fadeIn: 0.2, fadeOut: 0.9, gravity: 200 });
// A hard cut — full opacity, then gone (no death fade at all).
c.burst({ fadeOut: 0 });0.3(default) is today's exact look;0.6is a long gentle dissolve;0.1a quick blink-out;0a hard cut;1dissolves across the whole life. Coerced withclamp01against aMath.fround(0.3)sentinel — non-finite / undefined → the0.3default; negative →0(a hard cut, not a fallback to the default),> 1→1.- The
Math.fround(0.3)sentinel is the whole off-identity story. The default0.3lives in aFloat32Array, andMath.fround(0.3) !== 0.3(the round-trip is lossy), so the render guard compares against the frounded sentinel and leaves the original double-0.3death-fade line byte-for-byte unchanged. At the default the guard never fires, so the committed opacity fingerprint is preserved bit-for-bit. (ContrastscaleTo/scaleFrom/spinDrag, whose default1is Float32-exact —Math.fround(1) === 1— so their!= 1guards need no sentinel.) - Always written at spawn — load-bearing here. Unlike
fadeIn(whose zero-init0happens to mean "off"), a zero-init0forfadeOutmeans "hard cut, no death fade" — a wrong default — so the unconditional spawn write is a fail-closed requirement, not just house style. - Reuses
fadeIn's probe with no harness change. As the second knob on the opacity axis,fadeOutrides the exact determinism probe v1.19.0 built — the first render feature to cost no new test-harness plumbing. The trail dissolves out with the body; inert under reduced motion.
Canvas Sizing
lite-confetti uses ResizeObserver (not polling) to track canvas dimensions. The observer watches the canvas's parent element, RAF-deduped to prevent double-fire. clientWidth / clientHeight are never read in the hot loop — only cached cw / ch variables are used during rendering. This prevents layout thrashing at 60fps.
Shapes
| Shape | Description |
|---|---|
| 'rect' | Classic confetti rectangle (default). Height varies 40–100% of width for natural variation. |
| 'circle' | Round confetti dots |
| 'star' | 5-pointed star with 40% inner radius |
| 'triangle' | Equilateral triangle piece |
| 'emoji' | Any emoji character — set via emoji option (e.g. '🌟', '🎊', '❤️') |
Emoji shapes are rendered through a glyph atlas: each unique emoji is rasterized once to a small offscreen canvas the first time it is used, then drawn per particle as a cheap drawImage blit. This keeps a burst of hundreds of emoji particles as cheap as any other shape. (Earlier versions set ctx.font and called fillText per particle per frame, which re-rasterized the colour glyph every time — a burst of many emoji could stall the main thread.)
Custom shapes — registerShape(name, def)
Register your own shape on an instance, then use it as burst({ shape: name }). Shapes are per-instance: they are invisible to other instances and released on destroy(), so registration never leaks across instances and determinism stays sealed to the seed.
const c = createConfetti(canvas);
// 1. A VECTOR shape — a draw function. The engine sets fillStyle to the particle's
// colour before calling, so a plain fill() is coloured for you. Drawn centred at (0,0).
c.registerShape('heart', (ctx, w) => {
const s = w / 16;
ctx.beginPath();
ctx.moveTo(0, 4 * s);
ctx.bezierCurveTo(-7 * s, -3 * s, -3 * s, -8 * s, 0, -3 * s);
ctx.bezierCurveTo(3 * s, -8 * s, 7 * s, -3 * s, 0, 4 * s);
ctx.fill();
});
c.burst({ shape: 'heart', count: 80 });
// 2. An IMAGE SPRITE — prerendered once, then blitted per particle (same fast path as emoji).
const logo = new Image();
logo.src = '/logo.png';
c.registerShape('logo', { image: logo }); // an <img>, a <canvas>, or an ImageBitmap
c.spray({ shape: 'logo', duration: 1500 });registerShape returns the assigned shape id (>= 5; built-ins keep 0–4). Re-registering a custom name replaces it and keeps its id. It fails closed — an empty/non-string name, a built-in override ('rect', 'emoji', …), or a malformed def throws. A typo'd shape name at burst() time does not throw; it falls back to 'rect'.
Mixing shapes
Pass shapes (plural) to mix several shapes in one burst — each particle picks its own, so you get a confetti spread of stars and circles and rectangles without firing three overlapping bursts:
c.burst({ shapes: ['star', 'circle', 'rect'] });
// Repetition weights the mix — this is ~2:1 stars to circles:
c.burst({ shapes: ['star', 'star', 'circle'] });
// Custom registerShape() names compose for free:
c.registerShape('logo', { image: logo });
c.burst({ shapes: ['rect', 'logo', 'star'] });shapes overrides the singular shape. It fails closed the same way everything else does: unknown names are dropped, and an empty / non-array / all-unknown shapes falls back to shape. Mixing is deterministic under a seed and per-instance (a shapes entry naming another instance's custom shape is dropped, never borrowed). Omitting shapes is free — the single-shape path is byte-for-byte unchanged, committed fingerprint and all.
Custom shapes go through the same zero-allocation dispatch as the built-ins — the torture gate proves a live pool of a custom vector shape + an image sprite renders at ~0 bytes/frame.
Presets
Five drop-in configs for iconic effects. Spread them into burst() or spray():
import { createConfetti, presets } from '@zakkster/lite-confetti';
const c = createConfetti(canvas, { seed: 1 });
c.burst({ ...presets.fireworks }); // stars, explosive, upward
c.burst({ ...presets.cannons, x: 0 }); // angled launch — override origin
c.spray({ ...presets.snow, duration: 8000 }); // gentle falling, long life
c.burst({ ...presets.pride }); // OKLCH rainbow palette baked in
c.burst({ ...presets.blast, y: innerHeight }); // max-power cannon from the bottomBecause a preset is just an options object, anything you add after the spread wins — { ...presets.snow, gravity: 200 } keeps the snow look but drops faster. Every preset's shape is one of the five the engine renders, and every preset stays deterministic under a fixed seed (the test suite checks both).
Reach / power
If a burst feels weak — it barely leaves the origin instead of arcing up the screen — the lever is speed, not wind. Vertical reach is speed^2 / (2 * gravity), throttled further by the per-frame drag, so with the default straight-up angle a piece rises about speed^2 / (2 * gravity) pixels before falling back. The gentle default speed: 400 (with gravity: 600, drag: 0.98) rises only ~160px; to clear half a 1080-tall screen from the bottom you want speed around 1200-1500, and a looser drag (e.g. 0.99) lengthens the arc. wind is a purely horizontal force (the X-mirror of gravity), so it drifts a burst sideways but adds zero vertical reach — combining speed with wind reaches exactly as high as speed alone. presets.blast is the ready-made half-screen cannon: c.burst({ ...presets.blast, x, y: innerHeight }) peaks at ~1025px of rise on a 1080 screen.
Palette import (lite-hueforge)
colorsFromPalette() turns a lite-hueforge toGradientStops() result straight into a colors array:
import { colorsFromPalette } from '@zakkster/lite-confetti';
import { toGradientStops } from '@zakkster/lite-hueforge';
const stops = toGradientStops(myPalette); // [{ color: {l,c,h}, stop }, ...]
c.burst({ colors: colorsFromPalette(stops), count: 120 });It also accepts a { stops } wrapper, a plain colors array (pass-through), or a single color. It never returns an empty array — an empty colors would make the picker paint nothing, so bad input falls back to the default palette.
Burst from an element
fromElement() reads getBoundingClientRect() once and returns the element's centre as a burst origin:
import { fromElement, presets } from '@zakkster/lite-confetti';
button.addEventListener('click', () => {
confetti(fromElement(button, { ...presets.cannons }));
});Coordinate space matters. The returned x/y are in viewport coordinates — correct as-is for a full-screen overlay canvas (what confetti() creates, and what most apps use). For an inline or offset canvas, subtract that canvas's own rect:
const o = fromElement(button);
const cr = canvas.getBoundingClientRect();
c.burst({ x: o.x - cr.left, y: o.y - cr.top, count: 60 });Pointer-follow spray
spray({ followPointer: true }) makes the stream chase the pointer inside the canvas:
c.spray({ ...presets.fireworks, duration: 4000, followPointer: true });The pointer is tracked by a passive listener that binds only while a follow-spray is running, and unbinds when it ends (or on destroy()). Nothing is installed at import time, so a page that never follows the pointer pays nothing. Coordinates are converted from viewport space into the canvas's own space, so it works for inline and CSS-scaled canvases, not just full-screen overlays.
One caveat, by design: pointer-follow is not deterministic — it injects live pointer positions the seed can't know. It consumes no RNG draw, so a non-following spray still replays identically from a seed; a following one won't.
Recipes
import { confetti } from '@zakkster/lite-confetti';
submitBtn.addEventListener('click', () => {
confetti({
count: 100,
spread: 