Particle Network Background with Connecting Lines

A canvas particle field on a deep cobalt gradient. Dots of different sizes drift slowly, and any two that come close are joined by a line that fades with distance, so the field keeps forming and breaking small constellations. A bright node follows the pointer and pulls lines toward itself; with no pointer it wanders by itself so the page is never still. The hero on top has a headline, two buttons and a latency card, and collapses to one column on phones.

Code

import { useEffect, useRef } from "react";
import { motion, useReducedMotion } from "framer-motion";

// Tune the field here.
const FIELD = {
  density: 9500, // one dot per this many square pixels (lower = more dots)
  maxDots: 150,
  linkDistance: 140, // dots closer than this are joined by a line
  speed: 16, // drift in pixels per second
  dot: "255, 255, 255",
  accent: "125, 211, 252", // lines drawn to the pointer
};

const regions = [
  { name: "Frankfurt", code: "eu-central", ms: 11 },
  { name: "Virginia", code: "us-east", ms: 18 },
  { name: "Singapore", code: "ap-south", ms: 24 },
  { name: "Sao Paulo", code: "sa-east", ms: 31 },
];

type Dot = { x: number; y: number; vx: number; vy: number; r: number };

export default function ParticleNetworkBackgroundWithConnectingLines() {
  const hostRef = useRef<HTMLElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const reduce = useReducedMotion();

  useEffect(() => {
    const host = hostRef.current;
    const canvas = canvasRef.current;
    const ctx = canvas?.getContext("2d");
    if (!host || !canvas || !ctx) return;
    const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let w = 0;
    let h = 0;
    let dots: Dot[] = [];
    let raf = 0;
    let running = false;
    let visible = true;
    let last = 0;
    let clock = 0;
    const pointer = { x: 0, y: 0, tx: 0, ty: 0, live: false };

    const seed = () => {
      const count = Math.max(54, Math.min(FIELD.maxDots, Math.round((w * h) / FIELD.density)));
      dots = Array.from({ length: count }, () => {
        const angle = Math.random() * Math.PI * 2;
        const depth = 0.35 + Math.random() * 0.65; // nearer dots are bigger and faster
        return { x: Math.random() * w, y: Math.random() * h, vx: Math.cos(angle) * FIELD.speed * depth, vy: Math.sin(angle) * FIELD.speed * depth, r: 0.8 + depth * 1.9 };
      });
    };

    const draw = (dt: number) => {
      clock += dt;
      // With no real pointer, a ghost one wanders so the field always has a focal point.
      if (!pointer.live) {
        pointer.tx = w * (0.62 + Math.cos(clock * 0.45) * 0.24);
        pointer.ty = h * (0.5 + Math.sin(clock * 0.7) * 0.3);
      }
      const ease = dt ? 1 - Math.exp(-dt * 5) : 1;
      pointer.x += (pointer.tx - pointer.x) * ease;
      pointer.y += (pointer.ty - pointer.y) * ease;

      ctx.clearRect(0, 0, w, h);
      const link = FIELD.linkDistance;
      const reach = link * 1.5;
      for (const d of dots) {
        d.x += d.vx * dt;
        d.y += d.vy * dt;
        if (d.x < -20) d.x = w + 20;
        else if (d.x > w + 20) d.x = -20;
        if (d.y < -20) d.y = h + 20;
        else if (d.y > h + 20) d.y = -20;
      }
      ctx.lineWidth = 1;
      ctx.strokeStyle = `rgb(${FIELD.dot})`;
      for (let i = 0; i < dots.length; i++) {
        const a = dots[i];
        for (let j = i + 1; j < dots.length; j++) {
          const b = dots[j];
          const dx = a.x - b.x;
          const dy = a.y - b.y;
          if (dx > link || dx < -link || dy > link || dy < -link) continue;
          const dist = Math.sqrt(dx * dx + dy * dy);
          if (dist > link) continue;
          ctx.globalAlpha = (1 - dist / link) * 0.5;
          ctx.beginPath();
          ctx.moveTo(a.x, a.y);
          ctx.lineTo(b.x, b.y);
          ctx.stroke();
        }
      }
      ctx.strokeStyle = `rgb(${FIELD.accent})`;
      ctx.lineWidth = 1.2;
      for (const d of dots) {
        const dist = Math.hypot(d.x - pointer.x, d.y - pointer.y);
        if (dist > reach) continue;
        ctx.globalAlpha = (1 - dist / reach) * 0.85;
        ctx.beginPath();
        ctx.moveTo(d.x, d.y);
        ctx.lineTo(pointer.x, pointer.y);
        ctx.stroke();
      }
      for (const d of dots) {
        const near = Math.max(0, 1 - Math.hypot(d.x - pointer.x, d.y - pointer.y) / reach);
        ctx.globalAlpha = 0.45 + (d.r / 2.7) * 0.4 + near * 0.15;
        ctx.fillStyle = near > 0.05 ? `rgb(${FIELD.accent})` : `rgb(${FIELD.dot})`;
        ctx.beginPath();
        ctx.arc(d.x, d.y, d.r + near * 1.6, 0, Math.PI * 2);
        ctx.fill();
      }
      // The focal node, with a soft halo.
      const halo = ctx.createRadialGradient(pointer.x, pointer.y, 0, pointer.x, pointer.y, 90);
      halo.addColorStop(0, `rgba(${FIELD.accent}, 0.35)`);
      halo.addColorStop(1, `rgba(${FIELD.accent}, 0)`);
      ctx.globalAlpha = 1;
      ctx.fillStyle = halo;
      ctx.fillRect(pointer.x - 90, pointer.y - 90, 180, 180);
      ctx.fillStyle = "#fff";
      ctx.beginPath();
      ctx.arc(pointer.x, pointer.y, 3.2, 0, Math.PI * 2);
      ctx.fill();
    };

    const resize = () => {
      const rect = host.getBoundingClientRect();
      if (Math.abs(rect.width - w) < 1 && Math.abs(rect.height - h) < 1) return;
      const first = w === 0;
      w = rect.width;
      h = rect.height;
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.round(w * dpr);
      canvas.height = Math.round(h * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      seed();
      if (first) {
        pointer.x = w * 0.7;
        pointer.y = h * 0.45;
      }
      draw(0);
    };

    const frame = (now: number) => {
      const dt = Math.min((now - last) / 1000, 0.05);
      last = now;
      draw(dt);
      raf = requestAnimationFrame(frame);
    };
    const stop = () => {
      running = false;
      cancelAnimationFrame(raf);
    };
    const sync = () => {
      if (still || !visible || document.hidden) return stop();
      if (running) return;
      running = true;
      last = performance.now();
      raf = requestAnimationFrame(frame);
    };

    const onMove = (e: PointerEvent) => {
      const rect = host.getBoundingClientRect();
      pointer.tx = e.clientX - rect.left;
      pointer.ty = e.clientY - rect.top;
      pointer.live = true;
    };
    const onLeave = () => {
      pointer.live = false;
    };

    const ro = new ResizeObserver(resize);
    ro.observe(host);
    const io = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      sync();
    });
    io.observe(host);
    document.addEventListener("visibilitychange", sync);
    host.addEventListener("pointermove", onMove);
    host.addEventListener("pointerleave", onLeave);
    host.addEventListener("pointercancel", onLeave);
    host.addEventListener("pointerup", onLeave);
    resize();
    sync();

    return () => {
      stop();
      ro.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", sync);
      host.removeEventListener("pointermove", onMove);
      host.removeEventListener("pointerleave", onLeave);
      host.removeEventListener("pointercancel", onLeave);
      host.removeEventListener("pointerup", onLeave);
    };
  }, []);

  const rise = (delay: number) => ({
    initial: reduce ? false : { opacity: 0, y: 22 },
    animate: { opacity: 1, y: 0 },
    transition: { type: "spring" as const, stiffness: 90, damping: 18, delay },
  });

  return (
    <section ref={hostRef} className="relative isolate overflow-hidden bg-[#0a1f8f] text-white">
      <div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-20 bg-[radial-gradient(120%_90%_at_80%_10%,#2f5bff_0%,#1232c4_38%,#081466_72%,#050b3a_100%)]" />
      <canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 h-full w-full" />
      <div aria-hidden="true" className="pointer-events-none absolute inset-y-0 left-0 -z-10 w-full bg-gradient-to-r from-[#050b3a]/55 via-[#050b3a]/25 to-transparent lg:w-3/4 lg:from-[#050b3a]/85 lg:via-[#050b3a]/35" />

      <div className="mx-auto grid min-h-[680px] max-w-6xl items-center gap-12 px-6 py-20 lg:grid-cols-[1.15fr_0.85fr]">
        <div>
          <motion.p {...rise(0.05)} className="inline-flex items-center gap-2 rounded-md bg-white/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.14em] text-sky-100 ring-1 ring-inset ring-white/20">
            Private mesh networking
          </motion.p>
          <motion.h1 {...rise(0.12)} className="mt-6 max-w-xl text-4xl font-semibold leading-[1.05] tracking-tight sm:text-6xl">
            One network for every server you run
          </motion.h1>
          <motion.p {...rise(0.2)} className="mt-6 max-w-lg text-base leading-relaxed text-blue-100 sm:text-lg">
            Meshline links machines across clouds, offices and laptops into a single encrypted network. Each node finds the shortest path on its own, so there is no VPN gateway to babysit.
          </motion.p>
          <motion.div {...rise(0.28)} className="mt-9 flex flex-col gap-3 sm:flex-row">
            <a href="#" className="inline-flex h-12 items-center justify-center rounded-xl bg-white px-6 text-sm font-semibold text-blue-950 shadow-xl shadow-blue-950/40 transition hover:bg-sky-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-blue-800">
              Connect your first node
            </a>
            <a href="#" className="inline-flex h-12 items-center justify-center rounded-xl px-6 text-sm font-semibold text-white ring-1 ring-inset ring-white/35 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white">
              View the topology demo
            </a>
          </motion.div>
        </div>

        <motion.div {...rise(0.4)} className="rounded-2xl bg-[#050b3a]/55 p-5 shadow-2xl shadow-blue-950/50 ring-1 ring-white/15 backdrop-blur-md sm:p-6">
          <div className="flex items-center justify-between">
            <h2 className="text-sm font-semibold text-white">Live latency between regions</h2>
            <span className="inline-flex items-center gap-1.5 text-xs font-medium text-emerald-300">
              <span className="h-1.5 w-1.5 rounded-full bg-emerald-300" />
              All healthy
            </span>
          </div>
          <ul className="mt-5 space-y-4">
            {regions.map((r, i) => (
              <li key={r.code}>
                <div className="flex items-baseline justify-between text-sm">
                  <span className="font-medium text-white">
                    {r.name} <span className="ml-1 font-mono text-xs font-normal text-blue-200">{r.code}</span>
                  </span>
                  <span className="font-mono tabular-nums text-sky-200">{r.ms} ms</span>
                </div>
                <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-white/10">
                  <motion.div
                    className="h-full origin-left rounded-full bg-gradient-to-r from-sky-300 to-white"
                    style={{ width: `${(r.ms / 36) * 100}%` }}
                    initial={reduce ? false : { scaleX: 0 }}
                    animate={{ scaleX: 1 }}
                    transition={{ type: "spring", stiffness: 60, damping: 16, delay: 0.6 + i * 0.09 }}
                  />
                </div>
              </li>
            ))}
          </ul>
          <p className="mt-5 border-t border-white/10 pt-4 text-xs text-blue-200">214 nodes online across 9 regions</p>
        </motion.div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/particle-network-background-with-connecting-lines.json

When to use it

How to customize it

Accessibility

Questions

How are the connecting lines calculated?

Every frame the script compares each pair of dots. If the gap is under `linkDistance` it draws a line whose opacity is one minus the distance divided by `linkDistance`, so lines fade in as dots approach and fade out as they part. A cheap box test on the x and y difference skips the square root for pairs that are obviously too far apart.

Is checking every pair of dots slow?

With 150 dots that is about 11,000 comparisons a frame, which takes well under a millisecond. The count is capped by `maxDots` and scales with the area, so a phone draws far fewer dots than a desktop.

How does it stay sharp on high-density screens?

The canvas buffer is sized to the element multiplied by the device pixel ratio, capped at 2, and the context is scaled to match. Drawing code can then use ordinary CSS pixels.

Does the animation keep running when I scroll away?

No. An intersection observer and the page visibility event stop the animation frame loop when the section is off screen or the tab is hidden, and start it again when it returns.

Does it work on touch devices?

Yes. It listens to pointer events, so a finger dragging across the section moves the node. When the finger lifts, the node goes back to wandering.

Related: Starfield Background with Depth and Shooting Stars, Dot Matrix Background with Ripples and Pointer Spotlight, Animated Grid Background with Travelling Light Beams.

More animated backgrounds or all free UI components. Start for free to have a full site built around it.

More in Backgrounds

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap