Concepts
Behaviors
How particles change while they're alive.
Even though a particle’s properties can start out randomized, effects would look quite plain if the particles do not change over time. For example, confetti shoots up, slows down, falls and fades away, and none of that is decided when it spawns. It’s the work of behaviors, small functions that change a particle throughout the simulation. This page explains when behaviors run, what the built-in ones do and why their order matters.
What a behavior is
Section titled “What a behavior is”A Behavior is a function that receives a particle and changes it, on every simulation step. It can also be an object with an extra init function, which runs once when a particle spawns, for example to remember its starting size. Custom behaviors shows how to write your own.
A custom effect lists its behaviors in behaviors, and they apply to all of its particles:
import { createEffect, drag, fade, gravity, scale } from "party-js";
createEffect({ // ... behaviors: [gravity(800), drag(5), scale(0.2, 0), fade(0, 1)],});Functions like gravity() don’t change anything themselves. Instead they return the behavior, which is then called during the simulation loop.
The built-in effects use the same behaviors. confetti() applies downwards force with gravity(), slows them with drag(), grows them in with scale() and fades them out with fade(). Their impact on the particles can be tuned via their parameters. Built-in effects commonly expose those parameters as options, for example ConfettiOptions.gravity.
When behaviors run
Section titled “When behaviors run”A behavior can do work at two moments in a particle’s lifecycle:
- When the particle spawns, right after its values have been sampled. A behavior can prepare the particle here, for example by remembering its starting size. This is optional.
- On every simulation step while the particle is alive, which is where the actual modification happens.
Each step, party.js first ages the particle, then runs the behaviors on it, in the order they’re listed in, and lastly applies the physics simulation. Effects covers this in more detail.
Behaviors don’t run on dead particles. Once a particle’s age reaches its lifetime, it’s removed at the end of the step, without another update.
Built-in behaviors
Section titled “Built-in behaviors”| Behavior | Description |
|---|---|
gravity(strength) |
Pulls particles down with constant acceleration. |
drag(rate, edgeRatio?) |
Slows particles down in proportion to their speed, and optionally makes them flutter like paper. |
fade(in, out, ease?) |
Fades particles in after they spawn, and out before they die. |
scale(in, out, ease?) |
Grows particles from nothing after they spawn, and shrinks them away before they die. |
twinkle(options?) |
Makes particles glint occasionally, growing a little and flashing towards white. |
Gravity and drag
Section titled “Gravity and drag”gravity() and drag() affect a particle’s velocity. Like positions and velocities, they work in pixels and seconds, in particle space, where +y points up.
gravity(strength) adds strength pixels per second to the particle’s downward speed, every second. Confetti uses 800 by default. A negative strength pulls particles up instead, for example to make bubbles rise.
drag(rate) works against the particle’s movement, in every direction. Every second, the particle loses the fraction rate of its velocity, so drag is strongest while a particle is fast: a burst slows down sharply at first, then more and more gently. That’s how a burst of confetti can shoot out fast and still come to a stop within a few hundred pixels.
Together, the two make a falling particle speed up until it reaches a steady speed, its terminal velocity, of strength / rate. If you know how fast particles should fall at most, you can calculate the rate from it, just like confetti() does behind the scenes:
const g = 800; // px/s²const terminalVelocity = 160; // px/s
const behaviors = [gravity(g), drag(g / terminalVelocity)];The rate is measured in 1/s, and sensible values usually lie between 2 and 10. It’s sampled once for each particle, so you can also pass something like drag(range(4, 6)). The particles then fall at slightly different speeds, and a cloud of them stretches out as it falls, instead of coming down as a block.
Fluttering
Section titled “Fluttering”A piece of paper doesn’t fall straight down. It meets more resistance when it moves face-on than when it moves edge-on, so while it’s tilted, part of its fall turns into a sideways glide. As it tumbles, the tilt flips and the glide changes direction, and the paper sways from side to side.
The optional second argument of drag(), edgeRatio, simulates this. It sets how much drag a particle feels when it moves edge-on, relative to when it moves face-on:
const behaviors = [gravity(800), drag(800 / 160, 0.4)];With the default of 1, drag is the same in every direction. Values between 0.3 and 0.5 give a paper-like flutter, which is what confetti() uses, and lower values make particles glide far to the side. The face is the plane of the particle’s shape, so fluttering needs particles that tumble, for example with randomSpin().
Fading and scaling
Section titled “Fading and scaling”fade() and scale() affect the start and the end of a particle’s life. Both take two durations, in seconds:
- During the first
inseconds after spawning, the particle fades in from transparent, or grows from a size of 0. - During the last
outseconds before dying, it fades out again, or shrinks back to 0.
Pass 0 to skip either half. fade(0, 1) fades particles out over the last second of their life, and scale(0.2, 0) grows them in over the first 0.2 seconds.
If both durations together are longer than a particle’s lifetime, the transitions overlap, and it never reaches its full opacity or size.
Both behaviors work relative to the particle’s starting value. scale() grows it to the size it was sampled with, and fade() multiplies the opacity of its color, so a color that starts half transparent fades in to half transparent, not to fully opaque.
Easing
Section titled “Easing”By default, fading and scaling progress at a constant rate. Pass an Easing as the third argument to make them start or end softly instead:
| Easing | Description |
|---|---|
linear() |
At a constant rate. The default. |
sineIn() |
Starts slowly, and speeds up. |
sineOut() |
Starts quickly, and slows down. |
sineInOut() |
Starts slowly, speeds up, and slows down again. |
The out-transition runs the same curve backwards. An easing that starts the fade-in softly, like sineIn(), also makes the fade-out end softly.
An easing is just a function that turns a progress from 0 to 1 into the eased value, which should also go from 0 to 1, meaning you can easily write your own:
const cubicIn = (t) => t ** 3;fade(0.2, 0.5, cubicIn);Twinkling
Section titled “Twinkling”twinkle() makes particles glint occasionally: for a short moment, a particle grows a little and its color flashes towards white, then it settles back to how it was. Each particle glints a few times during its life, at random moments, so a group of them seems to sparkle. That’s what gives sparkles() its shimmer.
twinkle({ glints: 1.5, length: 0.22, size: 0.25, brightness: 0.6 });glints sets how many glints a particle catches per second, on average, and length how long each one lasts, in seconds. size is how much the particle grows at the peak of a glint, so 0.25 makes it 25% larger, and brightness how far its color moves towards white, from 0 to 1. The values above are the defaults, so twinkle() on its own looks the same.
The glint is applied on top of the particle’s size and color. Since scale() sets the size from scratch on every step, list twinkle() after it, as explained below.
The order of behaviors
Section titled “The order of behaviors”Behaviors run in the order they’re listed in, and each one sees the changes of the ones before it. Whether that matters depends on how a behavior changes a value:
- Some behaviors change a value step by step.
gravity()anddrag()adjust the velocity the particle already has, so their changes build up over time. - Others set a value from scratch on every step.
scale()calculates the size from the starting size and the particle’s age, andfade()does the same for the opacity. Whatever happened to the value before is overwritten.
For behaviors of the first kind, the order barely matters. Behaviors of the second kind replace the changes of everything listed before them. For example, here is a behavior that makes particles pulse:
Live Demo
import { createEffect, scale } from "party-js";
const pulse = (particle) => { particle.size *= 1 + 0.3 * Math.sin(particle.age * 10);};
createEffect({ // ... behaviors: [scale(0.2, 0.5), pulse],});Listed after scale(), pulse changes the freshly calculated size on every step, and the particles pulse while they grow and shrink. Listed before it, its change would be overwritten right away, and nothing would pulse. And on its own, without scale() resetting the size on every step, it would multiply the size it changed the step before, and the particles would quickly grow or shrink out of control.
Custom behaviors
Section titled “Custom behaviors”The simplest behavior is a function. party.js calls it for every particle, on every simulation step, with the particle and a context. Here’s one that pushes particles to the right, like a steady breeze:
const breeze = (particle, ctx) => { particle.velocity = { ...particle.velocity, x: particle.velocity.x + 100 * ctx.dt, };};ctx.dt is the length of the step, in seconds. Anything that’s meant to happen per second should be multiplied by it to keep effects framerate-independent. Here, the breeze adds 100 pixels per second to the speed, every second. Without dt, it would add 100 on every step, 120 times a second.
The new velocity is a new object, built from the old one, which keeps your behavior from accidentally changing a value that’s shared with something else.
Taking options
Section titled “Taking options”To make the strength adjustable, wrap the behavior in a function that takes the options, and returns the behavior. All built-in behaviors are written this way.
Live Demo
import { createEffect, drag, fade, gravity, scale } from "party-js";
const breeze = (strength) => (particle, ctx) => { particle.velocity = { ...particle.velocity, x: particle.velocity.x + strength * ctx.dt, };};
createEffect({ // ... behaviors: [gravity(-300), drag(1.7), breeze(100), scale(0.3, 0), fade(0, 1)],});Storing data per particle
Section titled “Storing data per particle”A breeze pushes every bubble the same way. To make them sway from side to side instead, each bubble can follow a wave over time, Math.cos(particle.age * 3). But then all bubbles would sway in sync, which would look too stiff. To solve this, we assign each bubble a random phase when it spawns, which it can store and access later on.
For that, a behavior can be an object instead of a function. Its init function runs once for each particle, right after it spawns, and its update function runs on every step, like a plain behavior function would. We can use a WeakMap to store per-particle data in the behavior:
Live Demo
import { createEffect, drag, fade, gravity, scale } from "party-js";
// pushes each particle from side to side, starting at a random point of the swingconst sway = (strength) => { const phaseMap = new WeakMap(); return { init(particle, ctx) { phaseMap.set(particle, ctx.rng() * 2 * Math.PI); }, update(particle, ctx) { const offset = phaseMap.get(particle); const push = Math.cos(particle.age * 3 + offset) * strength * ctx.dt; particle.velocity = { ...particle.velocity, x: particle.velocity.x + push, }; }, };};
createEffect({ // ... behaviors: [gravity(-300), drag(1.7), sway(200), scale(0.3, 0), fade(0, 1)],});A couple of notes on the implementation:
- The lookup is a
WeakMap, created inside the outer function. Every call ofsway()creates a new one, so two sways in the same effect never overwrite each other’s values. Also, a particle’s entry is cleaned up along with the particle after it has expired. - The phase comes from
ctx.rng, the effect’s random number generator, and not fromMath.random(), to keep the effect reproducible.
Quadratic drag
Section titled “Quadratic drag”As a last example, here’s quadratic drag, where air resistance grows with the square of the speed, as it does for real objects. The built-in drag() is linear instead, so that a slow fall doesn’t force a short flight:
const quadraticDrag = (coefficient) => (particle, ctx) => { const { x, y, z } = particle.velocity; const factor = 1 / (1 + coefficient * Math.hypot(x, y, z) * ctx.dt); particle.velocity = { x: x * factor, y: y * factor, z: z * factor };};Dividing by 1 + ... instead of subtracting keeps a fast particle from overshooting and reversing direction in a single step. Together with gravity, the terminal velocity is Math.sqrt(strength / coefficient), so to fall at most v pixels per second, use quadraticDrag(strength / v ** 2). The coefficient is measured in 1/px, so sensible values are tiny, usually between 0.0005 and 0.02.
Things to keep in mind
Section titled “Things to keep in mind”- Keep updates cheap. An update runs for every particle, 120 times a second. Don’t read from the page in it, for example with
getBoundingClientRect(). Ideally you would measure once, in the outer function, and use the result. - Errors stop the effect. If a behavior throws, the effect is stopped right away, and its
finishedpromise resolves with"stopped". The error is reported in the console, and other effects keep running.