Particle Network — Free HTML CSS JS Constellation Snippet

Particle Network · Animations · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Drifting nodes
Slow velocities with edge bounce.
Distance-faded links
Lines brighten as nodes get closer.
Cursor attraction
Nodes link to the pointer within reach.
Width-scaled density
Node count fits the viewport.
HiDPI-sharp canvas
devicePixelRatio scaling.
Re-seed on resize
Field rebuilds for new dimensions.
Click-through content
Headline passes the cursor to the canvas.
No dependencies
Pure canvas and vanilla JS.

About this UI Snippet

Particle Network — Connected Constellation Reacting to the Cursor

Screenshot of the Particle Network snippet rendered live

The particle network is the classic interactive backdrop where dozens of dots drift across the screen and draw connecting lines whenever they come near each other, forming an ever-shifting constellation that also reaches toward your cursor. This snippet builds it on an HTML <canvas> with plain vanilla JavaScript — sharp on retina screens, with no library.

Drifting nodes

Each node is a point with a position and a slow random velocity. Every frame the loop advances each node by its velocity and bounces it off the edges by flipping the relevant velocity component when it crosses a boundary. This gives a gentle, perpetual drift with nodes wandering and ricocheting softly off the walls — alive but calm. Node count scales with viewport width (min(W, 900) / 12), so the field stays appropriately dense on phones and desktops alike, recomputed on resize.

Connecting nearby nodes

The defining behavior is the linking. A double loop checks every pair of nodes, and when two are within LINK pixels (130), it draws a line between them whose opacity is 1 - distance / LINK — so links are brightest when nodes are close and fade to nothing as they drift apart. The result is a web that continuously forms and dissolves as nodes move, the hallmark of the constellation effect. This pairwise check is O(n²), which is why keeping the node count modest matters for smoothness.

Reaching toward the cursor

The cursor participates in the network. For each node, the loop also measures the distance to the mouse and, within a larger radius (LINK * 1.5), draws a brighter line from the node to the cursor. So as you move, the nearby part of the web stretches toward your pointer like it is being attracted — the interactive touch that makes the backdrop feel responsive. When the pointer leaves, the mouse position is parked far off-screen so those links disappear.

Crisp on every display

Canvas is resolution-dependent, so resize() reads devicePixelRatio (capped at 2 for performance), sizes the backing buffer accordingly, and applies a setTransform so all drawing is done in CSS pixels but rendered at device resolution. Without this the dots and lines would look soft on HiDPI screens. The handler also re-seeds the node field for the new dimensions.

Layering

The canvas fills the hero behind the content, which sits above with pointer-events: none so moving over the headline still feeds the cursor interaction below. A radial background gradient gives the constellation a subtle glow at the center and darkens the edges.

Customizing it

Change LINK for a denser or sparser web, adjust the node count divisor and velocity range for more or fewer, faster or slower nodes, recolor via the COLOR RGB string, or widen the cursor reach. For large fields, swap the O(n²) check for a spatial grid. Pair it with an aurora text headline or a dot pattern section for a connected, modern hero.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSA drifting field of dots fills the hero behind the text.
  2. 2
    Watch the linksLines form between nearby nodes and fade as they part.
  3. 3
    Move your cursorNearby nodes reach out and link to the pointer.
  4. 4
    Resize the windowThe node count and canvas re-fit and stay crisp.
  5. 5
    Recolor the webChange the COLOR RGB string.
  6. 6
    Tune the densityAdjust LINK, node count, and velocity.

Real-world uses

Common Use Cases

Tech landing heroes
Backdrop for an aurora text headline.
Network and data sites
Visualize connectivity behind a feature tabs showcase.
AI and platform pages
Pair with a dot pattern section.
Conference microsites
An interactive backdrop near a shiny text badge.
Portfolio intros
A connected alternative to a minimal hero.
Canvas demos
A reference for constellation networks.

Got questions?

Frequently Asked Questions

A double loop checks every pair of nodes each frame, and when two are within the link radius it draws a line whose opacity is 1 minus distance over the radius. So links are brightest when nodes are close and fade out as they drift apart, producing a web that continually forms and dissolves as the nodes move.

For each node the loop also measures the distance to the mouse and, within a larger radius, draws a brighter line from the node to the cursor. As you move, the nearby part of the web stretches toward the pointer. When the pointer leaves, the mouse position is moved far off-screen so those links vanish.

Yes. The resize function reads devicePixelRatio (capped at 2), sizes the canvas backing buffer to the CSS size times that ratio, and applies a setTransform so drawing happens in CSS pixels but renders at device resolution. Without this, the dots and thin lines would look blurry on HiDPI displays.

It is O(n squared), since every pair of nodes is checked each frame, so the node count is kept modest and scales with viewport width. That is smooth for typical hero fields. For much larger networks you would replace the brute-force check with a spatial grid or quadtree to only compare nearby nodes.

Put the canvas behind your content with a ref and run resize plus the rAF draw loop in a mount effect, cancelling the frame and removing listeners on unmount. Keep nodes and the mouse position in refs, not state, so the loop does not trigger re-renders. The component is framework-agnostic; in Tailwind just position the canvas absolute inset-0 behind a relative content layer.

Particle Network — Export as HTML, React, Vue, Angular & Tailwind

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Particle Network</title>
  <style>
    *{box-sizing:border-box;margin:0;padding:0}
    body{font-family:system-ui,-apple-system,sans-serif;background:#05050d;color:#fff}
    
    .pn-hero{position:relative;min-height:100vh;overflow:hidden;display:flex;align-items:center;justify-content:center;text-align:center;background:radial-gradient(ellipse at 50% 40%,#10102a,#05050d)}
    .pn-canvas{position:absolute;inset:0;width:100%;height:100%}
    .pn-content{position:relative;z-index:1;padding:0 20px;max-width:600px;pointer-events:none}
    .pn-content h1{font-size:clamp(34px,7vw,64px);font-weight:900;letter-spacing:-.03em;text-shadow:0 4px 30px rgba(0,0,0,.5)}
    .pn-content p{margin-top:14px;font-size:16px;color:#9a9ac0}
  </style>
</head>
<body>
  <section class="pn-hero">
    <canvas class="pn-canvas" id="pnCanvas" aria-hidden="true"></canvas>
    <div class="pn-content">
      <h1>Everything connected</h1>
      <p>A drifting constellation that links nearby nodes and reaches toward your cursor.</p>
    </div>
  </section>
  <script>
    var canvas = document.getElementById('pnCanvas');
    var ctx = canvas.getContext('2d');
    var W, H, dpr, nodes = [], mouse = { x: -9999, y: -9999 };
    var LINK = 130, COLOR = '129,140,248';
    
    function resize() {
      dpr = Math.min(devicePixelRatio || 1, 2);
      W = canvas.clientWidth; H = canvas.clientHeight;
      canvas.width = W * dpr; canvas.height = H * dpr;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      var count = Math.round(Math.min(W, 900) / 12);   // density scales with width
      nodes = [];
      for (var i = 0; i < count; i++) {
        nodes.push({ x: Math.random() * W, y: Math.random() * H,
          vx: (Math.random() - 0.5) * 0.4, vy: (Math.random() - 0.5) * 0.4 });
      }
    }
    window.addEventListener('resize', resize);
    resize();
    
    var hero = document.querySelector('.pn-hero');
    hero.addEventListener('pointermove', function (e) {
      var r = canvas.getBoundingClientRect();
      mouse.x = e.clientX - r.left; mouse.y = e.clientY - r.top;
    });
    hero.addEventListener('pointerleave', function () { mouse.x = mouse.y = -9999; });
    
    function draw() {
      ctx.clearRect(0, 0, W, H);
      // move nodes
      for (var i = 0; i < nodes.length; i++) {
        var n = nodes[i];
        n.x += n.vx; n.y += n.vy;
        if (n.x < 0 || n.x > W) n.vx *= -1;
        if (n.y < 0 || n.y > H) n.vy *= -1;
      }
      // links between nearby nodes (and to the cursor)
      for (var a = 0; a < nodes.length; a++) {
        for (var b = a + 1; b < nodes.length; b++) {
          var dx = nodes[a].x - nodes[b].x, dy = nodes[a].y - nodes[b].y;
          var d = Math.sqrt(dx * dx + dy * dy);
          if (d < LINK) {
            ctx.strokeStyle = 'rgba(' + COLOR + ',' + (1 - d / LINK) * 0.5 + ')';
            ctx.lineWidth = 1;
            ctx.beginPath(); ctx.moveTo(nodes[a].x, nodes[a].y); ctx.lineTo(nodes[b].x, nodes[b].y); ctx.stroke();
          }
        }
        var mdx = nodes[a].x - mouse.x, mdy = nodes[a].y - mouse.y, md = Math.sqrt(mdx * mdx + mdy * mdy);
        if (md < LINK * 1.5) {
          ctx.strokeStyle = 'rgba(' + COLOR + ',' + (1 - md / (LINK * 1.5)) * 0.7 + ')';
          ctx.beginPath(); ctx.moveTo(nodes[a].x, nodes[a].y); ctx.lineTo(mouse.x, mouse.y); ctx.stroke();
        }
      }
      // dots
      ctx.fillStyle = 'rgba(' + COLOR + ',.9)';
      for (var k = 0; k < nodes.length; k++) {
        ctx.beginPath(); ctx.arc(nodes[k].x, nodes[k].y, 2, 0, Math.PI * 2); ctx.fill();
      }
      requestAnimationFrame(draw);
    }
    requestAnimationFrame(draw);
  </script>
</body>
</html>
Tailwind
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Particle Network</title>
  <!-- Tailwind CSS v3+ — arbitrary value classes (e.g. bg-[#0f172a]) are valid -->
  <script src="https://cdn.tailwindcss.com"></script>
  <style>
    * {
      box-sizing:border-box;margin:0;padding:0
    }

    body {
      font-family:system-ui,-apple-system,sans-serif;background:#05050d;color:#fff
    }

    .pn-content h1 {
      font-size:clamp(34px,7vw,64px);font-weight:900;letter-spacing:-.03em;text-shadow:0 4px 30px rgba(0,0,0,.5)
    }

    .pn-content p {
      margin-top:14px;font-size:16px;color:#9a9ac0
    }
  </style>
</head>
<body>
  <section class="pn-hero relative min-h-screen overflow-hidden flex items-center justify-center text-center [background:radial-gradient(ellipse_at_50%_40%,#10102a,#05050d)]">
    <canvas class="absolute inset-0 w-full h-full" id="pnCanvas" aria-hidden="true"></canvas>
    <div class="pn-content relative z-[1] py-0 px-5 max-w-[600px] pointer-events-none">
      <h1>Everything connected</h1>
      <p>A drifting constellation that links nearby nodes and reaches toward your cursor.</p>
    </div>
  </section>
  <script>
    var canvas = document.getElementById('pnCanvas');
    var ctx = canvas.getContext('2d');
    var W, H, dpr, nodes = [], mouse = { x: -9999, y: -9999 };
    var LINK = 130, COLOR = '129,140,248';
    
    function resize() {
      dpr = Math.min(devicePixelRatio || 1, 2);
      W = canvas.clientWidth; H = canvas.clientHeight;
      canvas.width = W * dpr; canvas.height = H * dpr;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      var count = Math.round(Math.min(W, 900) / 12);   // density scales with width
      nodes = [];
      for (var i = 0; i < count; i++) {
        nodes.push({ x: Math.random() * W, y: Math.random() * H,
          vx: (Math.random() - 0.5) * 0.4, vy: (Math.random() - 0.5) * 0.4 });
      }
    }
    window.addEventListener('resize', resize);
    resize();
    
    var hero = document.querySelector('.pn-hero');
    hero.addEventListener('pointermove', function (e) {
      var r = canvas.getBoundingClientRect();
      mouse.x = e.clientX - r.left; mouse.y = e.clientY - r.top;
    });
    hero.addEventListener('pointerleave', function () { mouse.x = mouse.y = -9999; });
    
    function draw() {
      ctx.clearRect(0, 0, W, H);
      // move nodes
      for (var i = 0; i < nodes.length; i++) {
        var n = nodes[i];
        n.x += n.vx; n.y += n.vy;
        if (n.x < 0 || n.x > W) n.vx *= -1;
        if (n.y < 0 || n.y > H) n.vy *= -1;
      }
      // links between nearby nodes (and to the cursor)
      for (var a = 0; a < nodes.length; a++) {
        for (var b = a + 1; b < nodes.length; b++) {
          var dx = nodes[a].x - nodes[b].x, dy = nodes[a].y - nodes[b].y;
          var d = Math.sqrt(dx * dx + dy * dy);
          if (d < LINK) {
            ctx.strokeStyle = 'rgba(' + COLOR + ',' + (1 - d / LINK) * 0.5 + ')';
            ctx.lineWidth = 1;
            ctx.beginPath(); ctx.moveTo(nodes[a].x, nodes[a].y); ctx.lineTo(nodes[b].x, nodes[b].y); ctx.stroke();
          }
        }
        var mdx = nodes[a].x - mouse.x, mdy = nodes[a].y - mouse.y, md = Math.sqrt(mdx * mdx + mdy * mdy);
        if (md < LINK * 1.5) {
          ctx.strokeStyle = 'rgba(' + COLOR + ',' + (1 - md / (LINK * 1.5)) * 0.7 + ')';
          ctx.beginPath(); ctx.moveTo(nodes[a].x, nodes[a].y); ctx.lineTo(mouse.x, mouse.y); ctx.stroke();
        }
      }
      // dots
      ctx.fillStyle = 'rgba(' + COLOR + ',.9)';
      for (var k = 0; k < nodes.length; k++) {
        ctx.beginPath(); ctx.arc(nodes[k].x, nodes[k].y, 2, 0, Math.PI * 2); ctx.fill();
      }
      requestAnimationFrame(draw);
    }
    requestAnimationFrame(draw);
  </script>
</body>
</html>
React
import React, { useEffect } from 'react';

// CSS — optionally move to ParticleNetwork.module.css
const css = `
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#05050d;color:#fff}

.pn-hero{position:relative;min-height:100vh;overflow:hidden;display:flex;align-items:center;justify-content:center;text-align:center;background:radial-gradient(ellipse at 50% 40%,#10102a,#05050d)}
.pn-canvas{position:absolute;inset:0;width:100%;height:100%}
.pn-content{position:relative;z-index:1;padding:0 20px;max-width:600px;pointer-events:none}
.pn-content h1{font-size:clamp(34px,7vw,64px);font-weight:900;letter-spacing:-.03em;text-shadow:0 4px 30px rgba(0,0,0,.5)}
.pn-content p{margin-top:14px;font-size:16px;color:#9a9ac0}
`;

export default function ParticleNetwork() {
  // Auto-generated escape hatch: the original snippet's vanilla JS runs once
  // after mount and queries the rendered DOM. For idiomatic React, lift this
  // into state + handlers.
  useEffect(() => {
    const _listeners = [];
    const _originalAddEventListener = EventTarget.prototype.addEventListener;
    EventTarget.prototype.addEventListener = function(type, listener, options) {
      _listeners.push({ target: this, type, listener, options });
      return _originalAddEventListener.call(this, type, listener, options);
    };
    var canvas = document.getElementById('pnCanvas');
    var ctx = canvas.getContext('2d');
    var W, H, dpr, nodes = [], mouse = { x: -9999, y: -9999 };
    var LINK = 130, COLOR = '129,140,248';
    
    function resize() {
      dpr = Math.min(devicePixelRatio || 1, 2);
      W = canvas.clientWidth; H = canvas.clientHeight;
      canvas.width = W * dpr; canvas.height = H * dpr;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      var count = Math.round(Math.min(W, 900) / 12);   // density scales with width
      nodes = [];
      for (var i = 0; i < count; i++) {
        nodes.push({ x: Math.random() * W, y: Math.random() * H,
          vx: (Math.random() - 0.5) * 0.4, vy: (Math.random() - 0.5) * 0.4 });
      }
    }
    window.addEventListener('resize', resize);
    resize();
    
    var hero = document.querySelector('.pn-hero');
    hero.addEventListener('pointermove', function (e) {
      var r = canvas.getBoundingClientRect();
      mouse.x = e.clientX - r.left; mouse.y = e.clientY - r.top;
    });
    hero.addEventListener('pointerleave', function () { mouse.x = mouse.y = -9999; });
    
    function draw() {
      ctx.clearRect(0, 0, W, H);
      // move nodes
      for (var i = 0; i < nodes.length; i++) {
        var n = nodes[i];
        n.x += n.vx; n.y += n.vy;
        if (n.x < 0 || n.x > W) n.vx *= -1;
        if (n.y < 0 || n.y > H) n.vy *= -1;
      }
      // links between nearby nodes (and to the cursor)
      for (var a = 0; a < nodes.length; a++) {
        for (var b = a + 1; b < nodes.length; b++) {
          var dx = nodes[a].x - nodes[b].x, dy = nodes[a].y - nodes[b].y;
          var d = Math.sqrt(dx * dx + dy * dy);
          if (d < LINK) {
            ctx.strokeStyle = 'rgba(' + COLOR + ',' + (1 - d / LINK) * 0.5 + ')';
            ctx.lineWidth = 1;
            ctx.beginPath(); ctx.moveTo(nodes[a].x, nodes[a].y); ctx.lineTo(nodes[b].x, nodes[b].y); ctx.stroke();
          }
        }
        var mdx = nodes[a].x - mouse.x, mdy = nodes[a].y - mouse.y, md = Math.sqrt(mdx * mdx + mdy * mdy);
        if (md < LINK * 1.5) {
          ctx.strokeStyle = 'rgba(' + COLOR + ',' + (1 - md / (LINK * 1.5)) * 0.7 + ')';
          ctx.beginPath(); ctx.moveTo(nodes[a].x, nodes[a].y); ctx.lineTo(mouse.x, mouse.y); ctx.stroke();
        }
      }
      // dots
      ctx.fillStyle = 'rgba(' + COLOR + ',.9)';
      for (var k = 0; k < nodes.length; k++) {
        ctx.beginPath(); ctx.arc(nodes[k].x, nodes[k].y, 2, 0, Math.PI * 2); ctx.fill();
      }
      requestAnimationFrame(draw);
    }
    requestAnimationFrame(draw);
    // Cleanup: restore addEventListener and remove all listeners
    return () => {
      EventTarget.prototype.addEventListener = _originalAddEventListener;
      for (const { target, type, listener, options } of _listeners) {
        target.removeEventListener(type, listener, options);
      }
    };
  }, []);

  return (
    <>
      <style>{css}</style>
      <section className="pn-hero">
        <canvas className="pn-canvas" id="pnCanvas" aria-hidden="true"></canvas>
        <div className="pn-content">
          <h1>Everything connected</h1>
          <p>A drifting constellation that links nearby nodes and reaches toward your cursor.</p>
        </div>
      </section>
    </>
  );
}
React + Tailwind
import React, { useEffect } from 'react';
// Requires Tailwind CSS v3+ — https://tailwindcss.com/docs/installation
// Arbitrary value classes (e.g. bg-[#0f172a]) are valid Tailwind v3+

export default function ParticleNetwork() {
  // Auto-generated escape hatch: the original snippet's vanilla JS runs once
  // after mount and queries the rendered DOM. For idiomatic React, lift this
  // into state + handlers.
  useEffect(() => {
    const _listeners = [];
    const _originalAddEventListener = EventTarget.prototype.addEventListener;
    EventTarget.prototype.addEventListener = function(type, listener, options) {
      _listeners.push({ target: this, type, listener, options });
      return _originalAddEventListener.call(this, type, listener, options);
    };
    var canvas = document.getElementById('pnCanvas');
    var ctx = canvas.getContext('2d');
    var W, H, dpr, nodes = [], mouse = { x: -9999, y: -9999 };
    var LINK = 130, COLOR = '129,140,248';
    
    function resize() {
      dpr = Math.min(devicePixelRatio || 1, 2);
      W = canvas.clientWidth; H = canvas.clientHeight;
      canvas.width = W * dpr; canvas.height = H * dpr;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      var count = Math.round(Math.min(W, 900) / 12);   // density scales with width
      nodes = [];
      for (var i = 0; i < count; i++) {
        nodes.push({ x: Math.random() * W, y: Math.random() * H,
          vx: (Math.random() - 0.5) * 0.4, vy: (Math.random() - 0.5) * 0.4 });
      }
    }
    window.addEventListener('resize', resize);
    resize();
    
    var hero = document.querySelector('.pn-hero');
    hero.addEventListener('pointermove', function (e) {
      var r = canvas.getBoundingClientRect();
      mouse.x = e.clientX - r.left; mouse.y = e.clientY - r.top;
    });
    hero.addEventListener('pointerleave', function () { mouse.x = mouse.y = -9999; });
    
    function draw() {
      ctx.clearRect(0, 0, W, H);
      // move nodes
      for (var i = 0; i < nodes.length; i++) {
        var n = nodes[i];
        n.x += n.vx; n.y += n.vy;
        if (n.x < 0 || n.x > W) n.vx *= -1;
        if (n.y < 0 || n.y > H) n.vy *= -1;
      }
      // links between nearby nodes (and to the cursor)
      for (var a = 0; a < nodes.length; a++) {
        for (var b = a + 1; b < nodes.length; b++) {
          var dx = nodes[a].x - nodes[b].x, dy = nodes[a].y - nodes[b].y;
          var d = Math.sqrt(dx * dx + dy * dy);
          if (d < LINK) {
            ctx.strokeStyle = 'rgba(' + COLOR + ',' + (1 - d / LINK) * 0.5 + ')';
            ctx.lineWidth = 1;
            ctx.beginPath(); ctx.moveTo(nodes[a].x, nodes[a].y); ctx.lineTo(nodes[b].x, nodes[b].y); ctx.stroke();
          }
        }
        var mdx = nodes[a].x - mouse.x, mdy = nodes[a].y - mouse.y, md = Math.sqrt(mdx * mdx + mdy * mdy);
        if (md < LINK * 1.5) {
          ctx.strokeStyle = 'rgba(' + COLOR + ',' + (1 - md / (LINK * 1.5)) * 0.7 + ')';
          ctx.beginPath(); ctx.moveTo(nodes[a].x, nodes[a].y); ctx.lineTo(mouse.x, mouse.y); ctx.stroke();
        }
      }
      // dots
      ctx.fillStyle = 'rgba(' + COLOR + ',.9)';
      for (var k = 0; k < nodes.length; k++) {
        ctx.beginPath(); ctx.arc(nodes[k].x, nodes[k].y, 2, 0, Math.PI * 2); ctx.fill();
      }
      requestAnimationFrame(draw);
    }
    requestAnimationFrame(draw);
    // Cleanup: restore addEventListener and remove all listeners
    return () => {
      EventTarget.prototype.addEventListener = _originalAddEventListener;
      for (const { target, type, listener, options } of _listeners) {
        target.removeEventListener(type, listener, options);
      }
    };
  }, []);

  return (
    <>
      <style>{`
* {
  box-sizing:border-box;margin:0;padding:0
}

body {
  font-family:system-ui,-apple-system,sans-serif;background:#05050d;color:#fff
}

.pn-content h1 {
  font-size:clamp(34px,7vw,64px);font-weight:900;letter-spacing:-.03em;text-shadow:0 4px 30px rgba(0,0,0,.5)
}

.pn-content p {
  margin-top:14px;font-size:16px;color:#9a9ac0
}
      `}</style>
      <section className="pn-hero relative min-h-screen overflow-hidden flex items-center justify-center text-center [background:radial-gradient(ellipse_at_50%_40%,#10102a,#05050d)]">
        <canvas className="absolute inset-0 w-full h-full" id="pnCanvas" aria-hidden="true"></canvas>
        <div className="pn-content relative z-[1] py-0 px-5 max-w-[600px] pointer-events-none">
          <h1>Everything connected</h1>
          <p>A drifting constellation that links nearby nodes and reaches toward your cursor.</p>
        </div>
      </section>
    </>
  );
}
Vue
<template>
  <section class="pn-hero">
    <canvas class="pn-canvas" id="pnCanvas" aria-hidden="true"></canvas>
    <div class="pn-content">
      <h1>Everything connected</h1>
      <p>A drifting constellation that links nearby nodes and reaches toward your cursor.</p>
    </div>
  </section>
</template>

<script setup>
import { onMounted } from 'vue';

let canvas;
let ctx;
var W, H, dpr, nodes = [], mouse = { x: -9999, y: -9999 };
var LINK = 130, COLOR = '129,140,248';
function resize() {
  dpr = Math.min(devicePixelRatio || 1, 2);
  W = canvas.clientWidth; H = canvas.clientHeight;
  canvas.width = W * dpr; canvas.height = H * dpr;
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  var count = Math.round(Math.min(W, 900) / 12);   // density scales with width
  nodes = [];
  for (var i = 0; i < count; i++) {
    nodes.push({ x: Math.random() * W, y: Math.random() * H,
      vx: (Math.random() - 0.5) * 0.4, vy: (Math.random() - 0.5) * 0.4 });
  }
}
let hero;
function draw() {
  ctx.clearRect(0, 0, W, H);
  // move nodes
  for (var i = 0; i < nodes.length; i++) {
    var n = nodes[i];
    n.x += n.vx; n.y += n.vy;
    if (n.x < 0 || n.x > W) n.vx *= -1;
    if (n.y < 0 || n.y > H) n.vy *= -1;
  }
  // links between nearby nodes (and to the cursor)
  for (var a = 0; a < nodes.length; a++) {
    for (var b = a + 1; b < nodes.length; b++) {
      var dx = nodes[a].x - nodes[b].x, dy = nodes[a].y - nodes[b].y;
      var d = Math.sqrt(dx * dx + dy * dy);
      if (d < LINK) {
        ctx.strokeStyle = 'rgba(' + COLOR + ',' + (1 - d / LINK) * 0.5 + ')';
        ctx.lineWidth = 1;
        ctx.beginPath(); ctx.moveTo(nodes[a].x, nodes[a].y); ctx.lineTo(nodes[b].x, nodes[b].y); ctx.stroke();
      }
    }
    var mdx = nodes[a].x - mouse.x, mdy = nodes[a].y - mouse.y, md = Math.sqrt(mdx * mdx + mdy * mdy);
    if (md < LINK * 1.5) {
      ctx.strokeStyle = 'rgba(' + COLOR + ',' + (1 - md / (LINK * 1.5)) * 0.7 + ')';
      ctx.beginPath(); ctx.moveTo(nodes[a].x, nodes[a].y); ctx.lineTo(mouse.x, mouse.y); ctx.stroke();
    }
  }
  // dots
  ctx.fillStyle = 'rgba(' + COLOR + ',.9)';
  for (var k = 0; k < nodes.length; k++) {
    ctx.beginPath(); ctx.arc(nodes[k].x, nodes[k].y, 2, 0, Math.PI * 2); ctx.fill();
  }
  requestAnimationFrame(draw);
}

onMounted(() => {
  canvas = document.getElementById('pnCanvas');
  ctx = canvas.getContext('2d');
  window.addEventListener('resize', resize);
  resize();
  hero = document.querySelector('.pn-hero');
  hero.addEventListener('pointermove', function (e) {
    var r = canvas.getBoundingClientRect();
    mouse.x = e.clientX - r.left; mouse.y = e.clientY - r.top;
  });
  hero.addEventListener('pointerleave', function () { mouse.x = mouse.y = -9999; });
  requestAnimationFrame(draw);
});
</script>

<style scoped>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#05050d;color:#fff}

.pn-hero{position:relative;min-height:100vh;overflow:hidden;display:flex;align-items:center;justify-content:center;text-align:center;background:radial-gradient(ellipse at 50% 40%,#10102a,#05050d)}
.pn-canvas{position:absolute;inset:0;width:100%;height:100%}
.pn-content{position:relative;z-index:1;padding:0 20px;max-width:600px;pointer-events:none}
.pn-content h1{font-size:clamp(34px,7vw,64px);font-weight:900;letter-spacing:-.03em;text-shadow:0 4px 30px rgba(0,0,0,.5)}
.pn-content p{margin-top:14px;font-size:16px;color:#9a9ac0}
</style>
Angular
// @ts-nocheck
// Note: vanilla JS DOM manipulation is preserved as-is inside ngAfterViewInit().
// For idiomatic Angular, replace document.getElementById() with @ViewChild() refs
// and move state into component properties with two-way binding.
import { Component, AfterViewInit, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-particle-network',
  standalone: true,
  imports: [CommonModule],
  encapsulation: ViewEncapsulation.None,
  template: `
    <section class="pn-hero">
      <canvas class="pn-canvas" id="pnCanvas" aria-hidden="true"></canvas>
      <div class="pn-content">
        <h1>Everything connected</h1>
        <p>A drifting constellation that links nearby nodes and reaches toward your cursor.</p>
      </div>
    </section>
  `,
  styles: [`
    *{box-sizing:border-box;margin:0;padding:0}
    body{font-family:system-ui,-apple-system,sans-serif;background:#05050d;color:#fff}
    
    .pn-hero{position:relative;min-height:100vh;overflow:hidden;display:flex;align-items:center;justify-content:center;text-align:center;background:radial-gradient(ellipse at 50% 40%,#10102a,#05050d)}
    .pn-canvas{position:absolute;inset:0;width:100%;height:100%}
    .pn-content{position:relative;z-index:1;padding:0 20px;max-width:600px;pointer-events:none}
    .pn-content h1{font-size:clamp(34px,7vw,64px);font-weight:900;letter-spacing:-.03em;text-shadow:0 4px 30px rgba(0,0,0,.5)}
    .pn-content p{margin-top:14px;font-size:16px;color:#9a9ac0}
  `]
})
export class ParticleNetworkComponent implements AfterViewInit {
  ngAfterViewInit(): void {
    var canvas = document.getElementById('pnCanvas');
    var ctx = canvas.getContext('2d');
    var W, H, dpr, nodes = [], mouse = { x: -9999, y: -9999 };
    var LINK = 130, COLOR = '129,140,248';
    
    function resize() {
      dpr = Math.min(devicePixelRatio || 1, 2);
      W = canvas.clientWidth; H = canvas.clientHeight;
      canvas.width = W * dpr; canvas.height = H * dpr;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      var count = Math.round(Math.min(W, 900) / 12);   // density scales with width
      nodes = [];
      for (var i = 0; i < count; i++) {
        nodes.push({ x: Math.random() * W, y: Math.random() * H,
          vx: (Math.random() - 0.5) * 0.4, vy: (Math.random() - 0.5) * 0.4 });
      }
    }
    window.addEventListener('resize', resize);
    resize();
    
    var hero = document.querySelector('.pn-hero');
    hero.addEventListener('pointermove', function (e) {
      var r = canvas.getBoundingClientRect();
      mouse.x = e.clientX - r.left; mouse.y = e.clientY - r.top;
    });
    hero.addEventListener('pointerleave', function () { mouse.x = mouse.y = -9999; });
    
    function draw() {
      ctx.clearRect(0, 0, W, H);
      // move nodes
      for (var i = 0; i < nodes.length; i++) {
        var n = nodes[i];
        n.x += n.vx; n.y += n.vy;
        if (n.x < 0 || n.x > W) n.vx *= -1;
        if (n.y < 0 || n.y > H) n.vy *= -1;
      }
      // links between nearby nodes (and to the cursor)
      for (var a = 0; a < nodes.length; a++) {
        for (var b = a + 1; b < nodes.length; b++) {
          var dx = nodes[a].x - nodes[b].x, dy = nodes[a].y - nodes[b].y;
          var d = Math.sqrt(dx * dx + dy * dy);
          if (d < LINK) {
            ctx.strokeStyle = 'rgba(' + COLOR + ',' + (1 - d / LINK) * 0.5 + ')';
            ctx.lineWidth = 1;
            ctx.beginPath(); ctx.moveTo(nodes[a].x, nodes[a].y); ctx.lineTo(nodes[b].x, nodes[b].y); ctx.stroke();
          }
        }
        var mdx = nodes[a].x - mouse.x, mdy = nodes[a].y - mouse.y, md = Math.sqrt(mdx * mdx + mdy * mdy);
        if (md < LINK * 1.5) {
          ctx.strokeStyle = 'rgba(' + COLOR + ',' + (1 - md / (LINK * 1.5)) * 0.7 + ')';
          ctx.beginPath(); ctx.moveTo(nodes[a].x, nodes[a].y); ctx.lineTo(mouse.x, mouse.y); ctx.stroke();
        }
      }
      // dots
      ctx.fillStyle = 'rgba(' + COLOR + ',.9)';
      for (var k = 0; k < nodes.length; k++) {
        ctx.beginPath(); ctx.arc(nodes[k].x, nodes[k].y, 2, 0, Math.PI * 2); ctx.fill();
      }
      requestAnimationFrame(draw);
    }
    requestAnimationFrame(draw);

    // Bind inline-handler functions to the component so the template can call them
    Object.assign(this, { resize, draw });
  }
}