unused modules during bundling.
import { tsParticles } from "@tsparticles/engine";
import { loadPluginBlend } from "@tsparticles/plugin-blend";
import { loadPluginInteractivity } from "@tsparticles/plugin-interactivity";
import { loadPluginMove } from "@tsparticles/plugin-move";
import { loadPaletteNeon } from "@tsparticles/palette-neon";
async function initializeParticleRuntime(canvasElement: HTMLCanvasElement) {
// Register modular features before engine boot
await loadPluginBlend(tsParticles);
await loadPluginInteractivity(tsParticles);
await loadPluginMove(tsParticles);
// Load a curated palette package (lazy-loaded, tree-shakeable)
await loadPaletteNeon(tsParticles);
const animationSpec = buildParticleConfig();
await tsParticles.load({ id: "main-canvas", options: animationSpec });
}
2. Unified Paint System with Variants
The paint configuration replaces the fragmented color and stroke objects. It supports a single definition or an array of variants. When an array is provided, the engine randomly assigns a variant to each particle at instantiation, eliminating the need for duplicate particle groups.
function buildParticleConfig() {
return {
fullScreen: { enable: false },
background: { color: "#0a0a12" },
hdr: true, // Enables Display P3 auto-detection with sRGB fallback
palette: "neon", // Applies pre-configured blend modes and color curves
particles: {
number: { value: 300, density: { enable: true, area: 800 } },
paint: [
{
fill: { enable: true, color: { value: "#ff3366" } },
stroke: { color: { value: "#ffffff" }, width: 1.5, opacity: 0.6 }
},
{
fill: { enable: true, color: { value: "#00e5ff" } },
stroke: { color: { value: "#ff3366" }, width: 2, opacity: 0.8 }
},
{
fill: { enable: false }, // Hollow particles
stroke: { color: { value: "#a855f7" }, width: 3, opacity: 1 }
}
],
move: {
enable: true,
speed: { min: 0.5, max: 2.5, easing: "ease-in-out-sigmoid" },
direction: "none",
outModes: { default: "bounce" }
},
size: { value: { min: 2, max: 8 } }
},
interactivity: {
events: {
onHover: { enable: true, mode: "repulse" },
onClick: { enable: true, mode: "push" }
},
modes: {
repulse: { distance: 120, duration: 0.4 },
push: { quantity: 4 }
}
}
};
}
3. Rendering Pipeline & OffscreenCanvas
The engine automatically attempts to transfer the canvas context to a worker thread using transferControlToOffscreen(). When successful, paint operations execute off the main thread, preventing UI jank during heavy particle updates. The desynchronized: true context flag further reduces compositor latency by bypassing the standard display sync cycle.
async function setupRenderingSurface(targetId: string) {
const container = document.getElementById(targetId);
if (!container) throw new Error("Canvas container not found");
const canvas = document.createElement("canvas");
canvas.style.width = "100%";
canvas.style.height = "100%";
container.appendChild(canvas);
// Engine handles OffscreenCanvas transfer internally
// No manual worker setup required unless custom compositing is needed
await initializeParticleRuntime(canvas);
}
Architecture Rationale
- Modular Plugin Registration: Decoupling features into standalone packages allows bundlers to eliminate dead code. Only imported plugins are included in the final build.
- SpatialHashGrid over QuadTree: Hash grids provide constant-time average lookups for proximity queries. This is critical when particle counts exceed 500, where QuadTree node splitting causes memory churn and cache misses.
- Paint Variant Arrays: Randomized variant assignment at creation time removes the need for manual particle grouping or post-initialization mutation. The engine handles distribution internally.
- HDR/P3 Auto-Detection: The runtime checks
CSS.supports("color", "color(display-p3 1 0 0)") and switches the canvas color space accordingly. Fallback to sRGB is automatic, ensuring consistent behavior across legacy browsers.
Pitfall Guide
1. Plugin Registration Order Mismatch
Explanation: Loading plugins after calling tsParticles.load() causes silent failures. The engine validates available modules during initialization.
Fix: Always await plugin loaders before invoking load(). Group registrations in a dedicated bootstrap function.
2. Expecting Sequential Paint Variant Assignment
Explanation: Paint variants are randomly distributed per particle, not assigned sequentially or by index. Developers expecting deterministic ordering will see inconsistent visual output.
Fix: If deterministic grouping is required, use separate particle arrays with explicit groups configuration instead of paint variants.
3. Ignoring HDR Fallback Behavior
Explanation: Enabling hdr: true on non-P3 displays does not break rendering, but color values may appear desaturated if authored exclusively for P3 gamut.
Fix: Author colors using standard hex/RGB values. The engine automatically maps them to the available gamut. Reserve P3-specific color strings (color(display-p3 ...)) for known P3-only deployments.
4. Overloading the Main Thread with Easing Calculations
Explanation: Complex easing functions like ease-in-out-sigmoid or Gaussian curves require per-frame interpolation. Running them on the main thread alongside DOM updates can cause frame drops.
Fix: Delegate easing calculations to the particle move plugin. Keep easing definitions lightweight and avoid chaining multiple easing modifiers on the same axis.
5. Misconfiguring SpatialHashGrid Cell Size
Explanation: The hash grid partitions space into uniform cells. If cells are too large, proximity checks degrade to brute-force. If too small, memory overhead increases due to empty cell allocation.
Fix: Tune the grid resolution based on average particle radius. A cell size of 2-3x the maximum particle diameter typically yields optimal query performance.
6. Palette Package Bloat
Explanation: Importing entire palette directories instead of specific packages defeats tree-shaking and increases bundle size.
Fix: Use direct imports like import { loadPaletteNeon } from "@tsparticles/palette-neon". Avoid wildcard imports or index re-exports.
7. OffscreenCanvas Context Loss on Resize
Explanation: Transferring control to an offscreen canvas can detach the rendering context during aggressive DOM resizing or tab visibility changes.
Fix: Listen for visibilitychange and resize events. Call tsParticles.refresh() or reinitialize the canvas context when the page regains focus.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-density scene (>1000 particles) | SpatialHashGrid + OffscreenCanvas | Eliminates O(log n) lookup overhead and moves paint off main thread | Lower CPU usage, reduced frame drops |
| Marketing landing page with strict bundle limits | Modular plugin registration + selective palettes | Tree-shaking removes unused features and color curves | Smaller initial payload, faster TTI |
| Cross-browser compatibility required | sRGB fallback + hdr: true | Auto-detects P3 support and degrades gracefully to sRGB | Zero additional maintenance overhead |
| Deterministic particle grouping needed | Explicit particle arrays + groups | Paint variants are randomized; groups provide index-level control | Slightly higher config complexity, predictable output |
| Interactive hover/repulse effects | @tsparticles/plugin-interactivity + desynchronized: true | Reduces input latency and keeps compositor in sync with pointer events | Smoother UX, minimal GPU overhead |
Configuration Template
import { tsParticles } from "@tsparticles/engine";
import { loadPluginBlend } from "@tsparticles/plugin-blend";
import { loadPluginInteractivity } from "@tsparticles/plugin-interactivity";
import { loadPluginMove } from "@tsparticles/plugin-move";
import { loadPaletteDeepOcean } from "@tsparticles/palette-deep-ocean";
export async function deployParticleScene(containerId: string) {
const container = document.getElementById(containerId);
if (!container) return;
const canvas = document.createElement("canvas");
canvas.style.display = "block";
canvas.style.width = "100%";
canvas.style.height = "100%";
container.appendChild(canvas);
await loadPluginBlend(tsParticles);
await loadPluginInteractivity(tsParticles);
await loadPluginMove(tsParticles);
await loadPaletteDeepOcean(tsParticles);
const sceneConfig = {
fullScreen: { enable: false },
background: { color: "#020617" },
hdr: true,
palette: "deep-ocean",
particles: {
number: { value: 250, density: { enable: true, area: 800 } },
paint: [
{
fill: { enable: true, color: { value: "#38bdf8" } },
stroke: { color: { value: "#0ea5e9" }, width: 1, opacity: 0.7 }
},
{
fill: { enable: false },
stroke: { color: { value: "#7dd3fc" }, width: 2.5, opacity: 0.9 }
}
],
move: {
enable: true,
speed: { min: 0.8, max: 2.2, easing: "ease-in-out-smoothstep" },
outModes: { default: "out" }
},
size: { value: { min: 3, max: 9 } }
},
interactivity: {
events: {
onHover: { enable: true, mode: "bubble" },
onClick: { enable: true, mode: "repulse" }
},
modes: {
bubble: { distance: 150, size: 6, duration: 0.5 },
repulse: { distance: 100, duration: 0.3 }
}
}
};
await tsParticles.load({ id: containerId, options: sceneConfig });
}
Quick Start Guide
- Install Core & Plugins: Run
npm install @tsparticles/engine @tsparticles/plugin-blend @tsparticles/plugin-interactivity @tsparticles/plugin-move
- Create Canvas Container: Add a
<div id="particle-root"></div> to your markup and ensure it has explicit width/height or flex layout constraints.
- Bootstrap Runtime: Import the engine and required plugins, await their registration, then call
tsParticles.load() with your configuration object.
- Verify Rendering: Open DevTools, check the Performance tab for main-thread activity, and confirm that
OffscreenCanvas transfer succeeded. Adjust desynchronized and hdr flags if color or latency issues appear.
- Iterate & Optimize: Swap paint variants, tune easing functions, and profile bundle size. Replace monolithic imports with selective plugin loaders as your scene complexity grows.