---
name: particle-network
description: Use when you want "Techy, networked, futuristic" - Connects animated particles with dynamic lines.
---

# Particle Network

> **Category:** Advanced WebGL / Canvas  -  **Personality:** Techy, networked, futuristic
>
> **Best use:** Security, SaaS, data products
>
> **Ratings:** Professional 4/5 - Casual 3/5 - Exotic 4/5 - Visual impact 4/5 - Difficulty 4/5 - Performance cost 4/5

## What it is

A "constellation" or "mesh" effect: a field of slowly drifting points (nodes) where a **line is drawn between any two nodes that are closer than a threshold distance**, and the line fades as they drift apart. The pointer becomes a live node that **gently attracts** nearby points and **links** to them, so the graph reorganises around the cursor. It reads as a network, dependency graph or signal map — which is why security, infrastructure and data products lean on it.

It is the *connecting-lines* member of the particle family. Keep it distinct from a calm drifting **Particle Background** (no lines) and a springy cursor-reactive **Interactive Particles** field (no lines): here the **edges between nodes are the whole point**.

## Dependencies / CDN

**None — pure vanilla JS + the Canvas 2D API.** No library is needed (and none should be added — it would only add weight). The demo's display/body fonts are 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=Chakra+Petch:wght@400;500;600;700&family=Hanken+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
```

## HTML

A positioned stage with a full-bleed canvas behind the foreground content:

```html
<div class="pn-stage" id="pnStage">
  <canvas class="pn-canvas" id="pnCanvas" aria-hidden="true"></canvas>
  <!-- foreground content sits above the canvas (z-index > 0) -->
  <div class="pn-ui"> &hellip; nav / hero / HUD &hellip; </div>
</div>
```

The canvas is `aria-hidden` (decorative) and `pointer-events:none` so clicks/hover pass through to the stage, where the pointer listeners live.

## CSS

```css
.pn-stage{
  position:relative; overflow:hidden; border-radius:26px;
  min-height:600px; isolation:isolate; cursor:crosshair;
  /* a dark, varied backdrop so the additive glow + lines read */
  background:
    radial-gradient(115% 90% at 82% -12%, rgba(99,110,241,.22), transparent 56%),
    linear-gradient(180deg,#0a0e22, #05060e);
}
.pn-canvas{position:absolute; inset:0; width:100%; height:100%;
  z-index:0; display:block; pointer-events:none}   /* clicks fall through to the stage */
.pn-ui{position:relative; z-index:2}                 /* foreground above the canvas */
```

## JavaScript

The full network — drift + proximity links + pointer attract/link, DPR-aware, paused off-screen, static under reduced-motion. (The live demo layers labelled "hub" nodes and an FPS/links HUD on top of this same core.)

```js
(function(){
  var stage=document.getElementById('pnStage'),
      canvas=document.getElementById('pnCanvas');
  if(!stage||!canvas)return;
  var ctx=canvas.getContext('2d'); if(!ctx)return;

  var REDUCE=!!(window.matchMedia&&matchMedia('(prefers-reduced-motion: reduce)').matches);
  var NODE='150,170,255', LINE='112,140,255', ACCENT='255,191,82';  // periwinkle / indigo / amber
  var W=0,H=0,DPR=1,LINK=150,LINK2=LINK*LINK,ATTRACT=190,PULL=0.45,MAXV=1.7,MAXV2=MAXV*MAXV;
  var nodes=[]; var pointer={x:-9999,y:-9999,on:false};

  function rnd(a,b){return a+Math.random()*(b-a);}

  function seed(){                                    // density scales with the stage area
    var n=Math.round(Math.min(90,Math.max(36,(W*H)/13000)));
    nodes=[];
    for(var i=0;i<n;i++){
      var bvx=rnd(-0.16,0.16),bvy=rnd(-0.16,0.16);    // bvx/bvy = the node's ambient drift
      nodes.push({x:rnd(0,W),y:rnd(0,H),bvx:bvx,bvy:bvy,vx:bvx,vy:bvy,r:rnd(1.1,2.6)});
    }
  }

  function resize(){
    var r=stage.getBoundingClientRect();
    W=Math.max(1,r.width);H=Math.max(1,r.height);
    DPR=Math.max(1,Math.min(window.devicePixelRatio||1,2));   // cap DPR for fill-rate
    canvas.width=Math.round(W*DPR);canvas.height=Math.round(H*DPR);
    canvas.style.width=W+'px';canvas.style.height=H+'px';
    ctx.setTransform(DPR,0,0,DPR,0,0);                        // then draw in CSS pixels
    LINK=Math.max(108,Math.min(156,W*0.12));LINK2=LINK*LINK;  // link distance scales too
    ATTRACT=Math.max(130,Math.min(230,W*0.17));
    seed();
    if(REDUCE)draw();                                         // one static frame
  }

  function step(){
    var pon=pointer.on,px=pointer.x,py=pointer.y,AR2=ATTRACT*ATTRACT,i,p;
    for(i=0;i<nodes.length;i++){
      p=nodes[i];
      if(pon){                                               // gentle pull toward the pointer
        var dx=px-p.x,dy=py-p.y,d2=dx*dx+dy*dy;
        if(d2<AR2){var d=Math.sqrt(d2)||1,f=(1-d/ATTRACT)*PULL;p.vx+=dx/d*f;p.vy+=dy/d*f;}
      }
      p.vx+=(p.bvx-p.vx)*0.05;p.vy+=(p.bvy-p.vy)*0.05;        // relax back to ambient drift
      var s2=p.vx*p.vx+p.vy*p.vy;
      if(s2>MAXV2){var k=MAXV/Math.sqrt(s2);p.vx*=k;p.vy*=k;} // clamp speed
      p.x+=p.vx;p.y+=p.vy;
      if(p.x<0){p.x=0;p.vx=Math.abs(p.vx);p.bvx=Math.abs(p.bvx);}        // bounce, don't wrap
      else if(p.x>W){p.x=W;p.vx=-Math.abs(p.vx);p.bvx=-Math.abs(p.bvx);}
      if(p.y<0){p.y=0;p.vy=Math.abs(p.vy);p.bvy=Math.abs(p.bvy);}
      else if(p.y>H){p.y=H;p.vy=-Math.abs(p.vy);p.bvy=-Math.abs(p.bvy);}
    }
  }

  function draw(){
    ctx.clearRect(0,0,W,H);
    ctx.globalCompositeOperation='lighter';                  // additive glow on the dark stage
    var i,j,p,q,dx,dy,d2,d;

    ctx.lineWidth=1;ctx.strokeStyle='rgb('+LINE+')';         // node-to-node links (O(n^2))
    for(i=0;i<nodes.length;i++){p=nodes[i];
      for(j=i+1;j<nodes.length;j++){q=nodes[j];
        dx=p.x-q.x;dy=p.y-q.y;d2=dx*dx+dy*dy;
        if(d2<LINK2){d=Math.sqrt(d2);ctx.globalAlpha=(1-d/LINK)*0.5;     // fade with distance
          ctx.beginPath();ctx.moveTo(p.x,p.y);ctx.lineTo(q.x,q.y);ctx.stroke();}}}

    if(pointer.on){                                          // pointer links (amber "live" edges)
      var PLR=LINK*1.3,PLR2=PLR*PLR;
      ctx.strokeStyle='rgb('+ACCENT+')';ctx.lineWidth=1.1;
      for(i=0;i<nodes.length;i++){p=nodes[i];
        dx=p.x-pointer.x;dy=p.y-pointer.y;d2=dx*dx+dy*dy;
        if(d2<PLR2){d=Math.sqrt(d2);ctx.globalAlpha=(1-d/PLR)*0.85;
          ctx.beginPath();ctx.moveTo(pointer.x,pointer.y);ctx.lineTo(p.x,p.y);ctx.stroke();}}
    }

    ctx.globalAlpha=0.9;ctx.fillStyle='rgb('+NODE+')';       // the nodes themselves
    for(i=0;i<nodes.length;i++){p=nodes[i];
      ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,6.283);ctx.fill();}
    ctx.globalAlpha=1;ctx.globalCompositeOperation='source-over';
  }

  var raf=0,inView=false;
  function loop(){raf=requestAnimationFrame(loop);step();draw();}
  function start(){if(raf||REDUCE)return;raf=requestAnimationFrame(loop);}
  function stop(){if(raf){cancelAnimationFrame(raf);raf=0;}}

  resize();
  if(window.ResizeObserver){new ResizeObserver(resize).observe(stage);}
  else{window.addEventListener('resize',resize);}
  if(REDUCE)return;                                          // static graph: no loop, no pointer

  stage.addEventListener('pointermove',function(e){          // canvas is pointer-events:none,
    var r=stage.getBoundingClientRect();                     // so listen on the stage
    pointer.x=e.clientX-r.left;pointer.y=e.clientY-r.top;pointer.on=true;});
  stage.addEventListener('pointerleave',function(){pointer.on=false;});

  if('IntersectionObserver' in window){                      // pause when off-screen / hidden
    new IntersectionObserver(function(es){inView=es[0].isIntersecting;
      if(inView&&!document.hidden)start();else stop();},{threshold:0.01}).observe(stage);
  }else{inView=true;start();}
  document.addEventListener('visibilitychange',function(){
    if(document.hidden)stop();else if(inView)start();});
})();
```

## How it works

- **Nodes drift.** Each node carries a small constant ambient velocity (`bvx/bvy`). Each frame its working velocity is nudged back toward that ambient drift (`v += (bv - v) * 0.05`), so transient forces fade away and the field never goes still.
- **Lines are proximity edges.** A double loop over node pairs draws a stroke whenever the squared distance is below `LINK²`. **Comparing squared distances avoids a `sqrt` per pair**; `sqrt` is only taken for the pairs that actually link, to compute the fade `alpha = (1 - d/LINK) * 0.5`. That distance-based alpha is what makes edges appear and dissolve smoothly.
- **The pointer is a node.** Within `ATTRACT`, each node gets a soft pull toward the cursor (capped by `MAXV`); within `LINK*1.3` the cursor draws its own brighter (amber) edges. So moving the mouse gathers and re-wires the local mesh — "you joined the network".
- **Additive glow.** Drawing with `globalCompositeOperation='lighter'` over a dark backdrop makes overlapping lines and dots bloom like signal, instead of looking flat.
- **Bounce, not wrap.** Nodes reflect off the edges. Wrapping (teleporting a node to the opposite side) would snap its long edges across the whole stage — fine for a dot field, ugly for a line network.

## Customization

- **Density:** the `(W*H)/13000` divisor sets node count; smaller = denser. Keep the `Math.min(90, …)` cap — link cost is O(n²).
- **Connection distance:** `LINK` (here `~12% of width`). Larger = a busier, more "woven" graph; smaller = sparse constellations.
- **Pointer feel:** `PULL` (attraction strength) and `ATTRACT` (its radius); set `PULL` to `0` for link-only, no gathering. Flip the sign of the pull term for a repeller.
- **Palette:** swap the `NODE` / `LINE` / `ACCENT` rgb triplets and the stage gradient. Cool mesh + one warm accent (used here: periwinkle/indigo + amber) keeps the cursor's edges legible against the field.
- **Link opacity / weight:** the `* 0.5` factor and `lineWidth`.

## Accessibility & performance

- **Respect reduced motion.** `matchMedia('(prefers-reduced-motion: reduce)')` short-circuits the animation: the graph is seeded and drawn **once** as a static constellation, with no rAF loop and no pointer reaction.
- **Pause when not visible.** An `IntersectionObserver` stops the loop when the stage scrolls off-screen, and `visibilitychange` stops it when the tab is hidden — no CPU/GPU burned in the background.
- **DPR-aware but capped.** `devicePixelRatio` is clamped to `2` and applied via `setTransform`, so the canvas is crisp on retina without paying for 3× fill-rate on phones; all drawing is in CSS pixels.
- **O(n²) is the cost.** Cap node count (≈40 mobile, ≈90 desktop). If you need hundreds of nodes, bucket them into a spatial grid and only test neighbouring cells.
- The canvas is decorative: `aria-hidden="true"`, and all meaning lives in the real DOM text behind it.

## Gotchas

- **`clearRect` every frame.** Skipping it (or painting a translucent rect instead) leaves comet-trails — wrong for a crisp network. Clear fully each frame.
- **`pointer-events:none` on the canvas, listeners on the stage.** Otherwise the canvas eats hover/click and your buttons stop working. Read coordinates from `getBoundingClientRect()` (not `offsetX`), since the canvas is scaled by DPR.
- **`'lighter'` only pays off on a dark backdrop.** On a light stage additive blending blows out to white; use `source-over` and darker line colours instead.
- **Reset composite/alpha at the end of `draw()`.** Leftover `globalAlpha`/`globalCompositeOperation` will silently corrupt the next frame (and any canvas text you add).
- **Wrapping vs bouncing:** wrapping nodes makes edges jump across the canvas. Bounce off the edges for a network.
- **Canvas text** (if you add hub labels) ignores the `prefers-reduced-motion` CSS rule and isn't selectable — keep it minimal and draw it with `source-over` + a shadow for legibility.
