"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(null); const containerRef = useRef(null); const frameRef = useRef(0); const animationFrameRef = useRef(null); const isMobileRef = useRef(false); const isVisibleRef = useRef(true); const prefersReducedMotionRef = useRef(false); const particlesRef = useRef([]); 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({ dpr: 1, height: 0, width: 0 }); const containerRectRef = useRef(null); const followSpeedRef = useRef(followSpeed); const mobileSpeedRef = useRef(mobileSpeed); const particleColorsRef = useRef(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 (
{children}
); }