Web InnoventixFreeCode

Particles Background

Original · free

canvas particle background

byWeb InnoventixReact + Tailwind
bgxparticlesbackgrounds
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/bgx-particles.json
bgx-particles.tsx
"use client";

import { useEffect, useId, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

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

const PALETTES = {
  indigo: { label: "Indigo", rgb: [99, 102, 241] as const },
  violet: { label: "Violet", rgb: [139, 92, 246] as const },
  sky: { label: "Sky", rgb: [56, 189, 248] as const },
  emerald: { label: "Emerald", rgb: [16, 185, 129] as const },
  amber: { label: "Amber", rgb: [245, 158, 11] as const },
  rose: { label: "Rose", rgb: [244, 63, 94] as const },
} as const;

type PaletteKey = keyof typeof PALETTES;
const PALETTE_KEYS = Object.keys(PALETTES) as PaletteKey[];

function makeParticle(w: number, h: number): Particle {
  const angle = Math.random() * Math.PI * 2;
  const speed = 0.12 + Math.random() * 0.32;
  return {
    x: Math.random() * w,
    y: Math.random() * h,
    vx: Math.cos(angle) * speed,
    vy: Math.sin(angle) * speed,
    r: 1 + Math.random() * 1.7,
  };
}

function SwitchRow({
  checked,
  onChange,
  title,
  desc,
}: {
  checked: boolean;
  onChange: (v: boolean) => void;
  title: string;
  desc: string;
}) {
  const labelId = useId();
  return (
    <div className="flex items-center justify-between gap-4">
      <div className="min-w-0">
        <p id={labelId} className="text-sm font-medium text-slate-800 dark:text-slate-100">
          {title}
        </p>
        <p className="text-xs text-slate-500 dark:text-slate-400">{desc}</p>
      </div>
      <button
        type="button"
        role="switch"
        aria-checked={checked}
        aria-labelledby={labelId}
        onClick={() => onChange(!checked)}
        className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950 ${
          checked ? "bg-indigo-500" : "bg-slate-300 dark:bg-slate-700"
        }`}
      >
        <span
          className={`inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition-transform duration-200 ${
            checked ? "translate-x-[22px]" : "translate-x-0.5"
          }`}
        />
      </button>
    </div>
  );
}

export default function BgxParticles() {
  const prefersReduced = useReducedMotion();
  const reduced = prefersReduced === true;

  const [playing, setPlaying] = useState(true);
  const [count, setCount] = useState(96);
  const [showLinks, setShowLinks] = useState(true);
  const [interactive, setInteractive] = useState(true);
  const [palette, setPalette] = useState<PaletteKey>("indigo");
  const [isDark, setIsDark] = useState(false);

  const sectionRef = useRef<HTMLElement | null>(null);
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const particlesRef = useRef<Particle[]>([]);
  const mouseRef = useRef({ x: 0, y: 0, active: false });
  const rafRef = useRef<number | null>(null);
  const wRef = useRef(0);
  const hRef = useRef(0);
  const loopingRef = useRef(false);

  const countRef = useRef(count);
  const showLinksRef = useRef(showLinks);
  const interactiveRef = useRef(interactive);
  const paletteRef = useRef<PaletteKey>(palette);
  const isDarkRef = useRef(isDark);

  const advanceRef = useRef<(() => void) | null>(null);
  const renderRef = useRef<(() => void) | null>(null);

  const swatchRefs = useRef<Array<HTMLButtonElement | null>>([]);

  // Detect theme (class / data-theme / system) so canvas colors read on both.
  useEffect(() => {
    const root = document.documentElement;
    const mq = window.matchMedia("(prefers-color-scheme: dark)");
    const check = () => {
      const attr = root.getAttribute("data-theme");
      const dark =
        root.classList.contains("dark") ||
        attr === "dark" ||
        (!root.classList.contains("light") && attr !== "light" && mq.matches);
      setIsDark(dark);
    };
    check();
    mq.addEventListener("change", check);
    const mo = new MutationObserver(check);
    mo.observe(root, { attributes: true, attributeFilter: ["class", "data-theme"] });
    return () => {
      mq.removeEventListener("change", check);
      mo.disconnect();
    };
  }, []);

  // Setup canvas, particles, sizing, and draw routines (once).
  useEffect(() => {
    const canvas = canvasRef.current;
    const section = sectionRef.current;
    if (!canvas || !section) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const sizeCanvas = () => {
      const rect = section.getBoundingClientRect();
      const w = Math.max(1, Math.floor(rect.width));
      const h = Math.max(1, Math.floor(rect.height));
      wRef.current = w;
      hRef.current = h;
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.floor(w * dpr);
      canvas.height = Math.floor(h * dpr);
      canvas.style.width = `${w}px`;
      canvas.style.height = `${h}px`;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      for (const p of particlesRef.current) {
        if (p.x > w) p.x = Math.random() * w;
        if (p.y > h) p.y = Math.random() * h;
      }
    };

    const advance = () => {
      const w = wRef.current;
      const h = hRef.current;
      const ps = particlesRef.current;
      const mouse = mouseRef.current;
      const active = interactiveRef.current && mouse.active;
      const R = 150;
      const R2 = R * R;
      for (let i = 0; i < ps.length; i++) {
        const p = ps[i];
        p.x += p.vx;
        p.y += p.vy;
        if (active) {
          const dx = p.x - mouse.x;
          const dy = p.y - mouse.y;
          const d2 = dx * dx + dy * dy;
          if (d2 < R2 && d2 > 0.001) {
            const d = Math.sqrt(d2);
            const f = (1 - d / R) * 2.4;
            p.x += (dx / d) * f;
            p.y += (dy / d) * f;
          }
        }
        if (p.x < -6) p.x = w + 6;
        else if (p.x > w + 6) p.x = -6;
        if (p.y < -6) p.y = h + 6;
        else if (p.y > h + 6) p.y = -6;
      }
    };

    const render = () => {
      const w = wRef.current;
      const h = hRef.current;
      const ps = particlesRef.current;
      const [cr, cg, cb] = PALETTES[paletteRef.current].rgb;
      const dark = isDarkRef.current;
      const mouse = mouseRef.current;
      const active = interactiveRef.current && mouse.active;
      ctx.clearRect(0, 0, w, h);

      if (showLinksRef.current) {
        const maxD = 132;
        const maxD2 = maxD * maxD;
        ctx.lineWidth = 1;
        for (let i = 0; i < ps.length; i++) {
          const a = ps[i];
          for (let j = i + 1; j < ps.length; j++) {
            const b = ps[j];
            const dx = a.x - b.x;
            const dy = a.y - b.y;
            const d2 = dx * dx + dy * dy;
            if (d2 < maxD2) {
              const alpha = (1 - Math.sqrt(d2) / maxD) * (dark ? 0.45 : 0.55);
              ctx.strokeStyle = `rgba(${cr},${cg},${cb},${alpha})`;
              ctx.beginPath();
              ctx.moveTo(a.x, a.y);
              ctx.lineTo(b.x, b.y);
              ctx.stroke();
            }
          }
        }
      }

      if (active) {
        const maxD = 172;
        const maxD2 = maxD * maxD;
        ctx.lineWidth = 1.1;
        for (let i = 0; i < ps.length; i++) {
          const p = ps[i];
          const dx = p.x - mouse.x;
          const dy = p.y - mouse.y;
          const d2 = dx * dx + dy * dy;
          if (d2 < maxD2) {
            const alpha = (1 - Math.sqrt(d2) / maxD) * (dark ? 0.7 : 0.8);
            ctx.strokeStyle = `rgba(${cr},${cg},${cb},${alpha})`;
            ctx.beginPath();
            ctx.moveTo(p.x, p.y);
            ctx.lineTo(mouse.x, mouse.y);
            ctx.stroke();
          }
        }
        ctx.strokeStyle = `rgba(${cr},${cg},${cb},0.9)`;
        ctx.lineWidth = 1.5;
        ctx.beginPath();
        ctx.arc(mouse.x, mouse.y, 6, 0, Math.PI * 2);
        ctx.stroke();
      }

      ctx.fillStyle = `rgba(${cr},${cg},${cb},${dark ? 0.95 : 0.82})`;
      for (let i = 0; i < ps.length; i++) {
        const p = ps[i];
        ctx.beginPath();
        ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
        ctx.fill();
      }
    };

    advanceRef.current = advance;
    renderRef.current = render;

    sizeCanvas();
    if (particlesRef.current.length === 0) {
      const w = wRef.current;
      const h = hRef.current;
      particlesRef.current = Array.from({ length: countRef.current }, () => makeParticle(w, h));
    }
    render();

    const ro = new ResizeObserver(() => {
      sizeCanvas();
      if (!loopingRef.current) renderRef.current?.();
    });
    ro.observe(section);

    return () => {
      ro.disconnect();
      advanceRef.current = null;
      renderRef.current = null;
    };
  }, []);

  // Start / stop the animation loop.
  useEffect(() => {
    if (!playing || reduced) {
      loopingRef.current = false;
      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
      renderRef.current?.();
      return;
    }
    loopingRef.current = true;
    const loop = () => {
      advanceRef.current?.();
      renderRef.current?.();
      rafRef.current = requestAnimationFrame(loop);
    };
    rafRef.current = requestAnimationFrame(loop);
    return () => {
      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
      loopingRef.current = false;
    };
  }, [playing, reduced]);

  // Reconcile particle count with the slider.
  useEffect(() => {
    countRef.current = count;
    const arr = particlesRef.current;
    const w = wRef.current || 1;
    const h = hRef.current || 1;
    if (count > arr.length) {
      for (let i = arr.length; i < count; i++) arr.push(makeParticle(w, h));
    } else if (count < arr.length) {
      arr.length = count;
    }
    if (!loopingRef.current) renderRef.current?.();
  }, [count]);

  // Keep draw-time refs in sync; repaint once when idle.
  useEffect(() => {
    showLinksRef.current = showLinks;
    interactiveRef.current = interactive;
    paletteRef.current = palette;
    isDarkRef.current = isDark;
    if (!loopingRef.current) renderRef.current?.();
  }, [showLinks, interactive, palette, isDark]);

  const shuffle = () => {
    const w = wRef.current || 1;
    const h = hRef.current || 1;
    particlesRef.current = Array.from({ length: countRef.current }, () => makeParticle(w, h));
    if (!loopingRef.current) renderRef.current?.();
  };

  const accent = PALETTES[palette];
  const accentCss = `rgb(${accent.rgb[0]}, ${accent.rgb[1]}, ${accent.rgb[2]})`;
  const running = playing && !reduced;

  const reveal = (delay: number) =>
    reduced
      ? {}
      : {
          initial: { opacity: 0, y: 18 },
          animate: { opacity: 1, y: 0 },
          transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] as [number, number, number, number], delay },
        };

  return (
    <section
      ref={sectionRef}
      aria-label="Interactive particle field"
      onPointerMove={(e) => {
        const el = sectionRef.current;
        if (!el) return;
        const rect = el.getBoundingClientRect();
        mouseRef.current.x = e.clientX - rect.left;
        mouseRef.current.y = e.clientY - rect.top;
        mouseRef.current.active = true;
      }}
      onPointerLeave={() => {
        mouseRef.current.active = false;
      }}
      className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-white to-slate-100 px-6 py-24 text-slate-900 dark:from-slate-950 dark:via-slate-950 dark:to-slate-900 dark:text-slate-100 sm:px-10 lg:px-16"
    >
      <style>{`
        @keyframes bgxp-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.35; transform: scale(0.65); } }
        .bgxp-live-dot { animation: bgxp-pulse 1.8s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .bgxp-live-dot { animation: none; }
        }
      `}</style>

      <canvas
        ref={canvasRef}
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 z-0 h-full w-full"
      />

      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 z-0"
        style={{
          background: `radial-gradient(60% 55% at 78% 12%, rgba(${accent.rgb[0]}, ${accent.rgb[1]}, ${accent.rgb[2]}, 0.13), transparent 70%)`,
        }}
      />

      <div className="relative z-10 mx-auto grid w-full max-w-6xl items-center gap-12 lg:grid-cols-[1.15fr_1fr]">
        <motion.div {...reveal(0)}>
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200/80 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-[0.16em] text-slate-600 backdrop-blur dark:border-slate-700/70 dark:bg-slate-900/60 dark:text-slate-300">
            <span
              className="bgxp-live-dot h-1.5 w-1.5 rounded-full"
              style={{ backgroundColor: accentCss }}
            />
            Interactive background
          </span>

          <h2 className="mt-6 text-balance text-4xl font-semibold leading-[1.05] tracking-tight sm:text-5xl lg:text-6xl">
            A particle field that
            <span className="block" style={{ color: accentCss }}>
              bends around your cursor
            </span>
          </h2>

          <p className="mt-6 max-w-lg text-pretty text-base leading-relaxed text-slate-600 dark:text-slate-300 sm:text-lg">
            Sixty frames a second of pure 2D canvas &mdash; no WebGL, no shaders, nothing beyond
            React. Sweep your pointer across the field to push the nodes apart, then tune density,
            connections, and palette in real time.
          </p>

          <p
            aria-live="polite"
            className="mt-8 inline-flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-xs text-slate-500 dark:text-slate-400"
          >
            <span className="text-slate-700 dark:text-slate-200">{count}</span> nodes
            <span aria-hidden="true">&middot;</span>
            <span className="text-slate-700 dark:text-slate-200">{showLinks ? "linked" : "loose"}</span>
            <span aria-hidden="true">&middot;</span>
            <span className="text-slate-700 dark:text-slate-200">
              {reduced ? "still frame" : running ? "animating" : "paused"}
            </span>
          </p>
        </motion.div>

        <motion.div
          {...reveal(0.12)}
          className="rounded-2xl border border-slate-200/80 bg-white/75 p-6 shadow-xl shadow-slate-900/5 backdrop-blur-md dark:border-slate-700/60 dark:bg-slate-900/60 dark:shadow-black/30 sm:p-7"
        >
          <div className="flex items-center justify-between">
            <h3 className="text-sm font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
              Canvas controls
            </h3>
            <button
              type="button"
              onClick={shuffle}
              className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-950"
            >
              <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <path d="M16 3h5v5" />
                <path d="M4 20 21 3" />
                <path d="M21 16v5h-5" />
                <path d="m15 15 6 6" />
                <path d="M4 4l5 5" />
              </svg>
              Shuffle
            </button>
          </div>

          <div className="mt-5 flex items-center gap-3">
            <button
              type="button"
              onClick={() => setPlaying((p) => !p)}
              disabled={reduced}
              aria-pressed={playing}
              aria-label={playing ? "Pause animation" : "Play animation"}
              className="inline-flex flex-1 items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-[filter,opacity] hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:ring-offset-slate-950"
              style={{ backgroundColor: accentCss, boxShadow: `0 8px 24px -12px ${accentCss}` }}
            >
              {running ? (
                <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true">
                  <rect x="6" y="5" width="4" height="14" rx="1" />
                  <rect x="14" y="5" width="4" height="14" rx="1" />
                </svg>
              ) : (
                <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true">
                  <path d="M8 5.14v13.72a1 1 0 0 0 1.54.84l10.29-6.86a1 1 0 0 0 0-1.68L9.54 4.3A1 1 0 0 0 8 5.14Z" />
                </svg>
              )}
              {reduced ? "Static" : running ? "Pause" : "Play"}
            </button>
          </div>

          {reduced && (
            <p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
              Reduced motion is on &mdash; the field renders a single still frame.
            </p>
          )}

          <div className="mt-6">
            <label
              htmlFor="bgxp-density"
              className="flex items-center justify-between text-sm font-medium text-slate-800 dark:text-slate-100"
            >
              <span>Particle density</span>
              <span className="font-mono text-xs text-slate-500 dark:text-slate-400">{count}</span>
            </label>
            <input
              id="bgxp-density"
              type="range"
              min={24}
              max={180}
              step={2}
              value={count}
              onChange={(e) => setCount(Number(e.target.value))}
              className="mt-2 w-full cursor-pointer accent-indigo-500"
            />
          </div>

          <div className="mt-6 space-y-4 border-t border-slate-200/80 pt-5 dark:border-slate-700/60">
            <SwitchRow
              checked={showLinks}
              onChange={setShowLinks}
              title="Connection lines"
              desc="Draw links between nearby nodes"
            />
            <SwitchRow
              checked={interactive}
              onChange={setInteractive}
              title="Cursor repulsion"
              desc="Push nodes away from the pointer"
            />
          </div>

          <div className="mt-6 border-t border-slate-200/80 pt-5 dark:border-slate-700/60">
            <p className="mb-3 text-sm font-medium text-slate-800 dark:text-slate-100">Accent palette</p>
            <div role="radiogroup" aria-label="Accent palette" className="flex flex-wrap gap-2.5">
              {PALETTE_KEYS.map((key, idx) => {
                const p = PALETTES[key];
                const selected = palette === key;
                return (
                  <button
                    key={key}
                    ref={(el) => {
                      swatchRefs.current[idx] = el;
                    }}
                    type="button"
                    role="radio"
                    aria-checked={selected}
                    aria-label={p.label}
                    title={p.label}
                    tabIndex={selected ? 0 : -1}
                    onClick={() => setPalette(key)}
                    onKeyDown={(e) => {
                      const keys = PALETTE_KEYS;
                      let next = -1;
                      if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (idx + 1) % keys.length;
                      else if (e.key === "ArrowLeft" || e.key === "ArrowUp")
                        next = (idx - 1 + keys.length) % keys.length;
                      else if (e.key === "Home") next = 0;
                      else if (e.key === "End") next = keys.length - 1;
                      else return;
                      e.preventDefault();
                      setPalette(keys[next]);
                      swatchRefs.current[next]?.focus();
                    }}
                    className={`relative flex h-9 w-9 items-center justify-center rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-slate-900 focus-visible:ring-offset-white dark:focus-visible:ring-white dark:focus-visible:ring-offset-slate-950 ${
                      selected
                        ? "ring-2 ring-slate-900 ring-offset-2 ring-offset-white dark:ring-white dark:ring-offset-slate-950"
                        : ""
                    }`}
                    style={{ backgroundColor: `rgb(${p.rgb[0]}, ${p.rgb[1]}, ${p.rgb[2]})` }}
                  >
                    {selected && (
                      <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                        <path d="m5 13 4 4L19 7" />
                      </svg>
                    )}
                  </button>
                );
              })}
            </div>
          </div>
        </motion.div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →