436 lines
12 KiB
TypeScript
436 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useTheme } from "next-themes";
|
|
import type React from "react";
|
|
import { useCallback, useEffect, useRef } from "react";
|
|
|
|
const PALETTES = {
|
|
dark: {
|
|
particles: [
|
|
"rgba(255,255,255,0.70)",
|
|
"rgba(255,255,255,0.45)",
|
|
"rgba(180,180,180,0.50)",
|
|
"rgba(200,200,200,0.35)",
|
|
"rgba(255,255,255,0.22)",
|
|
],
|
|
grainOpacity: 0.05,
|
|
},
|
|
light: {
|
|
particles: [
|
|
"rgba(0,0,0,0.55)",
|
|
"rgba(0,0,0,0.35)",
|
|
"rgba(60,60,60,0.40)",
|
|
"rgba(80,80,80,0.25)",
|
|
"rgba(0,0,0,0.18)",
|
|
],
|
|
grainOpacity: 0.03,
|
|
},
|
|
} as const;
|
|
|
|
interface Particle {
|
|
angle: number;
|
|
radius: number;
|
|
speed: number;
|
|
size: number;
|
|
colorIndex: number;
|
|
wobbleAmp: number;
|
|
wobbleSpeed: number;
|
|
wobblePhase: number;
|
|
}
|
|
|
|
interface CanvasSize {
|
|
dpr: number;
|
|
height: number;
|
|
width: number;
|
|
}
|
|
|
|
interface AnimatedBackgroundContainerProps {
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
particleCount?: number;
|
|
orbitRadius?: number;
|
|
followSpeed?: number;
|
|
mobileSpeed?: number;
|
|
}
|
|
|
|
const DEFAULT_PARTICLE_COLORS: readonly string[] = PALETTES.dark.particles;
|
|
const PARTICLE_COLOR_COUNT = DEFAULT_PARTICLE_COLORS.length;
|
|
const EDGE_FADE_DISTANCE = 80;
|
|
const MAX_DEVICE_PIXEL_RATIO = 2;
|
|
const MOBILE_TARGET_DISTANCE = 30;
|
|
|
|
const rand = (min: number, max: number) => Math.random() * (max - min) + min;
|
|
|
|
const isMobileDevice = () => {
|
|
if (typeof window === "undefined") {
|
|
return false;
|
|
}
|
|
|
|
return window.matchMedia("(pointer: coarse)").matches || window.innerWidth < 768;
|
|
};
|
|
|
|
const createParticle = (orbitRadius: number): Particle => {
|
|
const minRadius = Math.max(10, orbitRadius * 0.12);
|
|
|
|
return {
|
|
angle: rand(0, Math.PI * 2),
|
|
radius: rand(minRadius, orbitRadius),
|
|
speed: rand(0.002, 0.003) * (Math.random() > 0.5 ? 1 : -1),
|
|
size: rand(1.2, 4),
|
|
colorIndex: Math.floor(rand(0, PARTICLE_COLOR_COUNT)),
|
|
wobbleAmp: rand(orbitRadius * 0.025, orbitRadius * 0.12),
|
|
wobbleSpeed: rand(0.008, 0.035),
|
|
wobblePhase: rand(0, Math.PI * 2),
|
|
};
|
|
};
|
|
|
|
export default function AnimatedBackgroundContainer({
|
|
children,
|
|
className = "",
|
|
particleCount = 60,
|
|
orbitRadius = 240,
|
|
followSpeed = 0.06,
|
|
mobileSpeed = 1,
|
|
}: AnimatedBackgroundContainerProps) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const frameRef = useRef(0);
|
|
const animationFrameRef = useRef<number | null>(null);
|
|
const isMobileRef = useRef(false);
|
|
const isVisibleRef = useRef(true);
|
|
const prefersReducedMotionRef = useRef(false);
|
|
const particlesRef = useRef<Particle[]>([]);
|
|
const mousePosRef = useRef({ x: 0, y: 0 });
|
|
const smoothMouseRef = useRef({ x: 0, y: 0 });
|
|
const mobileAnchorRef = useRef({ x: 0, y: 0 });
|
|
const mobileTargetRef = useRef({ x: 0, y: 0 });
|
|
const canvasSizeRef = useRef<CanvasSize>({ dpr: 1, height: 0, width: 0 });
|
|
const containerRectRef = useRef<DOMRect | null>(null);
|
|
const followSpeedRef = useRef(followSpeed);
|
|
const mobileSpeedRef = useRef(mobileSpeed);
|
|
const particleColorsRef = useRef<readonly string[]>(DEFAULT_PARTICLE_COLORS);
|
|
|
|
const { resolvedTheme } = useTheme();
|
|
const isDark = resolvedTheme === undefined || resolvedTheme === "dark";
|
|
const palette = isDark ? PALETTES.dark : PALETTES.light;
|
|
|
|
useEffect(() => {
|
|
particleColorsRef.current = palette.particles;
|
|
}, [palette]);
|
|
|
|
useEffect(() => {
|
|
followSpeedRef.current = followSpeed;
|
|
}, [followSpeed]);
|
|
|
|
useEffect(() => {
|
|
mobileSpeedRef.current = mobileSpeed;
|
|
}, [mobileSpeed]);
|
|
|
|
useEffect(() => {
|
|
particlesRef.current = Array.from({ length: particleCount }, () =>
|
|
createParticle(orbitRadius),
|
|
);
|
|
}, [particleCount, orbitRadius]);
|
|
|
|
const seedPositions = useCallback(() => {
|
|
const { height, width } = canvasSizeRef.current;
|
|
if (width === 0 || height === 0) {
|
|
return;
|
|
}
|
|
|
|
const centerX = width / 2;
|
|
const centerY = height / 2;
|
|
|
|
mousePosRef.current = { x: centerX, y: centerY };
|
|
smoothMouseRef.current = { x: centerX, y: centerY };
|
|
mobileAnchorRef.current = { x: centerX, y: centerY };
|
|
mobileTargetRef.current = {
|
|
x: rand(centerX * 0.4, centerX * 1.6),
|
|
y: rand(centerY * 0.4, centerY * 1.6),
|
|
};
|
|
}, []);
|
|
|
|
const updateContainerRect = useCallback(() => {
|
|
const container = containerRef.current;
|
|
if (container) {
|
|
containerRectRef.current = container.getBoundingClientRect();
|
|
}
|
|
}, []);
|
|
|
|
const resizeCanvas = useCallback(() => {
|
|
const canvas = canvasRef.current;
|
|
const container = containerRef.current;
|
|
if (!canvas || !container) {
|
|
return;
|
|
}
|
|
|
|
const width = container.clientWidth;
|
|
const height = container.clientHeight;
|
|
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DEVICE_PIXEL_RATIO);
|
|
const nextWidth = Math.round(width * dpr);
|
|
const nextHeight = Math.round(height * dpr);
|
|
|
|
canvasSizeRef.current = { dpr, height, width };
|
|
updateContainerRect();
|
|
|
|
if (canvas.width !== nextWidth) {
|
|
canvas.width = nextWidth;
|
|
}
|
|
if (canvas.height !== nextHeight) {
|
|
canvas.height = nextHeight;
|
|
}
|
|
canvas.style.width = `${width}px`;
|
|
canvas.style.height = `${height}px`;
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) {
|
|
return;
|
|
}
|
|
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
ctx.scale(dpr, dpr);
|
|
seedPositions();
|
|
}, [seedPositions, updateContainerRect]);
|
|
|
|
useEffect(() => {
|
|
isMobileRef.current = isMobileDevice();
|
|
resizeCanvas();
|
|
|
|
const handleResize = () => {
|
|
isMobileRef.current = isMobileDevice();
|
|
resizeCanvas();
|
|
};
|
|
|
|
const resizeObserver =
|
|
"ResizeObserver" in window
|
|
? new ResizeObserver(() => {
|
|
handleResize();
|
|
})
|
|
: null;
|
|
|
|
if (containerRef.current && resizeObserver) {
|
|
resizeObserver.observe(containerRef.current);
|
|
}
|
|
|
|
window.addEventListener("resize", handleResize);
|
|
window.addEventListener("scroll", updateContainerRect, {
|
|
capture: true,
|
|
passive: true,
|
|
});
|
|
return () => {
|
|
resizeObserver?.disconnect();
|
|
window.removeEventListener("resize", handleResize);
|
|
window.removeEventListener("scroll", updateContainerRect, { capture: true });
|
|
};
|
|
}, [resizeCanvas, updateContainerRect]);
|
|
|
|
const handleMouseMove = useCallback((event: MouseEvent) => {
|
|
const rect = containerRectRef.current;
|
|
if (!rect || isMobileRef.current) {
|
|
return;
|
|
}
|
|
|
|
mousePosRef.current = {
|
|
x: event.clientX - rect.left,
|
|
y: event.clientY - rect.top,
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
container.addEventListener("mousemove", handleMouseMove, { passive: true });
|
|
return () => container.removeEventListener("mousemove", handleMouseMove);
|
|
}, [handleMouseMove]);
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) {
|
|
return;
|
|
}
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) {
|
|
return;
|
|
}
|
|
|
|
const stopAnimation = () => {
|
|
if (animationFrameRef.current !== null) {
|
|
window.cancelAnimationFrame(animationFrameRef.current);
|
|
animationFrameRef.current = null;
|
|
}
|
|
};
|
|
|
|
const draw = () => {
|
|
if (!isVisibleRef.current || prefersReducedMotionRef.current) {
|
|
animationFrameRef.current = null;
|
|
return;
|
|
}
|
|
|
|
const { height, width } = canvasSizeRef.current;
|
|
if (width === 0 || height === 0) {
|
|
animationFrameRef.current = window.requestAnimationFrame(draw);
|
|
return;
|
|
}
|
|
|
|
frameRef.current += 1;
|
|
|
|
if (isMobileRef.current) {
|
|
const mobileLerp = 0.008 * mobileSpeedRef.current;
|
|
mobileAnchorRef.current.x +=
|
|
(mobileTargetRef.current.x - mobileAnchorRef.current.x) * mobileLerp;
|
|
mobileAnchorRef.current.y +=
|
|
(mobileTargetRef.current.y - mobileAnchorRef.current.y) * mobileLerp;
|
|
|
|
const dx = mobileTargetRef.current.x - mobileAnchorRef.current.x;
|
|
const dy = mobileTargetRef.current.y - mobileAnchorRef.current.y;
|
|
if (Math.hypot(dx, dy) < MOBILE_TARGET_DISTANCE) {
|
|
mobileTargetRef.current = {
|
|
x: rand(width * 0.15, width * 0.85),
|
|
y: rand(height * 0.15, height * 0.85),
|
|
};
|
|
}
|
|
|
|
smoothMouseRef.current.x = mobileAnchorRef.current.x;
|
|
smoothMouseRef.current.y = mobileAnchorRef.current.y;
|
|
} else {
|
|
const desktopLerp = followSpeedRef.current;
|
|
smoothMouseRef.current.x +=
|
|
(mousePosRef.current.x - smoothMouseRef.current.x) * desktopLerp;
|
|
smoothMouseRef.current.y +=
|
|
(mousePosRef.current.y - smoothMouseRef.current.y) * desktopLerp;
|
|
}
|
|
|
|
const centerX = smoothMouseRef.current.x;
|
|
const centerY = smoothMouseRef.current.y;
|
|
const colors = particleColorsRef.current;
|
|
|
|
ctx.clearRect(0, 0, width, height);
|
|
|
|
for (const particle of particlesRef.current) {
|
|
particle.angle += particle.speed;
|
|
|
|
const wobble =
|
|
Math.sin(frameRef.current * particle.wobbleSpeed + particle.wobblePhase) *
|
|
particle.wobbleAmp;
|
|
const radius = particle.radius + wobble;
|
|
const x = centerX + Math.cos(particle.angle) * radius;
|
|
const y = centerY + Math.sin(particle.angle) * radius;
|
|
|
|
const edgeFade = Math.max(
|
|
0,
|
|
Math.min(
|
|
x / EDGE_FADE_DISTANCE,
|
|
(width - x) / EDGE_FADE_DISTANCE,
|
|
y / EDGE_FADE_DISTANCE,
|
|
(height - y) / EDGE_FADE_DISTANCE,
|
|
1,
|
|
),
|
|
);
|
|
|
|
if (edgeFade <= 0) {
|
|
continue;
|
|
}
|
|
|
|
ctx.globalAlpha = edgeFade;
|
|
ctx.fillStyle = colors[particle.colorIndex] ?? colors[0] ?? "#ffffff";
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, particle.size, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
ctx.globalAlpha = 1;
|
|
animationFrameRef.current = window.requestAnimationFrame(draw);
|
|
};
|
|
|
|
const startAnimation = () => {
|
|
if (
|
|
animationFrameRef.current === null &&
|
|
isVisibleRef.current &&
|
|
!prefersReducedMotionRef.current
|
|
) {
|
|
animationFrameRef.current = window.requestAnimationFrame(draw);
|
|
}
|
|
};
|
|
|
|
const handleVisibilityChange = () => {
|
|
isVisibleRef.current = document.visibilityState === "visible";
|
|
|
|
if (isVisibleRef.current) {
|
|
startAnimation();
|
|
} else {
|
|
stopAnimation();
|
|
}
|
|
};
|
|
|
|
const motionMedia = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
const handleMotionChange = () => {
|
|
prefersReducedMotionRef.current = motionMedia.matches;
|
|
|
|
if (prefersReducedMotionRef.current) {
|
|
stopAnimation();
|
|
ctx.clearRect(0, 0, canvasSizeRef.current.width, canvasSizeRef.current.height);
|
|
} else {
|
|
startAnimation();
|
|
}
|
|
};
|
|
|
|
isVisibleRef.current = document.visibilityState === "visible";
|
|
prefersReducedMotionRef.current = motionMedia.matches;
|
|
startAnimation();
|
|
|
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
motionMedia.addEventListener("change", handleMotionChange);
|
|
|
|
return () => {
|
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
motionMedia.removeEventListener("change", handleMotionChange);
|
|
stopAnimation();
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={className}
|
|
style={{
|
|
position: "relative",
|
|
minHeight: "100vh",
|
|
width: "100%",
|
|
overflow: "hidden",
|
|
transition: "background-color 0.6s ease",
|
|
}}
|
|
>
|
|
<canvas
|
|
ref={canvasRef}
|
|
aria-hidden
|
|
style={{
|
|
position: "absolute",
|
|
inset: 0,
|
|
zIndex: 0,
|
|
pointerEvents: "none",
|
|
}}
|
|
/>
|
|
|
|
<div
|
|
aria-hidden
|
|
style={{
|
|
position: "absolute",
|
|
inset: 0,
|
|
zIndex: 1,
|
|
opacity: palette.grainOpacity,
|
|
pointerEvents: "none",
|
|
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`,
|
|
backgroundRepeat: "repeat",
|
|
backgroundSize: "180px 180px",
|
|
}}
|
|
/>
|
|
|
|
<div style={{ position: "relative", zIndex: 2 }}>{children}</div>
|
|
</div>
|
|
);
|
|
}
|