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

@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

Readme

@zakkster/lite-confetti

npm version npm bundle size npm downloads npm total downloads TypeScript License: MIT

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.

→ Live Interactive Playground

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-confetti

Quick 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 breeze

Wind 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 shelf

On 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 edge

Each 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:

  • turbulence is 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. Unlike sway (which oscillates on one axis and nets to zero), turbulence drives both axes and genuinely spreads the pool.
  • gust is a global, sinusoidally-oscillating horizontal acceleration (px/s²) layered on wind. 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 side

Both 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 one

The 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 pull
  • attract is a linear spring: the pull is attract × (center − pos), so it grows with distance and is zero at the center — no 1/r singularity, no NaN. A positive attract is a damped oscillator that spirals inward (drag bleeds the energy); a negative one repels.
  • swirl adds 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 / attractY set the center; they default to the burst origin, so a bare attract/swirl spins 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 up
  • settle is a rest-speed threshold in px/s. Each frame a piece bounces on the floor it loses energy (to bounce < 1 and drag); once the rebound is too weak to lift it — its post-bounce speed drops below settle — the piece freezes: velocity zeroed, pinned on the floor line, physics skipped. With bounce = 0 a piece rests on first contact; a higher bounce just makes it bounce longer before it settles (drag still bleeds energy each frame), and with no floor nothing 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 stop
  • friction is a coefficient 0–1. On every floor-contact frame the horizontal velocity is multiplied by 1 - friction: 0 is frictionless (the default — today's exact behaviour), 1 is full grip (a horizontal stop on the first contact), 0.2 a long skid, 0.8 a short one. With bounce = 0 a piece is in contact every frame, so vx decays geometrically to a stop; with bounce > 0 it 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 as settle.
  • It's a physics knob, not a render overlay. Unlike align/scaleTo/fadeIn/fadeOut, friction changes vx → position, so a friction burst has its own committed fingerprint; at friction: 0 every prior fingerprint reproduces byte-for-byte (the guard is a plain != 0, and 0 is exactly representable, so nothing drifts when off).
  • Finite by construction. The factor 1 - friction is always in [0, 1] (a negative friction clamps to 0, 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 by 1 - wallFriction — the ceiling damps horizontal vx, each wall damps vertical vy, always the component bounce does not reflect there, so it never cancels the rebound. It is one shared coefficient for the box's non-floor edges (the tangential analog to bounce's single restitution; the ceiling rides along because bounce never split the box edge-by-edge). Like friction it 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 separate friction. One subtlety: on a resting box (bounce: 0) wallFriction is 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 drift
  • spinDrag is a retention 0–1: 1 = off (tumble forever, today's exact look), 0.95 a gentle decay to a lazy drift, 0 an instant 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, > 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 damps spinV only, never tiltV (the wobble phase feeds sway and the turbulence curl).
  • It's a hybrid physics knob, not a pure render overlay. pool.spin is read in exactly two places — the render rotation and the turbulence curl (tilt*1.7 + spin). So with turbulence off a slower spin moves only the render rotation and the seeded position stream is byte-identical (only rotateHash moves, its own committed fingerprint); with turbulence on the curl reads the slower spin, so it moves positions too (a second committed fingerprint on the same turbulence baseline). At spinDrag: 1 every prior fingerprint — rotateHash included — 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
    ],
});
  • lifeColors is 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 colors pick (the trail is a simple flat overlay). The palette colors is still picked per particle — it's the trail color, and the body color when lifeColors is 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-length emitSize (a rain / snow curtain).
  • emit: 'ring' — the circle of radius emitSize around (x, y). Each piece is fired radially outward from the centre, so speed becomes the shell expansion rate and spread the 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 i wakes at stagger · i / count ms 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; a spray() already emits over time, so it ignores stagger.
  • How it stays deterministic. All count pieces 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 with stagger off (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 });
  • 0 is pure random spin (the classic look); 1 is 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. align changes only rotation, never position — so the seeded position stream (and every committed fingerprint) is byte-identical whether align is off or on; only the rotation earns its own deterministic fingerprint. Honored by both burst() and spray(). 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; 0 is rigid — frozen at each piece's random birth tilt (varied, not axis-aligned); 0.3 is a lazy drift; 2 doubles the tumble; a negative value reverses it. Coerced with num (0 and negatives are valid; non-finite → 1), never clamp01 — a rate multiplier is not a 0..1 blend.
  • A pure orientation overlay, turbulence-safe. spinRate is 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 from turbulence, 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 with align (the tumble scale runs first, then align blends toward the velocity heading). Draws no rng. Honored by both burst() and spray(); 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.2 shrinks out, 2 grows/blooms, 0 vanishes at death. s = scaleFrom + (scaleTo − scaleFrom) × (1 − lifeT), reusing the life fraction already computed for the opacity fade and the color ramp. Coerced with nonneg: a negative clamps to 0 (a size has no direction — not a mirror flip, not a fallback to 1; scaleTo: -2 renders like scaleTo: 0), non-finite → 1. scaleTo: 0 is a legitimate value (the size analog of spinRate: 0).
  • scaleFrom (v1.24.0) is the birth endpoint — the mirror scaleFrom:scaleTo :: fadeIn:fadeOut that closes the render-scale axis. Default 1 leaves the ramp starting at birth size (today's exact look); scaleFrom: 0.2 blooms up from a fifth-size, scaleFrom: 2 starts double and settles, and scaleFrom == scaleTo is an emergent constant multiplier. It supersedes the 0018 deferral note (which wrongly assumed sizeMin/sizeMax already covered a birth-size override — those set the constant birth size, they don't move the ramp's origin). Same nonneg coercion (a negative is born invisible, not a fallback to 1); folded into the same single ctx.scale as scaleTo.
  • Isotropic, one ctx.scale. The factor is applied to both axes and folded into flutter's single existing ctx.scale call — the X-wobble and the size ramp multiply on one transform, never a second call. pool.w/pool.h are 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 both burst() and spray(); 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; 0 freezes the wobble at each piece's own random birth tilt (a varied constant per piece, not collapsed to one value); 0.3 is a slow lazy flutter; 2 is a fast shimmer; a negative value reverses the phase. Coerced with num (0 and negatives are valid; non-finite → 1), never clamp01 — a rate multiplier is not a 0..1 blend.
  • Inert when flutter is 0. flutter (depth) multiplies the whole (1 − |cos|) term; at flutter: 0 the wobble is 1 regardless of the phase, so there is no speed to scale. So a flutterRate demo always sets flutter: 1.
  • A pure render overlay, turbulence-safe. flutterRate is a render-time phase scale about a birth pivot tilt0: it scales only the accumulated wobble phase and never touches pool.tilt — the phase the turbulence curl and sway read. 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 the scaleHash probe, no new channel). Draws no rng. Honored by both burst() and spray(); 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.4 eases in over the first 40% of life; 1 ramps across the whole life. Coerced with clamp01 (non-finite / non-numeric / undefined / negative → 0 off; > 1 → 1), exactly like align — 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. fadeIn changes only ctx.globalAlpha — it never touches pool.x/y/vx/vy or ctx.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. Unlike spinRate/flutterRate it 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); folding fadeIn in 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 both burst() and spray(); 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.6 is a long gentle dissolve; 0.1 a quick blink-out; 0 a hard cut; 1 dissolves across the whole life. Coerced with clamp01 against a Math.fround(0.3) sentinel — non-finite / undefined → the 0.3 default; 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 default 0.3 lives in a Float32Array, and Math.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.3 death-fade line byte-for-byte unchanged. At the default the guard never fires, so the committed opacity fingerprint is preserved bit-for-bit. (Contrast scaleTo/scaleFrom/spinDrag, whose default 1 is Float32-exact — Math.fround(1) === 1 — so their != 1 guards need no sentinel.)
  • Always written at spawn — load-bearing here. Unlike fadeIn (whose zero-init 0 happens to mean "off"), a zero-init 0 for fadeOut means "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, fadeOut rides 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 bottom

Because 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: