Somewhere between "this landing page needs a little life" and "I'll just write a quick canvas loop" lies a swamp of requestAnimationFrame bugs, retina scaling quirks, and pointer-event math. tsParticles is the bridge over that swamp. It's a dependency-free, fully typed engine that turns particle animations into a single declarative config object: animated backgrounds, confetti bursts, fireworks, snow, starfields, mouse-trail effects, and the classic linked "constellation" network you've seen on a hundred hero sections.
You describe what you want, and the engine handles the canvas, the animation loop, retina scaling, interactivity, and responsiveness. It's the modern TypeScript successor to the once-ubiquitous particles.js, rebuilt with a modular architecture so you only ship the features you actually use. The core package, @tsparticles/engine, is roughly 15 KB gzipped with zero dependencies, and there are official wrapper components for React, Vue, Angular, Svelte, Solid, Next.js, and just about every other framework that exists.
What Makes It Worth Reaching For
The headline feature is breadth. tsParticles isn't a one-trick confetti library; it's a general-purpose particle engine. A short tour of what's in the box:
- Declarative config. The entire effect is a JSON-serializable object typed as
ISourceOptions. No imperative drawing code. - Shapes galore. Circles, squares, stars, polygons, lines, text, emoji, and your own images.
- Interactivity. Hover and click events wired to modes like repulse, grab, bubble, push, attract, and trail.
- Plugins. Emitters that continuously spawn particles (the engine behind confetti and fountains), absorbers that suck particles into a growing mass, and polygon masks that constrain particles to an SVG path.
- Presets. Twenty-plus ready-made effects, snow, stars, fire, fireworks, hyperspace, loaded with a single line.
- Performance controls. Built-in
fpsLimit,pauseOnBlur,pauseOnOutsideViewport, anddetectRetinakeep things smooth and battery-friendly.
Because it renders to a single <canvas> element rather than one DOM node per particle, it scales to large particle counts that would bring a DOM-based approach to its knees.
Getting It Installed
The engine is the foundation, but on its own it draws nothing. You pair it with a bundle (a curated set of features) and, for React, the official wrapper. The slim bundle is the recommended default for most projects.
npm install @tsparticles/react @tsparticles/engine @tsparticles/slim
Or with yarn:
yarn add @tsparticles/react @tsparticles/engine @tsparticles/slim
@tsparticles/engine also provides the TypeScript types you'll want, like ISourceOptions, Engine, and Container.
Your First Constellation
The defining pattern in modern tsParticles is splitting engine initialization out of the component. You call initParticlesEngine exactly once per app lifecycle to register the feature packages, then gate rendering on an init flag. Here's the canonical interactive "linked network" background.
import { useEffect, useMemo, useState } from "react";
import Particles, { initParticlesEngine } from "@tsparticles/react";
import { loadSlim } from "@tsparticles/slim";
import type { ISourceOptions } from "@tsparticles/engine";
const App = () => {
const [init, setInit] = useState(false);
// Runs ONCE — registers features into the engine
useEffect(() => {
initParticlesEngine(async (engine) => {
await loadSlim(engine);
}).then(() => setInit(true));
}, []);
const options: ISourceOptions = useMemo(
() => ({
background: { color: { value: "#0d47a1" } },
fpsLimit: 120,
interactivity: {
events: {
onClick: { enable: true, mode: "push" },
onHover: { enable: true, mode: "repulse" },
},
modes: {
push: { quantity: 4 },
repulse: { distance: 200, duration: 0.4 },
},
},
particles: {
color: { value: "#ffffff" },
links: { color: "#ffffff", distance: 150, enable: true, opacity: 0.5, width: 1 },
move: { direction: "none", enable: true, outModes: { default: "bounce" }, speed: 6 },
number: { density: { enable: true }, value: 80 },
opacity: { value: 0.5 },
shape: { type: "circle" },
size: { value: { min: 1, max: 5 } },
},
detectRetina: true,
}),
[]
);
if (!init) {
return null;
}
return <Particles id="tsparticles" options={options} />;
};
export default App;
A few things to notice. The useEffect with an empty dependency array guarantees the engine is set up only once, even across re-renders. Wrapping options in useMemo prevents the config object from being recreated on every render, which would otherwise tear down and rebuild the particle system. And loadSlim is doing the heavy lifting here, registering the shapes, interactions, and updaters this config references. Swap it for loadFull or loadBasic depending on how much you need.
Reacting to Loaded Particles
Sometimes you want a handle on the running system, to trigger an effect, log diagnostics, or imperatively manipulate particles after they spin up. The particlesLoaded callback gives you the Container.
import type { Container } from "@tsparticles/engine";
const particlesLoaded = async (container?: Container): Promise<void> => {
if (container) {
console.log("Particles ready:", container.particles.count);
}
};
// ...later in JSX
<Particles id="tsparticles" options={options} particlesLoaded={particlesLoaded} />;
If your configs live in a CMS or a shared JSON file, you can skip inline options entirely and point the component at a remote URL. The component fetches and applies the config for you.
<Particles id="tsparticles" url="https://example.com/particles.json" />
Choosing How Much to Ship
Here's the central design decision tsParticles asks you to make, and the one most worth understanding. The engine is tiny, but features live in separate packages, so how you load them directly controls your bundle size.
There are three strategies, from most convenient to most frugal:
// 1. Bundles — one import, batteries included.
import { loadFull } from "tsparticles"; // everything, heaviest
import { loadSlim } from "@tsparticles/slim"; // the sane default (~32 KB gzip)
import { loadBasic } from "@tsparticles/basic"; // bare essentials
// 2. Presets — a named effect in one line.
import { loadSnowPreset } from "@tsparticles/preset-snow";
// 3. Hand-picked feature packages — lightest, most verbose.
import { loadExternalRepulseInteraction } from "@tsparticles/interaction-external-repulse";
import { loadParticlesLinksInteraction } from "@tsparticles/interaction-particles-links";
import { loadCircleShape } from "@tsparticles/shape-circle";
For a snow preset, the entire initialization collapses to almost nothing:
import { loadSnowPreset } from "@tsparticles/preset-snow";
initParticlesEngine(async (engine) => {
await loadSnowPreset(engine);
}).then(() => setInit(true));
// Then just reference it by name:
const options = { preset: "snow" };
The rule of thumb: start with loadSlim. If a bundle audit later shows particles are a meaningful chunk of your payload, drop down to manually composing the exact loadX packages your config touches. Reach for presets whenever you want a named, recognizable effect without thinking about which sub-features it needs.
Confetti Without the Whole Engine
If all you want is a celebration burst, tsParticles ships a dedicated confetti bundle that mirrors the popular canvas-confetti imperative API, no React component or config object required.
import { confetti } from "@tsparticles/confetti";
const celebrate = async () => {
await confetti({
particleCount: 100,
spread: 70,
origin: { y: 0.6 },
});
};
<button onClick={celebrate}>🎉 Celebrate</button>;
This is the pragmatic middle ground: you get the imperative simplicity of a confetti-only library, but if your project later grows to need snow, fireworks, or an interactive background, you're already inside the same engine and don't have to swap dependencies.
Notes for Next.js and the App Router
tsParticles runs entirely in the browser, so in the Next.js App Router your particle component needs to be a client component. Mark the file with "use client" at the top, and the same initParticlesEngine pattern works unchanged. There's also a dedicated @tsparticles/nextjs package if you'd rather use a wrapper tuned for the framework.
"use client";
import Particles, { initParticlesEngine } from "@tsparticles/react";
// ...rest is identical to the standard React setup
Keeping It Smooth
Two knobs dominate performance: particle count and links. Linked constellations involve neighbor checks that scale roughly with the square of the particle count, so a config that's buttery on desktop can stutter on a mid-range phone. A few defenses worth wiring in from the start:
- Use
number.densityso counts scale to the viewport instead of being fixed. - Set a sensible
fpsLimit(120 is generous; 60 is plenty and cheaper). - Keep
pauseOnBlurandpauseOnOutsideViewportenabled so off-screen or background tabs don't burn cycles. - Leave
detectRetinaon for crisp rendering, but be aware it increases the pixel workload on high-DPI screens.
const options: ISourceOptions = useMemo(
() => ({
fpsLimit: 60,
pauseOnBlur: true,
pauseOnOutsideViewport: true,
detectRetina: true,
particles: {
number: { density: { enable: true }, value: 60 },
links: { enable: true, distance: 120 },
move: { enable: true, speed: 2 },
},
}),
[]
);
Where It Fits
tsParticles is the Swiss-army knife of web particle effects. It's broader than canvas-confetti (which is wonderful but confetti-only), it's the maintained modern replacement for the stagnant particles.js, and it's dramatically lighter than rolling your own WebGL particle system with Three.js when you only need 2D canvas effects. The modular architecture is its defining trait and the thing worth internalizing: pick loadSlim by default, drop to hand-picked loadX packages when bundle size matters, and reach for presets when you just want a named effect.
For a celebration button, the confetti bundle is all you need. For an interactive hero background, a snowfall over your winter landing page, or a fireworks finale on a checkout success screen, the full engine gives you one consistent, typed, dependency-free toolkit. Describe the effect, gate the render on an init flag, and let the canvas take care of itself.