Core Philosophy
- A particle effect is a reward. It fires when someone completes something, and it exists to make the moment feel physical
- The value comes from novelty. A stock confetti burst that every other product ships reads as noise, because the user has seen that exact effect a hundred times
- Build the effect out of the product's own material: its brand shapes, its accent hues, its icon set. A liked heart should spray hearts, not generic paper
- Restraint is part of the design. One well-timed burst per meaningful action, not a burst on every click
- Fill modes are owned by
reveal-techniques.md, and transform composition and CSS trigonometry bycss-motion-mechanics.md. This doc points at them rather than restating them
When a particle effect is the right answer
- Use it for a completed, celebrated, one-off action: a purchase, a submitted application, a streak extended, a like
- Do not use it for routine actions, for anything a user repeats dozens of times a session, or for anything that has failed
- The effect must be additive. If removing it loses information, it was carrying meaning it should not have been carrying
- Budget the moment: the whole thing should be over in well under a second, and it must never block the interface underneath
Anatomy of an emitter
Every DOM-based effect is the same four steps.
- On the trigger, create a small number of elements, typically 5 to 20
- Give each one a randomised or derived position, angle, distance, size and delay
- Animate each with a CSS keyframe animation that moves, fades and scales it
- Remove each element once its animation is over
- Keep the animation in CSS and the maths in JavaScript. JavaScript decides where each particle goes, CSS moves it
- Pass per-particle values in as custom properties rather than writing individual keyframes
particle.style.setProperty("--angle", `${angle}deg`);
particle.style.setProperty("--distance", `${distance}px`);
particle.style.setProperty("--delay", `${index * 20}ms`);
One keyframe rule, any number of particles
- Custom properties can be read inside
@keyframes. One rule animates the whole burst, with each particle's destination set inline, instead of generating a keyframe rule per particle, which does not scale
@keyframes disperse {
to {
transform: translate(var(--x), var(--y));
}
}
- Omitting the
fromblock makes the animation start from the element's current value, so the same rule composes with varied starting states. Particles sit at their origin already, so spelling outfrom { transform: translate(0, 0) }adds nothing - The same trick works for opacity: a
to { opacity: 0 }keyframe fades any particle out from whatever opacity it already has, and afrom { opacity: 0 }keyframe fades in to whatever the element's own styles specify - Partial keyframes must come after fully specified ones in the
animationlist. Later animations win conflicts on a shared property, and a partial keyframe can only fill its missing end from an animation declared before it
Fill modes are not optional here
- A keyframe animation only applies its styles while it is running. The instant it ends, the element snaps back to the styles it had before, which is the single most common reason a burst "flickers back on" a frame before it disappears
- Every fade, shrink and disperse keyframe in a particle effect needs
forwards, or the particle returns to full opacity and full size for the gap between the animation ending and the node being removed
.particle {
animation:
disperse var(--pop-duration) var(--particle-curve) forwards,
fade-out var(--fade-duration) var(--fade-delay) forwards;
}
- A delayed particle has the mirror problem: keyframe styles do not apply during the delay either, so a staggered particle sits fully visible until its animation starts.
backwardsholds the first frame through the delay,bothholds both ends reveal-techniques.mdcovers fill modes in depth, including what a filled animation stops you changing afterwards
Cleanup
- Particles that are never removed accumulate as invisible DOM nodes. A user who taps a like button repeatedly can pile up hundreds of them
- There are two ways to remove them: one timer for the whole burst, or one
animationendlistener per particle. For particle systems the timer is the better default, and the reasons are below - Particles that spill outside their trigger will intercept hover and clicks. Set
pointer-events: noneon every particle - The container needs
position: relativeand nooverflow: hidden, or the burst gets clipped at the button edge - Cap the number in flight. If the trigger can be hammered, either ignore triggers while a burst is running or cancel the previous burst
One timer for the whole burst
Collect the burst into an array as it is created, then schedule a single removal for the batch.
const POP_BASE = 500;
const POP_SPREAD = 100;
const FADE_BASE = 1000;
const FADE_SPREAD = 500;
const MAX_FADE_DELAY = 300;
const MAX_POP_END = POP_BASE + POP_SPREAD;
const MAX_FADE_END = MAX_FADE_DELAY + FADE_BASE + FADE_SPREAD;
const MAX_LIFETIME = Math.max(MAX_POP_END, MAX_FADE_END);
const CLEANUP_MARGIN = 200;
const particles = [];
for (let index = 0; index < COUNT; index += 1) {
const particle = createParticle(index);
const popDuration = random(POP_BASE - POP_SPREAD, POP_BASE + POP_SPREAD);
const fadeDelay = random(0, MAX_FADE_DELAY);
const fadeDuration = random(FADE_BASE - FADE_SPREAD, FADE_BASE + FADE_SPREAD);
particle.style.setProperty("--pop-duration", `${popDuration}ms`);
particle.style.setProperty("--fade-delay", `${fadeDelay}ms`);
particle.style.setProperty("--fade-duration", `${fadeDuration}ms`);
container.appendChild(particle);
particles.push(particle);
}
setTimeout(() => {
for (const particle of particles) particle.remove();
}, MAX_LIFETIME + CLEANUP_MARGIN);
- One timer, not one timer per particle. Scheduling a timeout inside the creation loop gives you as many parallel timers and as many separate removals as there are particles, for a job that is one batched operation
- The timings live in JavaScript and are written onto each node as custom properties, so the CSS animates the same numbers the timer was sized from. Do not also set a duration in the stylesheet: two sources drift the moment someone slows the fade down, and the timer starts deleting particles mid-flight
- Use the maximum randomised duration for the timer, never the base value. Randomising the fade around a base means half the burst outlives a timer set to the base, and those particles vanish while still visible
- Sum along a chain, take the maximum across concurrent animations. A delay followed by a fade is a chain, so
MAX_FADE_ENDadds the delay ceiling to the duration ceiling. Taking only the fade ceiling would delete the late starters mid-fade disperseandfade-outare independent branches on the same element, not one sequence.dispersestarts immediately andfade-outstarts after its own delay, which is whyMAX_FADE_ENDincludes the delay ceiling. The timer has to outlive whichever branch finishes last, which isMath.maxof the two ceilings. Adding the ceilings together would hold dead nodes longer than any animation actually runs- Dispersion is usually the short branch, so the fade ceiling usually wins. Writing it as a maximum means the timer still holds when someone slows the disperse stage past the fade, instead of quietly cutting the pop short
- Add a small margin so the timer never lands on the same frame the animation ends
- The looseness is a feature, not a compromise. A timer is tied to nothing, so stages can be deliberately desynchronised: spawn the particles a moment after a pop circle starts, clean up after the longest animation, all without event plumbing. An event can only tell you that one specific animation finished
The animationend event
const cleanup = () => particle.remove();
particle.addEventListener("animationend", cleanup, { once: true });
particle.addEventListener("animationcancel", cleanup, { once: true });
- Exact, and needs no duration bookkeeping. This is the right shape when one element runs exactly one animation, for example a single ripple or a one-off badge
- It does not fire in every case. An animation that aborts before completing, because the element was removed from the DOM or the animation was taken off the element, fires no
animationendat all.animationcancelcovers the abort, so listen for both or those nodes leak - With several animations on one element,
animationendfires once per animation. Cleanup on the first event deletes the particle mid-fade.event.animationNamenames the keyframe rule that ended, so you can filter, but that pins the JavaScript to a CSS identifier and a rename breaks it silently at runtime - Cost scales with the burst: N particles means N listeners and N separate removals. Delegating to the container removes the per-node listener but not the N removals
Containment: keeping particles inside their box
- Positioning with
top: random(0, 100)%andleft: random(0, 100)%places the element's top-left corner, not its centre, so particles overflow the right and bottom edges and the field looks lopsided - The fix is to offset by half the particle's own size, which centres the point being randomised
.particle {
position: absolute;
top: var(--top);
left: var(--left);
translate: -50% -50%;
pointer-events: none;
}
translate: -50% -50%also gives a clean origin for scale and rotation, since the particle now grows from its centre- For a field that must stay strictly inside, clamp the random range instead, for example 10% to 90%, and let the visual weight sit away from the edges
Polar coordinates: bursts that radiate
- A burst should radiate from a point, so the natural parameters are angle and distance, not x and y
- Pick an angle and a distance, then convert to x and y once
const angle = normalize(index, 0, count, 0, 360) + random(-JITTER, JITTER);
const distance = random(MIN_RADIUS, MAX_RADIUS);
const radians = (angle * Math.PI) / 180;
const x = Math.cos(radians) * distance;
const y = Math.sin(radians) * distance;
- A minimum radius as well as a maximum turns the field from a disc into a ring, which keeps the centre clear so the thing being celebrated stays visible
- Screen coordinates grow downward, so 0 points right, 90 points down and 270 points up. Reasoning about a burst as though y grew upward is why an emitter aimed at 90 sprays into the floor
- The conversion can also happen entirely in CSS, with trigonometric functions inside
calc()reading--angleand--distancedirectly, which removes the JavaScript step.css-motion-mechanics.mdowns that syntax - Keep the conversion in JavaScript when you need x and y for anything else, or when one helper is shared across several effects. The JavaScript route also has the longer support history of the two, so check the CSS functions against your support floor before moving the maths into the stylesheet
Aiming the burst
- A base angle plus a spread turns an explosion into a directed spray:
random(BASE_ANGLE - SPREAD, BASE_ANGLE + SPREAD). A wand tip, a thruster and a cannon are all the same emitter with a different base angle and a narrow spread - A full 360 spread is the explosion case, and it is the only case where the index-derived angles below matter, since a narrow spread cannot clump into an empty half anyway
- A continuous emitter is a rate, not a count. Express it as particles per second so the number reads as an intent rather than a magic interval
const RATE_PER_SECOND = 15;
const timer = setInterval(spawnParticle, 1000 / RATE_PER_SECOND);
- A continuous emitter has no natural end, so each particle needs its own scheduled removal, and the interval needs a stop: clear it when the element scrolls out of view or the tab is hidden, or it spawns forever in a background tab
Spirals come from transform order
- Putting a
rotate()before thetranslate()inside the sametransformbends the straight flight path into a spiral, because each function operates on the coordinate space the previous one left behind
@keyframes disperse {
to {
transform: rotate(180deg) translate(var(--x), var(--y));
}
}
- Swap the order and the particle flies straight and spins in place instead.
css-motion-mechanics.mdcovers the composition rule
Even distribution beats pure randomness
- True randomness clumps. With a handful of particles at fully random angles, a substantial fraction of bursts put every particle in the same half of the circle, and those bursts look broken rather than random
- Derive the base angle from the particle's index so the circle is divided into equal wedges, then add a random offset so it still feels organic
const JITTER = 40;
const angle = normalize(index, 0, count, 0, 360) + random(-JITTER, JITTER);
- One constant is the whole chaos dial. At
0the particles form an evenly spaced ring. At a large value the burst reads as fully random, but no particle can travel further than the jitter from its own wedge, so an empty half is impossible. That is what makes it feel more random than randomness - Alternating distance on odd and even indices gives a zig-zag ring, which reads as a snowflake or a starburst rather than a circle:
const distance = index % 2 === 0 ? INNER : OUTER - The same principle applies to size, delay and colour: vary within deliberate bounds rather than sampling the whole range
The angle mapping uses count, not count minus one
normalize(index, 0, count, 0, 360)is correct. The highest index iscount - 1, so the last particle lands one wedge short of 360, and 360 is the same position as 0. That apparently wasted wedge is the gap the first particle occupies- Using
count - 1maps the last particle onto exactly the same angle as the first, so two particles overlap and the ring has a hole - Laying points across a span that is not a circle is the opposite case. A row of dots from left edge to right edge wants
count - 1, so the first sits at the start and the last sits at the end
Linear interpolation
- Mapping a value from one scale to another is the most reused piece of maths in this kind of work. Index to angle, scroll position to opacity, pointer position to rotation
- Keep one small utility and name it for what it does, so the intent is readable months later
function normalize(value, inMin, inMax, outMin, outMax) {
return ((value - inMin) / (inMax - inMin)) * (outMax - outMin) + outMin;
}
const angle = normalize(index, 0, count, 0, 360)says what it means.(360 / count) * indexsays the same thing but has to be decoded
Compose a clamped version once
Clamping a normalized value means repeating the output bounds at both call sites, which is exactly the kind of duplication that goes stale. Compose the two.
function clamp(value, min, max) {
if (min > max) [min, max] = [max, min];
return Math.min(Math.max(value, min), max);
}
function clampedNormalize(value, inMin, inMax, outMin, outMax) {
return clamp(normalize(value, inMin, inMax, outMin, outMax), outMin, outMax);
}
- The clamp must sort its own bounds. Descending output ranges are the normal case here, since mapping distance to strength means full strength at zero distance, and a plain
Math.min(Math.max(value, min), max)withmingreater thanmaxreturns the same wrong bound for every input. Sorting inside the clamp makes the direction of the range irrelevant to the caller pointer-interactions.mduses this same helper for proximity falloff
An exponential variant for curved falloff
function exponentialNormalize(value, inMin, inMax, outMin, outMax, exponent = 2) {
const progress = (value - inMin) / (inMax - inMin);
return outMin + (outMax - outMin) * Math.pow(progress, exponent);
}
- The only difference is a power curve applied to the progress before it is mapped, so the output bends instead of running straight
- Use it when the response should be concentrated at one end: a glow that stays strong near the centre and then drops off sharply, or a size that grows slowly and then runs away
One control, several parameters, different windows
A single input can orchestrate a whole composition by giving each parameter its own slice of the input range.
function chaosSettings(value) {
return {
jitter: normalize(value, 0, 100, 0, 60),
spread: clampedNormalize(value, 0, 50, 0.2, 1),
hueRange: clampedNormalize(value, 50, 100, 0, 45),
twinkle: clampedNormalize(value, 75, 100, 0, 1),
};
}
- Each parameter is flat before its window opens and flat after it closes, because the clamp pins it to the nearer bound outside the range
- Staggering the windows is what makes one slider feel like it is running a sequence rather than moving everything at once. Overlap them slightly so the handover is not visible
Colour
Constrain by locking channels, not by picking from a soup
- Never sample a fully random colour. Random values across every channel produce muddy, off-brand results that clash with the interface
- Lock lightness and chroma, vary only the hue, and the burst reads as one family that happens to be varied
const hue = BRAND_HUE + random(-18, 18);
particle.style.background = `oklch(0.72 0.19 ${hue})`;
- A hue outside 0 to 360 is legal and wraps implicitly, so adding to a hue never needs a manual modulo
- Sampling from a named palette is the other constrained option, and it buys themed variants from one emitter. Swap the array and the same code fires a different season
const PALETTES = {
default: ["oklch(0.72 0.19 25)", "oklch(0.8 0.15 60)", "oklch(0.88 0.09 95)"],
cool: ["oklch(0.72 0.15 250)", "oklch(0.8 0.12 200)", "oklch(0.9 0.06 220)"],
};
const palette = PALETTES[theme];
particle.style.background = palette[Math.floor(Math.random() * palette.length)];
- All colour values use
oklch(), which is also what makes hue jitter behave predictably
Animating between two colours passes through grey
- Interpolating a colour converts both endpoints into a rectangular colour space and moves each component along a straight line. Two vivid, opposite hues sit on opposite sides of the neutral axis, so the midpoint of that arithmetic lands near grey. No one would call grey the midpoint of red and teal, but that is what the channel maths returns
- The default interpolation space is Oklab, which is rectangular, so authoring the endpoints in
oklch()does not fix it. The interpolation space is what matters, not the syntax you wrote them in
Three fixes, in the order to reach for them on particles.
- Animate a hue rotation filter. No colour interpolation happens at all: the filter re-tints pixels that have already been painted, so there is no midpoint to get wrong. This is the first choice for particles, because it also keeps the work off the paint path
@keyframes hue-shift {
to { filter: hue-rotate(var(--hue-shift)); }
}
- Declare the interpolation space where the syntax allows it. Gradients and
color-mix()accept an interpolation method, andin oklchis polar, so hue travels around the wheel instead of across the middle. Put the plain declaration above it as the fallback, because a browser that cannot parse the method drops the whole declaration
.trail {
background: linear-gradient(to right, var(--from), var(--to));
background: linear-gradient(in oklch to right, var(--from), var(--to));
}
- Mix explicitly with
color-mix(in oklch, var(--from), var(--to) 50%)and set the result yourself, which is the escape hatch when neither of the above fits
A hue sweep of a full turn does nothing
oklch(0.72 0.19 0)andoklch(0.72 0.19 360)are the same colour. Hue is periodic and normalises into the range from 0 up to but not including 360, so the two endpoints resolve identically and the transition has zero distance to cover- Nothing moves, nothing errors. This is the classic silent failure when building a cycle-the-wheel effect: the code looks right, the element sits still
- Hue interpolation also takes the shorter arc by default, so even a half turn goes whichever way is nearer unless the interpolation method asks for
longer hue - The fix for a cycle is
filter: hue-rotate(), which interpolates an angle rather than two colours, so the direction and the length of the sweep are exactly what you wrote - A
hue-rotate()angle above 360 has the same effect as that angle modulo 360, so a sweep of exactly one full turn ends on the tint it started with. Use a partial turn when the particle should end somewhere new - The rotation tends to darken colours as it moves around the wheel, so start from slightly lighter, less saturated values than you want mid-flight
- Use a
lineartiming function for a colour shift, so every intermediate colour gets equal screen time. An eased colour change lingers on the two endpoints and rushes the interesting part in the middle - The performance tradeoff: animating
background-colorrestyles and repaints every particle each frame, whilehue-rotate()re-tints already-painted pixels but brings its own filter and layer cost. Neither wins by rule; profile both at your real particle count and keep the one that stays inside the frame budget
Making it feel good
- Vary the easing per particle. A uniform easing makes the burst read as a single object rather than many
- Ease out for the outward flight, so particles leave fast and settle. Constant speed looks mechanical
- Fade and shrink near the end of the flight rather than at the start, so the burst has presence before it disappears
- Stagger start times by a few tens of milliseconds. Perfect simultaneity is the strongest tell that the effect is generated
- Add a companion movement on the trigger itself, for example a quick scale on the button, so the source and the effect feel connected
What to vary
Randomising one property is subtle. Randomising all of them is the difference between a generated effect and an organic one. The full list worth wiring to a custom property:
- Fade duration, so particles do not all disappear on the same frame
- Fade delay, so they do not all start disappearing on the same frame either
- Dispersion duration, so they do not all stop moving at once
- Size, rotation and scale, so the field has visual texture
- Twinkle speed, if the particles pulse
Two structural rules govern how you pick those values.
- Derive some of them from the particle's distance. Nearer particles should stop moving and fade sooner, because that is what real debris does. Distance is already computed, so this costs nothing
- Do not derive all of them. If distance drives duration and delay and size, then every particle at the same distance behaves identically and the burst re-synchronises into visible rings. Mix the two: let distance set the base value and a random offset break the tie
- Every duration you randomise raises the ceiling the cleanup timer must clear. Track the maximum, not the base
Click ripples
- A ripple belongs at the click point, not at the centre of the control. Centring it is the tell that the effect is canned
- Convert the click's viewport coordinates into the target's own box, then subtract half the ripple's size so the ripple is centred on the click rather than starting there
const box = target.getBoundingClientRect();
const size = Math.max(box.width, box.height);
// A keyboard Enter fires a real click whose coordinates are 0, so fall back
// to the control's own centre. event.detail is 0 only when no pointer was involved.
const fromPointer = event.detail > 0;
const originX = fromPointer ? event.clientX - box.left : box.width / 2;
const originY = fromPointer ? event.clientY - box.top : box.height / 2;
ripple.style.width = `${size}px`;
ripple.style.height = `${size}px`;
ripple.style.left = `${originX - size / 2}px`;
ripple.style.top = `${originY - size / 2}px`;
- Without that branch the ripple appears at the control's top-left corner for every keyboard user, which is the kind of bug that never shows up in pointer testing. pointer-interactions covers the same trap for anything else anchored to a cursor
- The target needs
position: relativeand, unusually for this doc,overflow: hidden, so the ripple is clipped to the control's shape instead of spilling over its neighbours - Rapid clicks must overlap, not restart. Append a new element per click. Restarting one node's animation snaps the previous ripple out of existence halfway through, which reads as a glitch rather than as feedback
- Splitting grow and fade into two animations looks better than one combined animation, because the fade can outlast the growth and the ripple can reach full size while still visible
- That split is exactly the case where
animationendmisfires: the event fires once per animation, so cleanup keyed to the first one removes the node mid-fade. Use the batched timer, sized to the longer of the two
Gather then release
A press-and-hold interaction where the length of the press sets the size of the payoff. No click can express that.
- While the pointer is held, spawn particles on an interval. Each gets a random angle and distance, and animates from that outer point inward to the origin
- Store the angle and distance on the node as custom properties when it is created, so the release can reuse them
- On release, add a class carrying the outward keyframe, which reads the same two properties. Every particle returns to exactly where it was born, which is what makes the release read as a rebound rather than a second unrelated burst
.particle { animation: gather var(--gather-duration) forwards; }
.particle.released { animation: release var(--release-duration) forwards; }
@keyframes gather {
from { transform: translate(var(--x), var(--y)); }
}
@keyframes release {
to { transform: translate(var(--x), var(--y)); opacity: 0; }
}
- The burst is made of whatever gathered, so a short press releases a small burst and a long press releases a big one, with no extra code
- Two thresholds are worth encoding as named constants. Below roughly ten gathered particles, skip the release entirely, because a burst that thin reads as a rendering glitch rather than an effect. Above a couple of hundred, remove the excess instead of animating it. Tune both by watching the effect on the slowest device you support rather than trusting either number
- Cancel the interval on
pointerup,pointercancelandpointerleave. A press that ends outside the element otherwise keeps spawning forever - A hold with no keyboard equivalent is a pointer-only interaction. Fire the full effect on
EnterorSpacewith a fixed particle count, so the outcome is reachable without a pointer
Impulse from distance
Scattering nearby elements away from a click is the same maths pointed outward at the interface instead of at particles.
const angle = Math.atan2(itemCenterY - event.clientY, itemCenterX - event.clientX);
const distance = Math.hypot(itemCenterX - event.clientX, itemCenterY - event.clientY);
const impulse = clampedNormalize(distance, 0, BLAST_RADIUS, MAX_IMPULSE, 0);
item.velocityX += Math.cos(angle) * impulse;
item.velocityY += Math.sin(angle) * impulse;
- The output range descends, from
MAX_IMPULSEat the click point to0at the edge of the radius. That is the case the sorted clamp above exists for, and it is why the clamp must not assume its minimum comes first - Accumulate with
+=, never=. Overwriting throws away whatever momentum an item already had, so a second click while things are still moving resets them instead of hitting them harder. The additive form makes rapid repeat clicks compound, which is the behaviour the interaction promises - Divide the impulse by a per-element mass derived from its rendered size, so a large card barely shifts while a small chip flies. Without it every element moves the same distance and the scene has no weight
const mass = (item.offsetWidth * item.offsetHeight) / REFERENCE_AREA;
item.velocityX += (Math.cos(angle) * impulse) / mass;
- Track an angular velocity the same way and decay it with the same drag factor as the linear velocity, so items tumble while they fly and slow to a stop rather than snapping back upright
Performance
- Measure before optimising. Record the interaction in the performance panel, with the effect commented out as a baseline, and compare
- Read the recording against the frame budget: at 60Hz every frame has 16.7ms for all script, style, layout and paint work, and the panel flags the frames that run over. A healthy effect leaves most of each frame idle
- Check what fills the busy frames. Style-recalculation, layout and paint tasks recurring on every frame mean a layout property is being animated; particles that animate only
transformandopacitycomposite, so those rows stay nearly empty between spawns - CPU throttling in the panel gives a quick read on low-end hardware, but it does not slow the GPU, so confirm on a real slow device before trusting the result
- The usual cost is not the animation but the DOM churn: creating and destroying nodes, and forcing layout by reading and writing styles in the same frame
- Animate
transformandopacityonly. Animatingtop,left,widthorheighttriggers layout on every frame - Preload any asset a particle renders. An emitter that creates
<img>particles requests that file the first time it fires, so the first burst can play out before the image arrives and show nothing at all.<link rel="preload" href="/sparkle.svg" as="image">in the document head fixes it - If a burst genuinely needs hundreds of pieces, the answer is canvas, not a faster DOM emitter
Object pooling usually costs more than it saves
Recycling finished nodes instead of creating new ones is a game-development technique that rarely pays for itself here. It adds real complexity, and it has two ways to actively hurt.
- Recycled nodes carry stale state. A pooled particle keeps every custom property, class and inline style from its previous life. Anything the next particle does not explicitly overwrite is inherited, which shows up as one particle flying to another's destination or wearing the wrong colour. Scrubbing every property on the way in is work the fresh-node path never has to do, and the bug only appears once someone adds a property they forget to scrub
- The pool pins nodes against garbage collection. Holding a reference in an array is precisely what stops the collector reclaiming a detached node. When the effect stops, every node in the pool stays in memory for as long as the pool exists, so an emitter that ran for a while leaves hundreds of nodes retained forever. Draining the pool when the emitter stops is mandatory, and it is the step people forget
- Profile the create-and-discard version before reaching for a pool. Modern collectors handle that pattern without visibly dropping frames, so the pool is usually complexity bought for nothing
Reduced motion
- The operating system setting expresses a preference. Nothing is disabled automatically, so the code has to honour it
- Vestibular disorders are the most cited reason, but dyslexia, ADHD, autism, epilepsy and post-concussion vision problems all give people reasons to want less movement
- For a decorative burst, reduced motion means not firing it at all. There is no information to preserve
- Gate the motion inside a
no-preferencequery rather than removing it inside areducequery. A browser that cannot evaluate the query drops the block it does not understand, so the fail-safe direction is the one where dropping the block means no motion
@media (prefers-reduced-motion: no-preference) {
.particle {
animation:
disperse var(--pop-duration) var(--particle-curve) forwards,
fade-out var(--fade-duration) var(--fade-delay) forwards;
}
}
- The reverse shape, declaring the animation for everyone and switching it off inside
@media (prefers-reduced-motion: reduce), hands motion to every browser that cannot parse the query, which is the population most likely to need it removed - Make the JavaScript check the real gate, so no nodes are created at all. Query
no-preferenceand act on a positive match, for the same reason: an unevaluable query returnsfalseand nothing is emitted
const motionOk = window.matchMedia("(prefers-reduced-motion: no-preference)");
function onTrigger() {
if (motionOk.matches) emitBurst();
}
- Read
.matchesat the moment of the trigger rather than caching it at load. The query object stays live, so a mid-session toggle takes effect on the very next trigger and nochangelistener is needed at all - A
changelistener earns its place only when something is already running and has to be torn down, for example a looping ambient emitter - Reduced motion removes the movement, never the outcome. The like still registers, the count still increments, the confirmation still appears. Where the effect carries meaning, replace the flight with a static or fading state rather than removing the feedback
In React
- Hold the live particles in state as an array of objects, each with a stable id, and render them. Do not reach into the DOM to append nodes
- Compute angle, distance and delay when the particle is created, store them on the object, and pass them through as inline custom properties
- Spread new particles into the existing array rather than replacing it. Replacing unmounts every particle still in flight, so a rapid second trigger makes the first burst vanish mid-air
setParticles((current) => [...current, ...generateParticles(count)]);
- Remove particles from state on a timer sized to the longest animation, matching the batched cleanup above, rather than one
onAnimationEndhandler per particle - Give each particle a
keythat is unique across every particle in flight, not merely within its own burst, since spreading leaves concurrent bursts as siblings in one array and per-burst keys collide there, reusing elements mid-flight and restarting the animation visibly - Push the generation maths into a helpers module and import it. The component should read as "render these particles", not as a page of trigonometry, and the helpers are testable on their own
- The structure is identical in any component framework. Only the API names change
Common mistakes
- Installing a generic confetti package, which gives an effect the user has already seen elsewhere
- Leaving particle nodes in the DOM after they fade, which leaks memory across repeated triggers
- Scheduling one cleanup timer per particle instead of one per burst, which multiplies timers and removals for a single batched job
- Sizing the cleanup timer to the base duration when the durations are randomised, so the longest-lived particles are deleted while still visible
- Setting the animation duration in both the stylesheet and the cleanup timer, which drifts the moment either one is edited
- Removing a node on the first
animationendwhen the element runs several animations, which deletes it mid-fade - Omitting
forwards, so every particle flashes back to full opacity for a frame before it is removed - Forgetting
pointer-events: none, so invisible particles swallow hover and clicks - Randomising
topandleftwithout correcting for the particle's own size, which pushes the field off centre - Fully random angles, which clump and make the burst look lopsided
- Mapping index to angle with
count - 1, which stacks the last particle on the first and leaves a gap - Fully random colours, which look muddy and ignore the brand
- Animating
background-colorbetween two vivid hues, which passes through grey at the midpoint - Transitioning a hue from 0 to 360 and wondering why nothing happens, when both endpoints are the same colour
- Spawning a ripple at the element's centre, or restarting one ripple node instead of stacking a new one per click
- Overwriting an impulse with
=instead of accumulating with+=, so repeat clicks reset the motion rather than compounding it - Replacing the particle array in React state instead of spreading into it, which erases in-flight particles
- Adding an object pool without scrubbing recycled nodes or draining the pool, which trades a non-problem for stale state and retained memory
- Animating layout properties instead of
transformandopacity - Shipping the burst with no reduced-motion path, or writing that path as a
reduceoverride so unsupporting browsers still get motion