Home/Components/Hero Sections/Particle Flow Background - Next.js Canvas Animation
Code · Live Preview
Componenttsx
// app/components/ParticleFlowBackground/ParticleFlowBackground.tsx
"use client";

import React, { useEffect, useRef } from "react";
import styles from "./ParticleFlowBackground.module.css";

type Particle = {
  x: number;
  y: number;
  previousX: number;
  previousY: number;
  velocity: number;
  size: number;
  phase: number;
  life: number;
  maxLife: number;
  color: string;
};

export type ParticleFlowBackgroundProps = {
  children?: React.ReactNode;
  className?: string;
  contentClassName?: string;

  /**
   * Controls the number of particles relative to the container size.
   */
  density?: number;

  /**
   * Maximum number of particles rendered.
   */
  maxParticles?: number;

  /**
   * Particle movement speed.
   */
  speed?: number;

  /**
   * Makes nearby particles react to the cursor.
   */
  interactive?: boolean;

  /**
   * Shows the subtle background grid.
   */
  showGrid?: boolean;

  backgroundColor?: string;
  particleColors?: string[];
  glowColors?: [string, string, string];
};

const DEFAULT_PARTICLE_COLORS = ["#ddd6fe", "#a5f3fc", "#f0abfc", "#ffffff"];

const DEFAULT_GLOW_COLORS: [string, string, string] = [
  "rgba(124, 58, 237, 0.48)",
  "rgba(14, 165, 233, 0.32)",
  "rgba(236, 72, 153, 0.22)",
];

export default function ParticleFlowBackground({
  children,
  className = "",
  contentClassName = "",
  density = 1,
  maxParticles = 650,
  speed = 1,
  interactive = true,
  showGrid = true,
  backgroundColor = "#050507",
  particleColors = DEFAULT_PARTICLE_COLORS,
  glowColors = DEFAULT_GLOW_COLORS,
}: ParticleFlowBackgroundProps) {
  const containerRef = useRef<HTMLDivElement | null>(null);
  const canvasRef = useRef<HTMLCanvasElement | null>(null);

  const pointerRef = useRef({
    x: 0,
    y: 0,
    active: false,
  });

  const colorKey = particleColors.join("|||");

  useEffect(() => {
    const containerNode = containerRef.current;
    const canvasNode = canvasRef.current;
    if (!containerNode || !canvasNode) return;

    const canvasContext = canvasNode.getContext("2d");
    if (!canvasContext) return;

    const container: HTMLDivElement = containerNode;
    const canvas: HTMLCanvasElement = canvasNode;
    const context: CanvasRenderingContext2D = canvasContext;

    const colors = colorKey.split("|||");

    let width = 0;
    let height = 0;
    let animationFrame = 0;
    let previousTime = 0;
    let particles: Particle[] = [];

    let isInView = true;
    let isPageVisible = !document.hidden;

    const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");

    let reducedMotion = motionQuery.matches;
    let smoothedPointerX = 0;
    let smoothedPointerY = 0;

    function createParticle(spreadLife = true): Particle {
      const x = Math.random() * width;
      const y = Math.random() * height;
      const phase = Math.random() * Math.PI * 2;
      const maxLife = 180 + Math.random() * 300;

      return {
        x,
        y,
        previousX: x - Math.cos(phase) * 3,
        previousY: y - Math.sin(phase) * 3,
        velocity: 0.35 + Math.random() * 0.75,
        size: 0.45 + Math.random() * 1.15,
        phase,
        life: spreadLife ? Math.random() * maxLife : 0,
        maxLife,
        color: colors[Math.floor(Math.random() * colors.length)],
      };
    }

    function resetParticle(particle: Particle) {
      const side = Math.floor(Math.random() * 4);

      if (side === 0) {
        particle.x = Math.random() * width;
        particle.y = -10;
      } else if (side === 1) {
        particle.x = width + 10;
        particle.y = Math.random() * height;
      } else if (side === 2) {
        particle.x = Math.random() * width;
        particle.y = height + 10;
      } else {
        particle.x = -10;
        particle.y = Math.random() * height;
      }

      particle.phase = Math.random() * Math.PI * 2;
      particle.previousX = particle.x - Math.cos(particle.phase) * 3;
      particle.previousY = particle.y - Math.sin(particle.phase) * 3;
      particle.velocity = 0.35 + Math.random() * 0.75;
      particle.size = 0.45 + Math.random() * 1.15;
      particle.life = 0;
      particle.maxLife = 180 + Math.random() * 300;
      particle.color = colors[Math.floor(Math.random() * colors.length)];
    }

    function drawFrame(time: number, delta: number, advanceParticles: boolean) {
      context.clearRect(0, 0, width, height);
      context.lineCap = "round";
      context.lineJoin = "round";

      const pointer = pointerRef.current;

      smoothedPointerX += (pointer.x - smoothedPointerX) * 0.08;

      smoothedPointerY += (pointer.y - smoothedPointerY) * 0.08;

      const pointerRadius = Math.max(130, Math.min(260, width * 0.18));

      particles.forEach((particle) => {
        if (advanceParticles) {
          particle.previousX = particle.x;
          particle.previousY = particle.y;

          const angle =
            Math.sin(
              particle.x * 0.0032 + time * 0.00025 + particle.phase * 0.1,
            ) *
              1.4 +
            Math.cos(particle.y * 0.0041 - time * 0.00019) * 1.15 +
            Math.sin((particle.x + particle.y) * 0.0018 + time * 0.00012) * 0.7;

          let velocityX = Math.cos(angle) * particle.velocity * speed * delta;

          let velocityY = Math.sin(angle) * particle.velocity * speed * delta;

          if (interactive && pointer.active) {
            const differenceX = particle.x - smoothedPointerX;
            const differenceY = particle.y - smoothedPointerY;

            const distance = Math.sqrt(
              differenceX * differenceX + differenceY * differenceY,
            );

            if (distance > 0 && distance < pointerRadius) {
              const influence = 1 - distance / pointerRadius;

              const force = influence * 1.45 * speed * delta;

              // Perpendicular force creates a restrained swirl
              velocityX += (-differenceY / distance) * force;

              velocityY += (differenceX / distance) * force;

              // Slight repulsion prevents particles gathering
              velocityX += (differenceX / distance) * force * 0.12;

              velocityY += (differenceY / distance) * force * 0.12;
            }
          }

          particle.x += velocityX;
          particle.y += velocityY;
          particle.life += delta;

          const outsideBoundary =
            particle.x < -40 ||
            particle.x > width + 40 ||
            particle.y < -40 ||
            particle.y > height + 40;

          if (outsideBoundary || particle.life >= particle.maxLife) {
            resetParticle(particle);
          }
        }

        const fadeIn = Math.min(particle.life / 35, 1);
        const fadeOut = Math.min((particle.maxLife - particle.life) / 55, 1);

        const opacity = Math.max(0, Math.min(fadeIn, fadeOut));

        context.beginPath();
        context.moveTo(particle.previousX, particle.previousY);
        context.lineTo(particle.x, particle.y);
        context.strokeStyle = particle.color;
        context.lineWidth = particle.size;
        context.globalAlpha = opacity * 0.72;
        context.stroke();
      });

      context.globalAlpha = 1;
    }

    function stopAnimation() {
      if (animationFrame) {
        cancelAnimationFrame(animationFrame);
        animationFrame = 0;
      }
    }

    function animate(time: number) {
      animationFrame = 0;

      if (reducedMotion || !isInView || !isPageVisible) {
        return;
      }

      const delta = previousTime
        ? Math.min((time - previousTime) / 16.67, 2)
        : 1;

      previousTime = time;

      drawFrame(time, delta, true);
      animationFrame = requestAnimationFrame(animate);
    }

    function startAnimation() {
      if (animationFrame || reducedMotion || !isInView || !isPageVisible) {
        return;
      }

      previousTime = 0;
      animationFrame = requestAnimationFrame(animate);
    }

    function resizeCanvas() {
      stopAnimation();

      const bounds = container.getBoundingClientRect();

      width = Math.max(1, bounds.width);
      height = Math.max(1, bounds.height);

      const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);

      canvas.width = Math.floor(width * pixelRatio);
      canvas.height = Math.floor(height * pixelRatio);
      canvas.style.width = `${width}px`;
      canvas.style.height = `${height}px`;

      context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);

      const calculatedParticleCount = Math.floor(
        (width * height * density) / 2400,
      );

      const particleCount = Math.min(
        maxParticles,
        Math.max(70, calculatedParticleCount),
      );

      particles = Array.from({ length: particleCount }, () =>
        createParticle(true),
      );

      smoothedPointerX = width / 2;
      smoothedPointerY = height / 2;

      // Render immediately so the canvas is never empty.
      drawFrame(performance.now(), 0.5, true);

      startAnimation();
    }

    const resizeObserver = new ResizeObserver(resizeCanvas);

    const intersectionObserver = new IntersectionObserver(
      ([entry]) => {
        isInView = entry.isIntersecting;

        if (isInView) {
          startAnimation();
        } else {
          stopAnimation();
        }
      },
      {
        rootMargin: "100px",
      },
    );

    function handleVisibilityChange() {
      isPageVisible = !document.hidden;

      if (isPageVisible) {
        startAnimation();
      } else {
        stopAnimation();
      }
    }

    function handleMotionPreferenceChange(event: MediaQueryListEvent) {
      reducedMotion = event.matches;

      stopAnimation();

      if (reducedMotion) {
        drawFrame(performance.now(), 0.5, true);
      } else {
        startAnimation();
      }
    }

    resizeObserver.observe(container);
    intersectionObserver.observe(container);

    document.addEventListener("visibilitychange", handleVisibilityChange);

    motionQuery.addEventListener("change", handleMotionPreferenceChange);

    resizeCanvas();

    return () => {
      stopAnimation();
      resizeObserver.disconnect();
      intersectionObserver.disconnect();

      document.removeEventListener("visibilitychange", handleVisibilityChange);

      motionQuery.removeEventListener("change", handleMotionPreferenceChange);
    };
  }, [colorKey, density, interactive, maxParticles, speed]);

  function handlePointerMove(event: React.PointerEvent<HTMLDivElement>) {
    if (!interactive || event.pointerType === "touch") {
      return;
    }

    const bounds = event.currentTarget.getBoundingClientRect();

    pointerRef.current = {
      x: event.clientX - bounds.left,
      y: event.clientY - bounds.top,
      active: true,
    };
  }

  function handlePointerLeave() {
    pointerRef.current.active = false;
  }

  return (
    <div
      ref={containerRef}
      onPointerMove={handlePointerMove}
      onPointerLeave={handlePointerLeave}
      className={`relative isolate overflow-hidden ${className}`}
      style={{ backgroundColor }}
    >
      {/* Animated gradient lights */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 overflow-hidden"
      >
        <div
          className={`${styles.orb} ${styles.orbOne} absolute -left-56 -top-64 h-[38rem] w-[38rem] rounded-full blur-[110px] sm:h-[52rem] sm:w-[52rem]`}
          style={{
            background: `radial-gradient(circle, ${glowColors[0]} 0%, transparent 68%)`,
          }}
        />

        <div
          className={`${styles.orb} ${styles.orbTwo} absolute -right-64 top-[5%] h-[34rem] w-[34rem] rounded-full blur-[120px] sm:h-[48rem] sm:w-[48rem]`}
          style={{
            background: `radial-gradient(circle, ${glowColors[1]} 0%, transparent 68%)`,
          }}
        />

        <div
          className={`${styles.orb} ${styles.orbThree} absolute -bottom-72 left-[30%] h-[38rem] w-[38rem] rounded-full blur-[130px] sm:h-[52rem] sm:w-[52rem]`}
          style={{
            background: `radial-gradient(circle, ${glowColors[2]} 0%, transparent 68%)`,
          }}
        />
      </div>

      {/* Subtle technical grid */}
      {showGrid && (
        <div
          aria-hidden="true"
          className="
            pointer-events-none absolute inset-0 z-[1]
            bg-[linear-gradient(rgba(255,255,255,0.025)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.025)_1px,transparent_1px)]
            [background-size:64px_64px]
            [mask-image:radial-gradient(ellipse_at_center,black_10%,transparent_78%)]
          "
        />
      )}

      {/* Particle canvas */}
      <canvas
        ref={canvasRef}
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 z-[2] h-full w-full mix-blend-screen"
      />

      {/* Vignette */}
      <div
        aria-hidden="true"
        className="
          pointer-events-none absolute inset-0 z-[3]
          bg-[radial-gradient(ellipse_at_center,transparent_20%,rgba(0,0,0,0.15)_60%,rgba(0,0,0,0.55)_100%)]
        "
      />

      {/* Optional top highlight */}
      <div
        aria-hidden="true"
        className="
          pointer-events-none absolute inset-x-[15%] top-0 z-[3]
          h-px bg-gradient-to-r from-transparent via-white/25 to-transparent
        "
      />

      {/* Page content */}
      <div className={`relative z-10 ${contentClassName}`}>{children}</div>
    </div>
  );
}
// Component Source: https://codekk.dev
DesktopSandpackTailwind v4

Component details

Overview, install command, dependencies, and project links.

An interactive particle flow background component built with Next.js, TypeScript, and the HTML Canvas API - no third-party animation libraries. Hundreds of particles flow across the canvas following a noise-like vector field built from layered sine and cosine functions - each particle traces a short line segment from its previous position to its current one, which creates the flowing trail effect. Moving the cursor near particles creates a swirl and slight repulsion force so the flow reacts naturally without particles clustering. The animation is throttled with requestAnimationFrame, pauses automatically when the tab is hidden using the Page Visibility API, and stops when the component scrolls out of view using IntersectionObserver. Canvas size tracks the container using ResizeObserver and redraws immediately on resize so the canvas is never empty. Three animated background glow orbs drift slowly using CSS keyframe animations defined in CSS Modules. An optional subtle grid overlay uses a CSS mask-image radial gradient to fade at the edges. All animations respect prefers-reduced-motion - particles freeze and orbs stop when the user has reduced motion enabled. Particle colors, glow colors, density, speed, and spotlight size are all configurable via props. Works as a "use client" component in the Next.js App Router.

Added Sep 21, 2026

Discussion

Feedback, implementation tips, and usage notes.

0 threads

Comments

Sign in to join the conversation