Skip to content
Black FridayBlack Friday launch price: Lifetime $149 until 30 Nov. Regular price $299 from 1 Dec.ends in 15d 03:13:38Get Lifetime →
loopkit
freeNew272 lines · framer-motion

Hero Particle Field

A network of dots that reacts to the cursor. A full-width landing hero with a 2D-canvas particle field: drifting dots connect to nearby neighbours with faint lines and get pushed away from the pointer, behind a centred headline.

Use it for
Network, collaboration or realtime-data products where a light, reactive background says more than a static image.
Themes
Light + dark
License
Commercial use
Live
Live network

Every point, connected

A quiet particle field that reacts to the cursor — perfect for a dashboard, network or collaboration pitch.

tsx
"use client";

import * as React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";

function useReducedMotionSafe() {
  const prefersReduced = useReducedMotion();
  const [mounted, setMounted] = React.useState(false);
  React.useEffect(() => setMounted(true), []);
  return mounted ? !!prefersReduced : false;
}

/**
 * Full-width hero backed by a 2D-canvas particle field: dots drift slowly,
 * connect to nearby neighbours with faint lines, and are gently pushed away
 * from the pointer. No WebGL dependency, so it's the lightest background in
 * the set.
 */

export interface HeroParticleFieldProps {
  eyebrow?: string;
  headline?: string;
  subcopy?: string;
  primaryCta?: string;
  secondaryCta?: string;
  /** Particles per 10,000px² of canvas (0.4–2). */
  density?: number;
  /** Drift + repulsion speed multiplier. */
  speed?: number;
  /** Draw connecting lines between nearby particles. */
  connect?: boolean;
  className?: string;
}

type RGB = [number, number, number];
function hexToRgb(hex: string): RGB {
  const h = hex.replace("#", "");
  const v = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
  const n = parseInt(v, 16);
  if (Number.isNaN(n) || v.length < 6) return [17, 17, 15];
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function readColor(name: string, fallback: string): RGB {
  const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
  return hexToRgb(raw || fallback);
}

interface P {
  x: number;
  y: number;
  vx: number;
  vy: number;
  r: number;
}

export function HeroParticleField({
  eyebrow = "Live network",
  headline = "Every point, connected",
  subcopy = "A quiet particle field that reacts to the cursor — perfect for a dashboard, network or collaboration pitch.",
  primaryCta = "Try it free",
  secondaryCta = "See pricing",
  density = 1,
  speed = 1,
  connect = true,
  className,
}: HeroParticleFieldProps) {
  const reduce = useReducedMotionSafe();
  const hostRef = React.useRef<HTMLDivElement>(null);
  const canvasRef = React.useRef<HTMLCanvasElement>(null);

  React.useEffect(() => {
    const host = hostRef.current;
    const canvas = canvasRef.current;
    if (!host || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    let seed = 7;
    const rnd = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);

    let w = 0;
    let h = 0;
    let dpr = Math.min(window.devicePixelRatio || 1, 2);
    let particles: P[] = [];
    let ink: RGB = [17, 17, 15];
    let accent: RGB = [255, 90, 31];

    const applyTheme = () => {
      ink = readColor("--foreground", "#11110f");
      accent = readColor("--signal", "#ff5a1f");
    };
    applyTheme();
    const mo = new MutationObserver(applyTheme);
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });

    const seedParticles = () => {
      const count = Math.max(20, Math.min(420, Math.round(((w * h) / 10000) * density)));
      particles = Array.from({ length: count }, () => ({
        x: rnd() * w,
        y: rnd() * h,
        vx: (rnd() - 0.5) * 10,
        vy: (rnd() - 0.5) * 10,
        r: 1.2 + rnd() * 1.6,
      }));
    };

    const resize = () => {
      w = host.clientWidth;
      h = host.clientHeight;
      dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.max(1, Math.round(w * dpr));
      canvas.height = Math.max(1, Math.round(h * dpr));
      canvas.style.width = `${w}px`;
      canvas.style.height = `${h}px`;
      seedParticles();
    };
    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(host);

    const mouse = { x: -9999, y: -9999, active: false };
    const onMove = (e: PointerEvent) => {
      const r = host.getBoundingClientRect();
      mouse.x = e.clientX - r.left;
      mouse.y = e.clientY - r.top;
      mouse.active = true;
    };
    const onLeave = () => (mouse.active = false);
    host.addEventListener("pointermove", onMove);
    host.addEventListener("pointerleave", onLeave);

    let visible = true;
    const io = new IntersectionObserver(([e]) => (visible = e.isIntersecting));
    io.observe(host);

    const LINK_DIST = 110;
    const draw = (dt: number) => {
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, w, h);

      for (const p of particles) {
        p.x += p.vx * dt * speed;
        p.y += p.vy * dt * speed;
        if (mouse.active) {
          const dx = p.x - mouse.x;
          const dy = p.y - mouse.y;
          const d2 = dx * dx + dy * dy;
          if (d2 < 130 * 130) {
            const d = Math.max(8, Math.sqrt(d2));
            const f = ((130 - d) / 130) * 60;
            p.vx += (dx / d) * f * dt;
            p.vy += (dy / d) * f * dt;
          }
        }
        p.vx *= 0.985;
        p.vy *= 0.985;
        if (p.x < -20) p.x = w + 20;
        if (p.x > w + 20) p.x = -20;
        if (p.y < -20) p.y = h + 20;
        if (p.y > h + 20) p.y = -20;
      }

      if (connect) {
        ctx.lineWidth = 1;
        for (let i = 0; i < particles.length; i++) {
          for (let j = i + 1; j < particles.length; j++) {
            const a = particles[i];
            const b = particles[j];
            const dx = a.x - b.x;
            const dy = a.y - b.y;
            const d2 = dx * dx + dy * dy;
            if (d2 < LINK_DIST * LINK_DIST) {
              const alpha = (1 - Math.sqrt(d2) / LINK_DIST) * 0.22;
              ctx.strokeStyle = `rgba(${ink[0]},${ink[1]},${ink[2]},${alpha})`;
              ctx.beginPath();
              ctx.moveTo(a.x, a.y);
              ctx.lineTo(b.x, b.y);
              ctx.stroke();
            }
          }
        }
      }

      particles.forEach((p, i) => {
        const [cr, cg, cb] = i % 11 === 0 ? accent : ink;
        ctx.fillStyle = `rgba(${cr},${cg},${cb},${i % 11 === 0 ? 0.9 : 0.55})`;
        ctx.beginPath();
        ctx.arc(p.x, p.y, i % 11 === 0 ? p.r * 1.8 : p.r, 0, Math.PI * 2);
        ctx.fill();
      });
    };

    let raf = 0;
    let last = performance.now();
    const loop = () => {
      raf = requestAnimationFrame(loop);
      if (!visible || document.hidden) return;
      const now = performance.now();
      const dt = Math.min(0.05, (now - last) / 1000);
      last = now;
      draw(dt);
    };
    if (reduce) draw(0);
    else loop();

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      io.disconnect();
      mo.disconnect();
      host.removeEventListener("pointermove", onMove);
      host.removeEventListener("pointerleave", onLeave);
    };
  }, [density, speed, connect, reduce]);

  return (
    <section className={cn("relative isolate overflow-hidden bg-background", className)}>
      <div ref={hostRef} className="absolute inset-0">
        <canvas ref={canvasRef} className="absolute inset-0" aria-hidden />
      </div>
      <div className="absolute inset-0 bg-gradient-to-b from-background/0 via-background/5 to-background" aria-hidden />

      <div className="relative mx-auto flex min-h-[30rem] max-w-5xl flex-col items-center px-4 pb-20 pt-20 text-center sm:min-h-[34rem] sm:px-6 sm:pt-28">
        <motion.span
          initial={reduce ? false : { opacity: 0, y: -8 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.4 }}
          className="inline-flex items-center gap-2 rounded-full border border-border bg-card/80 px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.16em] text-muted-foreground backdrop-blur"
        >
          <span className="size-1.5 rounded-full bg-signal" aria-hidden />
          {eyebrow}
        </motion.span>

        <motion.h1
          initial={reduce ? false : { opacity: 0, y: 14 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.55, delay: 0.1 }}
          className="mt-6 font-display text-[clamp(2.4rem,7vw,5.25rem)] font-extrabold leading-[0.96] tracking-[-0.04em]"
        >
          {headline}
        </motion.h1>

        <motion.p
          initial={reduce ? false : { opacity: 0, y: 10 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.5, delay: 0.28 }}
          className="mt-5 max-w-xl text-lg leading-relaxed text-muted-foreground"
        >
          {subcopy}
        </motion.p>

        <motion.div
          initial={reduce ? false : { opacity: 0, y: 10 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.5, delay: 0.38 }}
          className="mt-8 flex flex-wrap items-center justify-center gap-3"
        >
          <a href="#" className="inline-flex h-12 items-center rounded-full bg-primary px-6 text-[15px] font-semibold text-primary-foreground transition hover:opacity-90">
            {primaryCta} →
          </a>
          <a href="#" className="inline-flex h-12 items-center rounded-full border-2 border-foreground px-6 text-[15px] font-semibold transition hover:bg-foreground hover:text-background">
            {secondaryCta}
          </a>
        </motion.div>
      </div>
    </section>
  );
}

export default HeroParticleField;

How it behaves

  • →A capped number of particles (density scales count, hard-capped at 420) drift with light friction inside a 2D canvas and wrap around the edges.
  • →Particles within 130px of the pointer are pushed away with a falloff force; the effect only runs while the pointer is over the hero (pointerleave clears it).
  • →When connect is on, particles within 110px of each other are joined by a line whose opacity fades with distance; every 11th particle is drawn larger in the signal colour as a focal accent.
  • →Colours are read from --foreground and --signal and refreshed on every .dark class toggle via MutationObserver.
  • →The loop pauses when the hero scrolls offscreen (IntersectionObserver) or the tab is hidden; prefers-reduced-motion draws one static frame and skips the simulation.
  • →Canvas is resized (and particles reseeded) via ResizeObserver, capped at devicePixelRatio 2.

Accessibility

  • ✓Canvas is aria-hidden; only the eyebrow, heading, copy and two real <a> CTAs are exposed to assistive tech.
  • ✓Headline is a single <h1>; CTAs keep the design system's visible focus ring.
  • ✓No WebGL dependency, so the effect works wherever 2D canvas does, including most reduced-capability browsers.
  • ✓Honours prefers-reduced-motion.