---
name: interactive-particles
description: Use when you want "Interactive, techy, playful" - Particles react to cursor movement or clicks.
---

# Interactive Particles

> **Category:** Background  -  **Personality:** Interactive, techy, playful
>
> **Best use:** Hero sections, demos, portfolios
>
> **Ratings:** Professional 3/5 - Casual 5/5 - Exotic 5/5 - Visual impact 5/5 - Difficulty 4/5 - Performance cost 4/5

## What it is

A canvas particle field that **responds to the pointer**. Each dot has a "home" position; when the cursor comes within a radius the dots are pushed away (**repel**) or pulled in (**attract**), then spring elastically back home once the cursor leaves. Clicking fires a **burst** — an outward impulse plus a short-lived ring of sparks. Unlike a calm particle background (which just drifts) or a particle network (which draws connecting lines), this one is a playful, hands-on toy: it invites the visitor to move and click. Perfect for a techy hero, a product demo, or a portfolio splash.

## Dependencies / CDN

None - vanilla JS + Canvas 2D. The demo only loads display/body fonts, which 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=JetBrains+Mono:wght@400;500&family=Sora:wght@400;500;600;700&family=Unbounded:wght@600;700;800&display=swap" rel="stylesheet">
```

## HTML

A relatively-positioned stage, a full-bleed canvas behind the content, and your hero copy on top. The optional segmented control flips the pointer behaviour live:

```html
<div class="stage" id="stage">
  <canvas id="fx" aria-hidden="true"></canvas>      <!-- behind, pointer-events:none -->
  <div class="ui">
    <div class="seg" id="seg" role="group" aria-label="Pointer behaviour">
      <button type="button" data-mode="repel" class="is-on" aria-pressed="true">Repel</button>
      <button type="button" data-mode="attract" aria-pressed="false">Attract</button>
    </div>
    <h1>Interfaces that react to your every move.</h1>
    <p class="hint">Move your cursor &middot; click to burst</p>
  </div>
</div>
```

## CSS

The stage clips the canvas; the canvas is purely decorative so it ignores the pointer (the stage element listens instead). Additive (`lighter`) compositing in JS gives the dots their neon glow over the dark stage.

```css
.stage{position:relative; overflow:hidden; border-radius:28px; isolation:isolate;
  min-height:clamp(540px,72vh,700px); cursor:crosshair;
  background:radial-gradient(125% 120% at 50% -10%,#1a1436,#0c0a1b 52%,#070611);}
.stage canvas{position:absolute; inset:0; display:block; pointer-events:none;}  /* z below content */
.ui{position:relative; z-index:2; color:#fff;}                                  /* copy on top */
```

## JavaScript

The whole engine — DPR-aware canvas, a per-frame pointer force, a spring back to home, and a click burst. Listeners are bound to the **stage** so the effect never hijacks the rest of the page:

```js
(function(){
  var stage=document.getElementById('stage'), canvas=document.getElementById('fx');
  var ctx=canvas.getContext('2d');
  var reduce=matchMedia('(prefers-reduced-motion: reduce)').matches;
  var PALETTE=['#23e0ff','#ff3ea5','#9b5cff','#c6ff4a','#ffb84d'];
  var W,H,RADIUS, particles=[], sparks=[], mode='repel';
  var pointer={x:-9999,y:-9999,on:false};
  var rnd=function(a,b){return a+Math.random()*(b-a);};

  function seed(){
    var n=Math.round(Math.min(190,Math.max(46,(W*H)/9200)));   // density by area, capped
    particles=[];
    for(var i=0;i<n;i++){var hx=Math.random()*W,hy=Math.random()*H;
      particles.push({hx:hx,hy:hy,x:hx,y:hy,vx:0,vy:0,r:rnd(1.3,3.6),
        c:PALETTE[(Math.random()*PALETTE.length)|0],ph:Math.random()*6.283,sp:rnd(.25,.9)});}
  }
  function resize(){
    var r=stage.getBoundingClientRect(); W=r.width; H=r.height;
    var dpr=Math.min(devicePixelRatio||1,2);                   // cap DPR for fill-rate
    canvas.width=W*dpr; canvas.height=H*dpr;
    canvas.style.width=W+'px'; canvas.style.height=H+'px';
    ctx.setTransform(dpr,0,0,dpr,0,0);                         // draw in CSS pixels
    RADIUS=Math.max(85,Math.min(165,W*0.12)); seed();
    if(reduce) draw(0);                                        // one static frame
  }
  function burst(px,py){                                       // click impulse + sparks
    for(var i=0;i<particles.length;i++){var p=particles[i];
      var dx=p.x-px,dy=p.y-py,d=Math.hypot(dx,dy)||1; if(d<210){var f=(1-d/210)*14;p.vx+=dx/d*f;p.vy+=dy/d*f;}}
    for(var j=0;j<24;j++){var a=j/24*6.283+Math.random()*.3,s=rnd(3,7.5);
      sparks.push({x:px,y:py,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life:1,r:rnd(1.4,3),
        c:PALETTE[(Math.random()*PALETTE.length)|0]});}
    if(sparks.length>420) sparks.splice(0,sparks.length-420);
  }
  function draw(t){
    ctx.clearRect(0,0,W,H); ctx.globalCompositeOperation='lighter';   // additive glow
    for(var i=0;i<particles.length;i++){var p=particles[i];
      var tx=p.hx+Math.sin(t*p.sp+p.ph)*7, ty=p.hy+Math.cos(t*p.sp*0.9+p.ph)*7;  // ambient breathing
      if(pointer.on){var dx=p.x-pointer.x,dy=p.y-pointer.y,d=Math.hypot(dx,dy)||1;
        if(d<RADIUS){var f=(1-d/RADIUS); f=f*f*2.3*(mode==='repel'?1:-1); p.vx+=dx/d*f; p.vy+=dy/d*f;}}
      p.vx+=(tx-p.x)*0.014; p.vy+=(ty-p.y)*0.014;              // spring home
      p.vx*=0.86; p.vy*=0.86;                                  // friction
      var s2=p.vx*p.vx+p.vy*p.vy; if(s2>900){var k=30/Math.sqrt(s2);p.vx*=k;p.vy*=k;}  // clamp speed
      p.x+=p.vx; p.y+=p.vy;
      var spd=Math.min(1,Math.sqrt(s2)/7);                     // faster = bigger & brighter
      ctx.globalAlpha=0.5+spd*0.5; ctx.fillStyle=p.c;
      ctx.beginPath(); ctx.arc(p.x,p.y,p.r*(1+spd*0.85),0,6.283); ctx.fill();
    }
    for(var s,i2=sparks.length-1;i2>=0;i2--){s=sparks[i2];
      s.vx*=0.92; s.vy*=0.92; s.x+=s.vx; s.y+=s.vy; s.life-=0.024;
      if(s.life<=0){sparks.splice(i2,1);continue;}
      ctx.globalAlpha=s.life; ctx.fillStyle=s.c;
      ctx.beginPath(); ctx.arc(s.x,s.y,s.r,0,6.283); ctx.fill();
    }
    ctx.globalAlpha=1; ctx.globalCompositeOperation='source-over';
  }

  // Optional repel/attract toggle
  var seg=document.getElementById('seg');
  if(seg) seg.addEventListener('click',function(e){var b=e.target.closest('button[data-mode]');if(!b)return;
    mode=b.getAttribute('data-mode');
    [].forEach.call(seg.querySelectorAll('button'),function(x){var on=x===b;
      x.classList.toggle('is-on',on); x.setAttribute('aria-pressed',on?'true':'false');});});

  if(reduce){ resize(); addEventListener('resize',resize); return; }   // static, no loop, no reaction

  // Interaction — all bound to the stage, never to window
  stage.addEventListener('pointermove',function(e){var r=stage.getBoundingClientRect();
    pointer.x=e.clientX-r.left; pointer.y=e.clientY-r.top; pointer.on=true;});
  stage.addEventListener('pointerleave',function(){pointer.on=false;});
  stage.addEventListener('pointerup',function(e){if(e.pointerType!=='mouse')pointer.on=false;});  // touch
  stage.addEventListener('pointerdown',function(e){var r=stage.getBoundingClientRect();
    burst(e.clientX-r.left,e.clientY-r.top);});

  var raf=0,t0=performance.now();
  function frame(now){ draw((now-t0)/1000); raf=requestAnimationFrame(frame); }
  resize();
  new ResizeObserver(resize).observe(stage);                  // re-fit to the stage box
  new IntersectionObserver(function(es){es.forEach(function(en){                 // pause off-screen
    if(en.isIntersecting){if(!raf)raf=requestAnimationFrame(frame);} else {cancelAnimationFrame(raf);raf=0;}});
  },{threshold:0.01}).observe(stage);
})();
```

## How it works

- **Home + spring.** Every particle remembers its starting point (`hx,hy`). Each frame it accelerates back toward a slowly-wandering target (`(tx-x)*0.014`), so the field always re-forms after being disturbed. Friction (`v*=0.86`) bleeds the energy off smoothly.
- **Pointer force.** Within `RADIUS`, force scales with `(1 - d/RADIUS)²` (eased, strongest at the cursor) and is added along the vector between dot and pointer. The sign flips for repel vs attract.
- **Click burst.** `pointerdown` adds a one-shot outward impulse to nearby dots and spawns ~24 ephemeral spark particles that fade over ~0.7s — the reward for clicking.
- **Neon look.** Drawing with `globalCompositeOperation='lighter'` makes overlapping dots add their light, so the field glows against the dark stage. Speed nudges each dot's size and alpha up, so motion reads as energy.

## Customization

- **Vibe:** swap `PALETTE` (one calm hue = serene; the 5-colour set = playful confetti). Drop `'lighter'` back to `'source-over'` for flat, non-glowing dots.
- **Feel:** raise the `2.3` force multiplier or `RADIUS` for a stronger push; lower friction (`0.80`) for a snappier, springier field; raise the spring (`0.02`) to make dots race home.
- **Density:** change the `/9200` divisor (smaller = more dots) and the `190` cap.
- **Default behaviour:** start with `mode='attract'` for a gentler "gather toward me" feel, or remove the toggle entirely.

## Accessibility & performance

- **Reduced motion:** when `prefers-reduced-motion: reduce` is set, the script draws a single static frame and **attaches no pointer listeners and starts no loop** — no animation, no reaction.
- **DPR-aware:** the backing store is sized to `devicePixelRatio` (capped at 2) so dots stay crisp on retina without paying for 3× fill-rate.
- **Cheap to run:** ~50–190 simple `arc` fills, no shadows, no per-pixel work. An `IntersectionObserver` pauses the loop whenever the stage scrolls out of view.
- The canvas is `aria-hidden` decoration; all meaning lives in the real HTML on top, which stays fully readable and focusable.

## Gotchas

- **Bind to the stage, not `window`.** Listening on `window`/`document` makes the whole page react and leaks the effect outside its box. Convert `clientX/clientY` to stage-local coords with `getBoundingClientRect()`.
- **`pointer-events:none` on the canvas** so it never blocks clicks/hover on the buttons and links above it; the stage element receives the events.
- **Touch needs a manual reset.** `pointerleave` doesn't fire after a touch ends, so also clear `pointer.on` on `pointerup`/`pointercancel` (guarded by `pointerType`), or dots stay frozen mid-flee.
- **Re-seed on resize.** Particle homes are absolute pixels; without re-seeding on a `ResizeObserver`, dots cluster in the old corner after the stage changes size.
- **Don't draw connecting lines** — that's a *particle network*, a different effect. Keep this one dots-only so its identity (cursor-reactive playground) stays distinct.
- Resetting `globalAlpha` and `globalCompositeOperation` at the end of each frame matters; leaving `lighter` on will wash out anything else drawn to the canvas.
