EmpireUI
Get Pro
← Blog8 min read#particle-background#react-animation#tsparticles

Particle Background in React: tsParticles and Canvas API

Add particle backgrounds to React with tsParticles or the Canvas API. Real code, real config, no fluff — hero sections that actually move.

Dark background with glowing particle dots connected by thin lines, representing a particle network animation effect

Why Particle Backgrounds Still Work in 2026

Honestly, particle backgrounds have survived every design trend cycle because they do something that static gradients can't — they make a page feel alive without demanding the user's attention. They stay in the periphery. They respond to mouse movement. They fill space without creating visual noise that competes with your actual content.

The catch? Most implementations you'll find on Stack Overflow are either five years stale, tangled in jQuery, or use a library that adds 300 KB to your bundle for what amounts to moving dots. We're going to do this properly with two approaches: tsParticles v2.12.0 (the maintained successor to particles.js) and a raw Canvas API component you can paste straight into a Next.js 14 app.

Neither approach requires a PhD in WebGL. The Canvas API path is maybe 80 lines of TypeScript. The tsParticles path is mostly JSON config. You pick based on your constraints — bundle size vs. configurability. Both are covered here.

Setting Up tsParticles in React

Install the two packages you actually need. Don't install the full monolithic tsparticles package — it's enormous. Instead, use the slim bundle with only the features you want.

npm install @tsparticles/react@3.0.0 @tsparticles/slim@3.0.0

The @tsparticles/slim preset ships with particles, links, and mouse interaction. That covers 95% of hero section use cases. If you need complex shapes or physics simulations, you'd reach for @tsparticles/engine directly and tree-shake from there. For a standard floating-dot background, slim is plenty. Wire it up in your component with initParticlesEngine to avoid re-initializing on every render.

tsParticles Component: Full Code Example

Here's a complete ParticleBackground component that initializes once, renders into a full-viewport container, and cleans up on unmount. The config values below are tuned for a dark SaaS hero — adjust color.value and opacity.value for light themes.

'use client';
import { useEffect, useMemo, useState } from 'react';
import Particles, { initParticlesEngine } from '@tsparticles/react';
import { loadSlim } from '@tsparticles/slim';
import type { ISourceOptions } from '@tsparticles/engine';

export function ParticleBackground() {
  const [init, setInit] = useState(false);

  useEffect(() => {
    initParticlesEngine(async (engine) => {
      await loadSlim(engine);
    }).then(() => setInit(true));
  }, []);

  const options: ISourceOptions = useMemo(() => ({
    background: { color: { value: '#0a0a0a' } },
    fpsLimit: 60,
    interactivity: {
      events: {
        onHover: { enable: true, mode: 'repulse' },
        resize: { enable: true },
      },
      modes: {
        repulse: { distance: 120, duration: 0.4 },
      },
    },
    particles: {
      color: { value: '#ffffff' },
      links: {
        color: 'rgba(255,255,255,0.15)',
        distance: 150,
        enable: true,
        opacity: 0.4,
        width: 1,
      },
      move: {
        enable: true,
        speed: 1.2,
        direction: 'none',
        outModes: { default: 'bounce' },
      },
      number: { value: 80, density: { enable: true } },
      opacity: { value: 0.5 },
      size: { value: { min: 1, max: 3 } },
    },
    detectRetina: true,
  }), []);

  if (!init) return null;

  return (
    <Particles
      id="hero-particles"
      options={options}
      className="absolute inset-0 -z-10"
    />
  );
}

A few things worth noting in that config. fpsLimit: 60 keeps it from chewing through battery on mobile. The link color uses rgba(255,255,255,0.15) instead of a solid color — that's intentional, because fully opaque lines look cheap and dominate the composition. The outModes: bounce setting keeps particles on screen instead of wrapping around, which looks cleaner on contained hero sections. You can also try aurora-style animated backgrounds if you want something more fluid and less geometric.

Raw Canvas API: No Library Required

Here's the thing: you don't always want a library. If you're shipping a landing page where every kilobyte matters, or you want full control over the animation loop, the Canvas API is your friend. The following component weighs nothing — it's pure TypeScript and ships with zero dependencies.

'use client';
import { useEffect, useRef } from 'react';

interface Particle {
  x: number;
  y: number;
  vx: number;
  vy: number;
  radius: number;
  opacity: number;
}

export function CanvasParticles({ count = 70 }: { count?: number }) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext('2d');
    if (!ctx) return;

    let animId: number;
    const particles: Particle[] = [];

    const resize = () => {
      canvas.width = canvas.offsetWidth;
      canvas.height = canvas.offsetHeight;
    };
    resize();
    window.addEventListener('resize', resize);

    for (let i = 0; i < count; i++) {
      particles.push({
        x: Math.random() * canvas.width,
        y: Math.random() * canvas.height,
        vx: (Math.random() - 0.5) * 1.4,
        vy: (Math.random() - 0.5) * 1.4,
        radius: Math.random() * 2 + 1,
        opacity: Math.random() * 0.5 + 0.2,
      });
    }

    const draw = () => {
      ctx.clearRect(0, 0, canvas.width, canvas.height);

      for (const p of particles) {
        p.x += p.vx;
        p.y += p.vy;
        if (p.x < 0 || p.x > canvas.width) p.vx *= -1;
        if (p.y < 0 || p.y > canvas.height) p.vy *= -1;

        ctx.beginPath();
        ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
        ctx.fillStyle = `rgba(255, 255, 255, ${p.opacity})`;
        ctx.fill();
      }

      // draw links between close particles
      for (let i = 0; i < particles.length; i++) {
        for (let j = i + 1; j < particles.length; j++) {
          const dx = particles[i].x - particles[j].x;
          const dy = particles[i].y - particles[j].y;
          const dist = Math.sqrt(dx * dx + dy * dy);
          if (dist < 120) {
            ctx.beginPath();
            ctx.moveTo(particles[i].x, particles[i].y);
            ctx.lineTo(particles[j].x, particles[j].y);
            ctx.strokeStyle = `rgba(255,255,255,${0.15 * (1 - dist / 120)})`;
            ctx.lineWidth = 0.8;
            ctx.stroke();
          }
        }
      }

      animId = requestAnimationFrame(draw);
    };

    draw();
    return () => {
      cancelAnimationFrame(animId);
      window.removeEventListener('resize', resize);
    };
  }, [count]);

  return (
    <canvas
      ref={canvasRef}
      className="absolute inset-0 w-full h-full -z-10"
    />
  );
}

The link opacity formula 0.15 * (1 - dist / 120) is worth explaining. As two particles drift apart, their connecting line fades out proportionally — so the network doesn't look like a hard graph, it looks organic. That one line of math does most of the aesthetic work. The O(n²) neighbor check is fine up to around 150 particles. Beyond that, you'd want a spatial index like a quadtree.

Performance Tuning: Frame Rate, Particle Count, and Mobile

How many particles is too many? On desktop, 80-120 particles with links enabled runs fine at 60 fps. Drop to 40-60 on mobile. The easiest way to handle this responsively is to read window.devicePixelRatio and scale your count accordingly — a device with a 3x pixel ratio on a small screen doesn't need 100 particles.

If you're using tsParticles, set detectRetina: true and it handles DPR scaling automatically. For the raw Canvas implementation, set canvas.width = canvas.offsetWidth * devicePixelRatio and ctx.scale(dpr, dpr) before drawing. This prevents blurry particles on retina displays — the kind of detail that separates polished implementations from quick prototypes.

Also consider will-change: transform on the canvas element's wrapper. It hints to the browser to promote that layer to the GPU compositor, which reduces repaint overhead. Don't go overboard with will-change — applying it to too many elements causes memory pressure — but for a full-viewport canvas that's animating constantly, it's appropriate. You can contrast this subtlety-first approach with more dramatic effects like shooting stars backgrounds when you want something with more motion energy.

Integrating with Tailwind v4 and Dark Mode

In Tailwind v4.0.2, the inset-0 and absolute utilities work the same as before, but the configuration structure changed significantly. If you're on v4, you're defining design tokens in CSS rather than tailwind.config.js. For particle backgrounds, this mostly doesn't matter — you're positioning via utility classes and the particle colors are set in JavaScript config, not Tailwind.

The one place Tailwind's dark mode matters here is if you want the particle background to adapt. Wrap your particle component with a conditional check on document.documentElement.classList.contains('dark') or use a useTheme hook if you've got one wired up. Then pass different background.color.value and particles.color.value to tsParticles based on the current theme. White particles on #0a0a0a for dark mode, dark particles (#1a1a2e at 30% opacity) on a light background for light mode. See how theme toggle patterns in React handle this cleanly if you need the full toggle setup.

One thing people miss: the canvas background color in tsParticles and your page background need to match, or you'll get a flash of wrong color on load. Either set background.color.value: 'transparent' in the tsParticles config and handle background via CSS, or make sure your root element's background-color matches before the canvas renders.

tsParticles vs Canvas API: Picking the Right Tool

When should you use tsParticles vs rolling your own canvas? If you need mouse interaction (repulse, attract, grab), polygon shapes, or you want to configure everything via JSON without writing animation loops, tsParticles wins. The ecosystem is active, the v3 API is stable, and the slim bundle is around 40 KB gzipped — acceptable for most apps.

If you're building a component library, shipping a micro-frontend, or you're on an aggressive performance budget, the raw Canvas approach is the better call. No peer dependencies. No version conflicts. It's 80 lines of code you own completely. What's the tradeoff? You write the interaction logic yourself if you want mouse effects.

There's also a middle path worth knowing about: the CSS @property animation technique for simple floating dots. No JavaScript at all. Works for purely decorative particles with no interaction. But it tops out quickly in terms of control — you can't do links between particles or responsive density without JS. For comparison, see the full landscape of free animated backgrounds for React to see where particles fit against other approaches like aurora and spotlight effects.

Accessibility and Reduced Motion

Here's something that often gets skipped: the prefers-reduced-motion media query. A significant percentage of users have vestibular disorders or simply find constant motion distracting. Animating 80 particles nonstop for these users is actively harmful UX.

In the Canvas component, check window.matchMedia('(prefers-reduced-motion: reduce)').matches before starting the animation loop. If it returns true, either render a static scatter of dots (no movement, no animation frame loop) or render nothing at all. In tsParticles, set reducedMotion: { enable: true, minimumFpsLimit: 10 } in your options — this automatically slows or stops animation when the OS preference is set.

For the tsParticles approach, add this to the root of your options object: motion: { disable: true, reduce: { factor: 4, value: true } }. This tells tsParticles to respect the OS setting automatically. It's one config key. There's no reason to skip it. Also add aria-hidden="true" to the particle container element — it's purely decorative and should be invisible to screen readers.

FAQ

Does tsParticles v3 work with Next.js App Router and server components?

Yes, but the component itself must be a client component — add 'use client' at the top. The initParticlesEngine call uses browser APIs (canvas, requestAnimationFrame) that aren't available server-side. The rest of your page can remain server components; just wrap the particle component in a Suspense boundary if you want a fallback while it initializes.

Why are my particles blurry on Retina/HiDPI displays?

The canvas physical pixel dimensions don't match the CSS display size by default. Fix it by setting canvas.width = canvas.offsetWidth * window.devicePixelRatio, canvas.height = canvas.offsetHeight * window.devicePixelRatio, then calling ctx.scale(devicePixelRatio, devicePixelRatio) before drawing. With tsParticles, just set detectRetina: true in your config — it handles this automatically.

How do I make the particle background transparent so the page background shows through?

In tsParticles, set background: { color: { value: 'transparent' } } in your options and position the canvas absolutely over your content using CSS. For the raw Canvas API component, don't fill the canvas background at all — just call clearRect each frame instead of fillRect with a color, and make sure the canvas element itself has no background-color style applied.

What's the maximum particle count before performance degrades noticeably?

With links enabled (the O(n²) neighbor check), you'll start seeing frame drops on mid-range mobile devices around 100-120 particles. Without links, you can push to 300+ particles comfortably. A practical rule: cap at 60 particles on mobile (check navigator.hardwareConcurrency < 4 as a rough proxy) and 100 on desktop. tsParticles' density config handles this relative to viewport area automatically when you set density.enable: true.

Can I trigger a particle burst animation on a button click?

With tsParticles, yes — use the particles.push or emitters feature. Access the tsParticles container instance via the particlesLoaded callback, then call container.addParticle() or configure an emitter that triggers on a custom event. For the raw Canvas approach, you'd maintain a separate burst array of short-lived particles with their own decay logic in the animation loop.

Is there a way to do particle backgrounds with CSS only, no JavaScript?

Sort of. You can animate individual elements using CSS @keyframes and create a scatter of dots with absolute positioning, but you're limited to maybe 20-30 elements before the DOM overhead becomes worse than canvas. There's no CSS equivalent for proximity-based line drawing between particles. For purely decorative, non-interactive scattered dots, CSS works. For anything with links or mouse interaction, you need canvas or WebGL.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

GSAP + React: Production-Ready Animation Without Side EffectsFramer Motion Layout Animations: Shared, Animate PresenceAurora UI Effects: Animated Northern Lights in CSS and ReactNeon Glow UI Components: Building Cyberpunk-Inspired React Apps