---
name: particle-background
description: Use when you want "Techy, dynamic, immersive" - Adds animated dots, particles, or small elements in the background.
---

# Particle Background

> **Category:** Background  -  **Personality:** Techy, dynamic, immersive
>
> **Best use:** Tech, AI, security, gaming
>
> **Ratings:** Professional 4/5 - Casual 3/5 - Exotic 4/5 - Visual impact 4/5 - Difficulty 3/5 - Performance cost 4/5

## What it is

A field of small dots that drift slowly and twinkle behind your hero content, painted on a single `<canvas>` with the 2D API. Unlike a cursor-reactive particle field or a constellation (dots joined by lines), this one is **calm ambient drift** — nothing chases the pointer and nothing is connected, so it adds depth and a "live system" feel without stealing attention from the copy. It suits dark, techy hero sections for AI, security, infrastructure, fintech and gaming brands. It is DPR-aware (crisp on Retina), uses additive blending for a soft glow, and **pauses itself whenever it scrolls off-screen or the tab is hidden** so it never burns battery in the background.

## Dependencies / CDN

None - vanilla JS + one `<canvas>`. The demo only loads display/label fonts (optional):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Sora:wght@400;600;700;800&display=swap" rel="stylesheet">
```

## HTML

The canvas sits behind the content inside a positioned, clipped stage:

```html
<div class="pb-stage">
  <canvas class="pb-canvas" aria-hidden="true"></canvas>
  <div class="pb-content">
    <!-- your nav / hero / CTAs go here, on top of the particles -->
    <h2>Quiet by design. Relentless by default.</h2>
  </div>
</div>
```

## CSS

The effect only needs the canvas pinned behind the content. Everything else is theming.

```css
.pb-stage{
  position:relative; overflow:hidden; isolation:isolate;
  border-radius:26px; min-height:600px;
  display:flex; align-items:center; justify-content:center; padding:56px 28px;
  /* a dark, slightly varied backdrop makes the dots read */
  background:
    radial-gradient(120% 95% at 50% -12%, #16294a 0%, rgba(22,41,74,0) 55%),
    linear-gradient(180deg,#070c17,#05080f);
}
.pb-canvas{position:absolute; inset:0; width:100%; height:100%; z-index:0; display:block}
.pb-content{position:relative; z-index:1; width:100%; max-width:980px} /* above the field */
```

## JavaScript

The whole effect. Drop-in: it finds `.pb-stage` / `.pb-canvas`, sizes for DPR, drifts + twinkles the dots, and pauses when off-screen or hidden. `prefers-reduced-motion` paints one static scatter and never starts the loop.

```js
(function(){
  var stage  = document.querySelector('.pb-stage');
  var canvas = document.querySelector('.pb-canvas');
  if(!stage || !canvas) return;
  var ctx = canvas.getContext('2d'); if(!ctx) return;

  var REDUCE = !!(window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches);
  var dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 2)); // cap at 2 for fill-rate
  var COLORS = [[94,234,212],[45,212,191],[96,165,250],[167,139,250],[226,240,255]];
  var W=0, H=0, particles=[], raf=null, running=false, inView=false;

  function rnd(a,b){ return a + Math.random()*(b-a); }
  function pickColor(){
    var r = Math.random();
    if(r < 0.07) return COLORS[3];        // rare violet
    if(r < 0.14) return COLORS[4];        // rare white sparkle
    if(r < 0.30) return COLORS[2];        // some blue
    return COLORS[Math.random()<0.5?0:1]; // mostly teal/cyan
  }
  function spawn(){
    var glow = Math.random() < 0.07;                  // ~7% soft glowing motes
    var ang = Math.random()*Math.PI*2;
    var spd = glow ? rnd(0.02,0.08) : rnd(0.05,0.20); // px/frame — gentle
    return {
      x:Math.random()*W, y:Math.random()*H,
      r: glow ? rnd(2.2,4.4) : rnd(0.5,2.0),
      vx:Math.cos(ang)*spd,
      vy:Math.sin(ang)*spd - 0.015,                   // faint upward bias, like rising data
      baseA: glow ? rnd(0.16,0.32) : rnd(0.12,0.62),
      tw:Math.random()*Math.PI*2, twSpd:rnd(0.004,0.018), // slow twinkle
      col:pickColor(), glow:glow
    };
  }
  function target(){ return Math.max(36, Math.min(150, Math.round(W*H/8500))); } // count ∝ area
  function seed(){
    var n = target();
    while(particles.length < n) particles.push(spawn());
    if(particles.length > n) particles.length = n;
  }
  function resize(){
    var w = Math.max(1, Math.round(stage.clientWidth));
    var h = Math.max(1, Math.round(stage.clientHeight));
    var pw = W||w, ph = H||h; W=w; H=h;
    canvas.width = Math.round(w*dpr); canvas.height = Math.round(h*dpr); // backing store
    canvas.style.width = w+'px'; canvas.style.height = h+'px';          // CSS size
    ctx.setTransform(dpr,0,0,dpr,0,0);                                  // draw in CSS px
    if(particles.length && (pw!==w || ph!==h)){                        // keep field stable
      var sx=w/pw, sy=h/ph;
      for(var i=0;i<particles.length;i++){ particles[i].x*=sx; particles[i].y*=sy; }
    }
    seed();
    if(REDUCE) draw();
  }
  function step(){
    for(var i=0;i<particles.length;i++){
      var p=particles[i], m=6+p.r*6;
      p.x+=p.vx; p.y+=p.vy; p.tw+=p.twSpd;
      if(p.x<-m) p.x=W+m; else if(p.x>W+m) p.x=-m;   // wrap around edges
      if(p.y<-m) p.y=H+m; else if(p.y>H+m) p.y=-m;
    }
  }
  function draw(){
    ctx.globalCompositeOperation='source-over'; ctx.clearRect(0,0,W,H);
    ctx.globalCompositeOperation='lighter';          // additive glow on the dark stage
    for(var i=0;i<particles.length;i++){
      var p=particles[i], c=p.col;
      var a=p.baseA*(0.55+0.45*Math.sin(p.tw)); if(a<0) a=0;
      if(p.glow){
        var g=ctx.createRadialGradient(p.x,p.y,0,p.x,p.y,p.r*6);
        g.addColorStop(0,'rgba('+c[0]+','+c[1]+','+c[2]+','+a+')');
        g.addColorStop(1,'rgba('+c[0]+','+c[1]+','+c[2]+',0)');
        ctx.fillStyle=g; ctx.beginPath(); ctx.arc(p.x,p.y,p.r*6,0,6.283); ctx.fill();
        ctx.fillStyle='rgba('+c[0]+','+c[1]+','+c[2]+','+Math.min(1,a*1.6)+')';
        ctx.beginPath(); ctx.arc(p.x,p.y,p.r*0.9,0,6.283); ctx.fill();
      } else {
        ctx.fillStyle='rgba('+c[0]+','+c[1]+','+c[2]+','+a+')';
        ctx.beginPath(); ctx.arc(p.x,p.y,p.r,0,6.283); ctx.fill();
      }
    }
  }
  function frame(){ raf=requestAnimationFrame(frame); step(); draw(); }
  function start(){ if(running||REDUCE) return; running=true; raf=requestAnimationFrame(frame); }
  function stop(){ running=false; if(raf){ cancelAnimationFrame(raf); raf=null; } }

  resize();
  if(typeof ResizeObserver!=='undefined'){ new ResizeObserver(resize).observe(stage); }
  else { window.addEventListener('resize', resize); }

  if(REDUCE){ draw(); return; }                      // static scatter, no loop

  // pause when scrolled out of view…
  if('IntersectionObserver' in window){
    new IntersectionObserver(function(es){
      inView = es[0].isIntersecting;
      if(inView){ if(!document.hidden) start(); } else stop();
    }, {threshold:0}).observe(stage);
  } else { inView=true; start(); }
  // …and when the tab is hidden
  document.addEventListener('visibilitychange', function(){
    if(document.hidden) stop(); else if(inView) start();
  });
})();
```

## How it works

- **One canvas, many cheap circles.** Each particle is a plain object (`x, y, vx, vy, radius, colour, alpha, twinkle`). Every frame we nudge `x/y` by a tiny velocity, advance a `twinkle` phase, then redraw. No DOM nodes, no per-particle elements.
- **Calm drift, not interaction.** Velocities are a fraction of a pixel per frame with a faint upward bias, so the field breathes rather than races. There is deliberately **no pointer handler and no line-drawing** — that is what separates it from cursor-reactive and "constellation" variants.
- **DPR-aware sizing.** The canvas *backing store* is `cssPx × devicePixelRatio`, but `ctx.setTransform(dpr,0,0,dpr,0,0)` lets you keep drawing in CSS pixels, so dots stay razor-sharp on Retina without rewriting the maths. DPR is capped at 2 to bound fill-rate.
- **Additive glow.** `globalCompositeOperation = 'lighter'` makes overlapping dots add their light together on the dark backdrop, giving a soft bloom; a small share of "motes" get a radial-gradient halo for depth.
- **Self-stopping.** An `IntersectionObserver` cancels the `requestAnimationFrame` loop the moment the stage leaves the viewport and restarts it when it returns; `visibilitychange` does the same for background tabs.
- **Twinkle.** `alpha = baseAlpha * (0.55 + 0.45·sin(phase))` makes each dot gently pulse at its own rate.

## Customization

- **Density / calmness:** change the `W*H/8500` divisor — bigger = fewer, airier dots; smaller = denser. The `36…150` clamp keeps tiny and huge stages sane.
- **Speed:** edit the `spd` ranges in `spawn()`. Drop them toward `0.02–0.08` for a near-still "dust" feel; raise for more energy. Remove the `- 0.015` to kill the upward drift.
- **Palette:** swap the `COLORS` rows (RGB triplets) and the probabilities in `pickColor()`. One hue family reads calmest; add an accent for character.
- **Glow vs flat:** raise the `0.07` mote probability and `r` range for a lantern-like look, or delete the `if(p.glow)` branch for crisp pinpoint stars only.
- **Twinkle:** widen `twSpd` for livelier shimmer, or set `a = p.baseA` to disable it.

## Accessibility & performance

- **Reduced motion:** when `prefers-reduced-motion: reduce` is set, the script seeds the field and draws a **single static scatter**, then returns — no animation loop ever starts.
- **Battery / CPU:** the loop is paused off-screen (IntersectionObserver) and on hidden tabs (visibilitychange), so an unseen hero costs nothing. Capping DPR at 2 and keeping the count bounded keeps fill-rate in check on phones.
- **Decorative:** the canvas is `aria-hidden="true"` and carries no information, so screen readers skip it. Keep real content in `.pb-content` with sufficient contrast over the dark stage.
- **Layout cost:** drawing happens in CSS pixels via a transform, so there is no per-frame layout/reflow — only paint.

## Gotchas

- **Set BOTH sizes.** `canvas.width/height` (backing store, in device px) **and** `canvas.style.width/height` (CSS px) must be set, or the image is blurry or mis-scaled. Forgetting the DPR transform is the usual "why are my dots fuzzy on a Mac" bug.
- **Resize re-seeds, don't rebuild.** Re-creating the canvas context on every resize leaks/stutters. Scale existing particle positions and top-up/trim the array instead (as `resize()` does); a `ResizeObserver` on the stage is more reliable than `window.onresize` for a contained panel.
- **`'lighter'` can blow out.** Additive blending over a *light* background turns to mush; it relies on a dark stage. Reset to `'source-over'` before `clearRect` each frame (clearing under `'lighter'` is a no-op surprise).
- **Needs contrast behind text.** A dense bright field can fight your headline — keep alphas low, add a subtle radial vignette or `text-shadow`, and the particles stay ambient rather than noisy.
- **`overflow:hidden` + `border-radius`** on the stage needs `isolation:isolate` (a stacking context) so the canvas and any `backdrop-filter` panels composite cleanly inside the rounded clip.
