Starfield Background

Parallax star field flying toward the viewer — speed reacts to mouse movement for deep-space immersion

backgroundcanvasstarsparallaxmouse
Preview

Source Code

"use client";

import { useRef, useEffect, useCallback } from "react";

interface Star {
  x: number;
  y: number;
  z: number;
  pz: number;
}

interface StarfieldBackgroundProps {
  children?: React.ReactNode;
  starCount?: number;
  speed?: number;
  className?: string;
}

export default function StarfieldBackground({
  children,
  starCount = 400,
  speed = 1,
  className = "",
}: StarfieldBackgroundProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);
  const starsRef = useRef<Star[]>([]);
  const mouseRef = useRef({ x: 0, y: 0, active: false });
  const animationRef = useRef<number>(0);

  const initStars = useCallback(
    (depth: number) => {
      starsRef.current = Array.from({ length: starCount }, () => {
        const z = Math.random() * depth;
        return {
          x: (Math.random() - 0.5) * 2000,
          y: (Math.random() - 0.5) * 2000,
          z,
          pz: z,
        };
      });
    },
    [starCount]
  );

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

    let cssWidth = 0;
    let cssHeight = 0;
    const depth = 1000;

    const resize = () => {
      const dpr = window.devicePixelRatio || 1;
      cssWidth = container.clientWidth;
      cssHeight = container.clientHeight;
      canvas.width = cssWidth * dpr;
      canvas.height = cssHeight * dpr;
      canvas.style.width = `${cssWidth}px`;
      canvas.style.height = `${cssHeight}px`;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      initStars(depth);
    };

    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(container);

    const handleMouseMove = (e: MouseEvent) => {
      const rect = container.getBoundingClientRect();
      mouseRef.current = {
        x: (e.clientX - rect.left) / rect.width - 0.5,
        y: (e.clientY - rect.top) / rect.height - 0.5,
        active: true,
      };
    };

    const handleMouseLeave = () => {
      mouseRef.current = { x: 0, y: 0, active: false };
    };

    container.addEventListener("mousemove", handleMouseMove);
    container.addEventListener("mouseleave", handleMouseLeave);

    const animate = () => {
      // Trail fade — keeps motion-blur without fully clearing
      ctx.fillStyle = "rgba(5, 5, 16, 0.35)";
      ctx.fillRect(0, 0, cssWidth, cssHeight);

      const stars = starsRef.current;
      const mouse = mouseRef.current;
      const centerX = cssWidth / 2 + mouse.x * cssWidth * 0.15;
      const centerY = cssHeight / 2 + mouse.y * cssHeight * 0.15;
      const boost = mouse.active ? 1 + (Math.abs(mouse.x) + Math.abs(mouse.y)) * 4 : 1;
      const frameSpeed = speed * boost * 6;

      for (const s of stars) {
        s.pz = s.z;
        s.z -= frameSpeed;

        if (s.z <= 1) {
          s.x = (Math.random() - 0.5) * 2000;
          s.y = (Math.random() - 0.5) * 2000;
          s.z = depth;
          s.pz = depth;
          continue;
        }

        const k = 128 / s.z;
        const sx = s.x * k + centerX;
        const sy = s.y * k + centerY;

        if (sx < 0 || sx >= cssWidth || sy < 0 || sy >= cssHeight) continue;

        const pk = 128 / s.pz;
        const psx = s.x * pk + centerX;
        const psy = s.y * pk + centerY;

        const size = (1 - s.z / depth) * 2.4;
        const alpha = Math.min(1, (1 - s.z / depth) * 1.4);

        ctx.strokeStyle = `rgba(255, 255, 255, ${alpha})`;
        ctx.lineWidth = size;
        ctx.beginPath();
        ctx.moveTo(psx, psy);
        ctx.lineTo(sx, sy);
        ctx.stroke();
      }

      animationRef.current = requestAnimationFrame(animate);
    };

    animate();

    return () => {
      cancelAnimationFrame(animationRef.current);
      ro.disconnect();
      container.removeEventListener("mousemove", handleMouseMove);
      container.removeEventListener("mouseleave", handleMouseLeave);
    };
  }, [speed, initStars]);

  return (
    <div ref={containerRef} className={`relative overflow-hidden bg-[#050510] ${className}`}>
      <canvas ref={canvasRef} className="absolute inset-0" />
      <div className="relative z-10 pointer-events-none">{children}</div>
    </div>
  );
}