← all components
#01

Interactive constellation field

A living constellation of glowing particles that drift and link to their neighbors. Your cursor becomes an energy orb that swirls nearby particles into a vortex and lights the web around it in electric purple. Canvas-based, 60fps, with additive glow and motion trails. Available in React or plain HTML, Canvas and JavaScript.

ReactCanvasJSHTML
// LIVE DEMO

Move your cursor

Particles swirl into a glowing web around you.

share this component →FacebookX / TwitterLinkedIn
// THE CODE
React
"use client";
import { useEffect, useRef } from "react";

export default function ConstellationField() {
  const ref = useRef(null);
  useEffect(() => {
    const cv = ref.current, ctx = cv.getContext("2d");
    if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    let w, h, raf, t = 0;
    const mouse = { x: -9999, y: -9999, active: false };
    const DPR = Math.min(devicePixelRatio || 1, 2);
    let pts = [];

    function resize() {
      const r = cv.getBoundingClientRect(); w = r.width; h = r.height;
      cv.width = w * DPR; cv.height = h * DPR; ctx.setTransform(DPR,0,0,DPR,0,0);
      const n = Math.min(120, Math.floor(w * h / 7000));
      pts = Array.from({ length: n }, () => ({
        x: Math.random()*w, y: Math.random()*h,
        vx: (Math.random()-.5)*.35, vy: (Math.random()-.5)*.35,
        r: 1.2 + Math.random()*2, hue: 195 + Math.random()*55,
      }));
    }
    function frame() {
      t += .01;
      ctx.fillStyle = "rgba(6,9,18,.28)"; ctx.fillRect(0,0,w,h);
      ctx.globalCompositeOperation = "lighter";
      for (const p of pts) {
        if (mouse.active) {
          const dx = mouse.x - p.x, dy = mouse.y - p.y, d2 = dx*dx + dy*dy, R = 170;
          if (d2 < R*R) {
            const d = Math.sqrt(d2)||1, f = (R-d)/R;
            p.vx += (dx/d)*f*.5 - (dy/d)*f*.4;
            p.vy += (dy/d)*f*.5 + (dx/d)*f*.4;
          }
        }
        p.vx *= .94; p.vy *= .94; p.x += p.vx; p.y += p.vy;
        if (p.x<0) p.x=w; if (p.x>w) p.x=0; if (p.y<0) p.y=h; if (p.y>h) p.y=0;
      }
      for (let i=0;i<pts.length;i++) for (let j=i+1;j<pts.length;j++) {
        const a=pts[i], b=pts[j], dx=a.x-b.x, dy=a.y-b.y, dist=Math.sqrt(dx*dx+dy*dy);
        if (dist < 130) {
          let al=(1-dist/130)*.4, hue=200;
          if (mouse.active) {
            const mx=(a.x+b.x)/2-mouse.x, my=(a.y+b.y)/2-mouse.y, md=Math.sqrt(mx*mx+my*my);
            if (md<170){ al+=(1-md/170)*.7; hue=260; }
          }
          ctx.strokeStyle=`hsla(${hue},90%,65%,${al})`;
          ctx.lineWidth = al>.5 ? 1.4 : .8;
          ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke();
        }
      }
      for (const p of pts) {
        const g = ctx.createRadialGradient(p.x,p.y,0,p.x,p.y,p.r*4);
        g.addColorStop(0,`hsla(${p.hue},95%,70%,.9)`); g.addColorStop(1,`hsla(${p.hue},95%,70%,0)`);
        ctx.fillStyle=g; ctx.beginPath(); ctx.arc(p.x,p.y,p.r*4,0,7); ctx.fill();
      }
      if (mouse.active) {
        const pulse = 22 + Math.sin(t*6)*4;
        const g = ctx.createRadialGradient(mouse.x,mouse.y,0,mouse.x,mouse.y,pulse*2.4);
        g.addColorStop(0,"rgba(125,211,252,.9)"); g.addColorStop(.4,"rgba(129,140,248,.4)");
        g.addColorStop(1,"rgba(129,140,248,0)");
        ctx.fillStyle=g; ctx.beginPath(); ctx.arc(mouse.x,mouse.y,pulse*2.4,0,7); ctx.fill();
      }
      ctx.globalCompositeOperation = "source-over";
      raf = requestAnimationFrame(frame);
    }
    const onMove = e => { const r=cv.getBoundingClientRect();
      mouse.x=e.clientX-r.left; mouse.y=e.clientY-r.top; mouse.active=true; };
    const onLeave = () => mouse.active = false;
    resize(); frame();
    addEventListener("resize", resize);
    cv.addEventListener("pointermove", onMove); cv.addEventListener("pointerleave", onLeave);
    return () => { cancelAnimationFrame(raf); removeEventListener("resize", resize);
      cv.removeEventListener("pointermove", onMove); cv.removeEventListener("pointerleave", onLeave); };
  }, []);
  return <canvas ref={ref} className="w-full h-[360px] rounded-2xl bg-[#060912]" />;
}
HTML + Canvas + JS
<!-- HTML -->
<canvas id="field" style="width:100%;height:360px;border-radius:16px;background:#060912"></canvas>

<script>
const cv = document.getElementById('field'), ctx = cv.getContext('2d');
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
  let w, h, t = 0; const mouse = { x:-9999, y:-9999, active:false };
  const DPR = Math.min(devicePixelRatio || 1, 2); let pts = [];
  function resize() {
    const r = cv.getBoundingClientRect(); w = r.width; h = r.height;
    cv.width = w*DPR; cv.height = h*DPR; ctx.setTransform(DPR,0,0,DPR,0,0);
    const n = Math.min(120, Math.floor(w*h/7000));
    pts = Array.from({length:n}, () => ({ x:Math.random()*w, y:Math.random()*h,
      vx:(Math.random()-.5)*.35, vy:(Math.random()-.5)*.35, r:1.2+Math.random()*2, hue:195+Math.random()*55 }));
  }
  function frame() {
    t += .01; ctx.fillStyle='rgba(6,9,18,.28)'; ctx.fillRect(0,0,w,h);
    ctx.globalCompositeOperation='lighter';
    for (const p of pts) {
      if (mouse.active) { const dx=mouse.x-p.x, dy=mouse.y-p.y, d2=dx*dx+dy*dy, R=170;
        if (d2<R*R){ const d=Math.sqrt(d2)||1, f=(R-d)/R;
          p.vx += (dx/d)*f*.5-(dy/d)*f*.4; p.vy += (dy/d)*f*.5+(dx/d)*f*.4; } }
      p.vx*=.94; p.vy*=.94; p.x+=p.vx; p.y+=p.vy;
      if(p.x<0)p.x=w; if(p.x>w)p.x=0; if(p.y<0)p.y=h; if(p.y>h)p.y=0;
    }
    for (let i=0;i<pts.length;i++) for (let j=i+1;j<pts.length;j++) {
      const a=pts[i],b=pts[j],dx=a.x-b.x,dy=a.y-b.y,dist=Math.sqrt(dx*dx+dy*dy);
      if (dist<130){ let al=(1-dist/130)*.4, hue=200;
        if (mouse.active){ const mx=(a.x+b.x)/2-mouse.x,my=(a.y+b.y)/2-mouse.y,md=Math.sqrt(mx*mx+my*my);
          if(md<170){ al+=(1-md/170)*.7; hue=260; } }
        ctx.strokeStyle='hsla('+hue+',90%,65%,'+al+')'; ctx.lineWidth = al>.5?1.4:.8;
        ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke(); }
    }
    for (const p of pts) { const g=ctx.createRadialGradient(p.x,p.y,0,p.x,p.y,p.r*4);
      g.addColorStop(0,'hsla('+p.hue+',95%,70%,.9)'); g.addColorStop(1,'hsla('+p.hue+',95%,70%,0)');
      ctx.fillStyle=g; ctx.beginPath(); ctx.arc(p.x,p.y,p.r*4,0,7); ctx.fill(); }
    if (mouse.active) { const pulse=22+Math.sin(t*6)*4, g=ctx.createRadialGradient(mouse.x,mouse.y,0,mouse.x,mouse.y,pulse*2.4);
      g.addColorStop(0,'rgba(125,211,252,.9)'); g.addColorStop(.4,'rgba(129,140,248,.4)'); g.addColorStop(1,'rgba(129,140,248,0)');
      ctx.fillStyle=g; ctx.beginPath(); ctx.arc(mouse.x,mouse.y,pulse*2.4,0,7); ctx.fill(); }
    ctx.globalCompositeOperation='source-over'; requestAnimationFrame(frame);
  }
  cv.addEventListener('pointermove', e => { const r=cv.getBoundingClientRect();
    mouse.x=e.clientX-r.left; mouse.y=e.clientY-r.top; mouse.active=true; });
  cv.addEventListener('pointerleave', () => mouse.active=false);
  addEventListener('resize', resize); resize(); frame();
}
</script>
// HOW IT WORKS
  • 01Particles drift on a canvas; nearby ones are joined by lines whose brightness fades with distance.
  • 02The cursor applies a swirl force (pull toward it plus a tangential spin), so particles orbit into a vortex instead of just scattering.
  • 03Links near the cursor brighten and shift to purple, and a pulsing radial-gradient orb marks the cursor, drawn with 'lighter' blend mode for real glow.
  • 04Instead of clearing the canvas each frame, it paints a translucent fill, leaving soft motion trails. Respects prefers-reduced-motion.
Want a custom component for your product?
Let's build it →