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>
);
}