refactor animated background, fix type

This commit is contained in:
2026-04-24 14:23:52 +02:00
parent ea7ddb8e51
commit 65b9184a22
2 changed files with 219 additions and 175 deletions

View File

@@ -1,16 +1,10 @@
"use client";
import React, { useRef, useEffect, useCallback, useState } from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useTheme } from "next-themes";
/* ─────────────────────────────────────────────
* Config — grayscale palettes
* ───────────────────────────────────────────── */
const PALETTES = {
dark: {
base: "#0a0a0a",
particles: [
"rgba(255,255,255,0.70)",
"rgba(255,255,255,0.45)",
@@ -18,9 +12,9 @@ const PALETTES = {
"rgba(200,200,200,0.35)",
"rgba(255,255,255,0.22)",
],
grainOpacity: 0.05,
},
light: {
base: "#f5f5f5",
particles: [
"rgba(0,0,0,0.55)",
"rgba(0,0,0,0.35)",
@@ -28,22 +22,10 @@ const PALETTES = {
"rgba(80,80,80,0.25)",
"rgba(0,0,0,0.18)",
],
grainOpacity: 0.03,
},
} as const;
/* ─────────────────────────────────────────────
* Helpers
* ───────────────────────────────────────────── */
const isMobileDevice = (): boolean => {
if (typeof window === "undefined") return false;
return window.matchMedia("(pointer: coarse)").matches || window.innerWidth < 768;
};
const rand = (min: number, max: number) => Math.random() * (max - min) + min;
/* ─────────────────────────────────────────────
* Particle
* ───────────────────────────────────────────── */
interface Particle {
angle: number;
radius: number;
@@ -55,33 +37,43 @@ interface Particle {
wobblePhase: number;
}
const spawnParticle = (): Particle => ({
angle: rand(0, Math.PI * 2),
radius: rand(30, 240),
speed: rand(0.003, 0.002) * (Math.random() > 0.5 ? 1 : -1),
size: rand(1.2, 4),
colorIndex: Math.floor(rand(0, 5)),
wobbleAmp: rand(6, 30),
wobbleSpeed: rand(0.008, 0.035),
wobblePhase: rand(0, Math.PI * 2),
});
/* ─────────────────────────────────────────────
* Component
* ───────────────────────────────────────────── */
interface AnimatedBackgroundContainerProps {
children: React.ReactNode;
className?: string;
/** Number of orbiting particles. Default 60 */
particleCount?: number;
/** Max orbit radius in px — controls how far particles spread from the cursor. Default 240 */
orbitRadius?: number;
/** How quickly particles catch up to cursor (01). Default 0.06 */
followSpeed?: number;
/** Speed multiplier for mobile random anchor drift. Default 1 */
mobileSpeed?: number;
}
const DEFAULT_PARTICLE_COLORS: readonly string[] = PALETTES.dark.particles;
const PARTICLE_COLOR_COUNT = DEFAULT_PARTICLE_COLORS.length;
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 = "",
@@ -92,172 +84,225 @@ export default function AnimatedBackgroundContainer({
}: AnimatedBackgroundContainerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const mousePos = useRef({ x: 0, y: 0 });
const smoothMouse = useRef({ x: 0, y: 0 });
const mobileAnchor = useRef({ x: 0, y: 0 });
const mobileTarget = useRef({ x: 0, y: 0 });
const isMobile = useRef(false);
const particles = useRef<Particle[]>([]);
const frame = useRef(0);
const frameRef = useRef(0);
const animationFrameRef = useRef<number | null>(null);
const isMobileRef = 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 followSpeedRef = useRef(followSpeed);
const mobileSpeedRef = useRef(mobileSpeed);
const particleColorsRef = useRef<readonly string[]>(DEFAULT_PARTICLE_COLORS);
const [mounted, setMounted] = useState(false);
const { resolvedTheme } = useTheme();
let isDark = resolvedTheme === "dark";
if (resolvedTheme == undefined) {
isDark = true;
}
const isDark = resolvedTheme === undefined || resolvedTheme === "dark";
const palette = isDark ? PALETTES.dark : PALETTES.light;
/* Spawn particles */
useEffect(() => {
const minR = Math.max(10, orbitRadius * 0.12);
particles.current = Array.from({ length: particleCount }, () => ({
...spawnParticle(),
radius: rand(minR, orbitRadius),
wobbleAmp: rand(orbitRadius * 0.025, orbitRadius * 0.12),
}));
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]);
/* Detect mobile & seed positions */
const seedPositions = useCallback(() => {
const container = containerRef.current;
if (!container) {
return;
}
const centerX = container.clientWidth / 2;
const centerY = container.clientHeight / 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 resizeCanvas = useCallback(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) {
return;
}
const width = container.clientWidth;
const height = container.clientHeight;
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
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);
if (!mounted) {
seedPositions();
}
}, [mounted, seedPositions]);
useEffect(() => {
setMounted(true);
isMobile.current = isMobileDevice();
if (containerRef.current) {
const cx = containerRef.current.clientWidth / 2;
const cy = containerRef.current.clientHeight / 2;
mousePos.current = { x: cx, y: cy };
smoothMouse.current = { x: cx, y: cy };
mobileAnchor.current = { x: cx, y: cy };
mobileTarget.current = {
x: rand(cx * 0.4, cx * 1.6),
y: rand(cy * 0.4, cy * 1.6),
isMobileRef.current = isMobileDevice();
resizeCanvas();
const handleResize = () => {
isMobileRef.current = isMobileDevice();
resizeCanvas();
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [resizeCanvas]);
useEffect(() => {
if (!mounted) {
return;
}
}, []);
/* Resize canvas to match container */
useEffect(() => {
const resize = () => {
const canvas = canvasRef.current;
seedPositions();
}, [mounted, seedPositions]);
const handleMouseMove = useCallback((event: MouseEvent) => {
const container = containerRef.current;
if (!canvas || !container) return;
const dpr = window.devicePixelRatio || 1;
const w = container.clientWidth;
const h = container.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
const ctx = canvas.getContext("2d");
if (ctx) ctx.scale(dpr, dpr);
};
resize();
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}, []);
if (!container || isMobileRef.current) {
return;
}
/* Mouse tracking (desktop only) */
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!containerRef.current || isMobile.current) return;
const rect = containerRef.current.getBoundingClientRect();
mousePos.current = {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
const rect = container.getBoundingClientRect();
mousePosRef.current = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
}, []);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
el.addEventListener("mousemove", handleMouseMove, { passive: true });
return () => el.removeEventListener("mousemove", handleMouseMove);
const container = containerRef.current;
if (!container) {
return;
}
container.addEventListener("mousemove", handleMouseMove, { passive: true });
return () => container.removeEventListener("mousemove", handleMouseMove);
}, [handleMouseMove]);
/* ── GSAP ticker — draw loop ── */
useGSAP(
() => {
if (!mounted) return;
useEffect(() => {
if (!mounted) {
return;
}
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
if (!canvas || !container) {
return;
}
const ctx = canvas.getContext("2d");
if (!ctx) return;
if (!ctx) {
return;
}
const tick = () => {
const w = container.clientWidth;
const h = container.clientHeight;
frame.current++;
const draw = () => {
const width = container.clientWidth;
const height = container.clientHeight;
frameRef.current += 1;
/* Anchor: smooth-follow cursor or drift on mobile */
if (isMobile.current) {
mobileAnchor.current.x +=
(mobileTarget.current.x - mobileAnchor.current.x) * 0.008 * mobileSpeed;
mobileAnchor.current.y +=
(mobileTarget.current.y - mobileAnchor.current.y) * 0.008 * mobileSpeed;
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 = mobileTarget.current.x - mobileAnchor.current.x;
const dy = mobileTarget.current.y - mobileAnchor.current.y;
if (Math.sqrt(dx * dx + dy * dy) < 30) {
mobileTarget.current = {
x: rand(w * 0.15, w * 0.85),
y: rand(h * 0.15, h * 0.85),
const dx = mobileTargetRef.current.x - mobileAnchorRef.current.x;
const dy = mobileTargetRef.current.y - mobileAnchorRef.current.y;
if (Math.hypot(dx, dy) < 30) {
mobileTargetRef.current = {
x: rand(width * 0.15, width * 0.85),
y: rand(height * 0.15, height * 0.85),
};
}
smoothMouse.current.x = mobileAnchor.current.x;
smoothMouse.current.y = mobileAnchor.current.y;
smoothMouseRef.current = { ...mobileAnchorRef.current };
} else {
smoothMouse.current.x +=
(mousePos.current.x - smoothMouse.current.x) * followSpeed;
smoothMouse.current.y +=
(mousePos.current.y - smoothMouse.current.y) * followSpeed;
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 cx = smoothMouse.current.x;
const cy = smoothMouse.current.y;
const centerX = smoothMouseRef.current.x;
const centerY = smoothMouseRef.current.y;
const colors = particleColorsRef.current;
/* Clear frame */
ctx.clearRect(0, 0, w, h);
ctx.clearRect(0, 0, width, height);
/* Draw each particle */
particles.current.forEach((p) => {
p.angle += p.speed;
for (const particle of particlesRef.current) {
particle.angle += particle.speed;
const wobble =
Math.sin(frame.current * p.wobbleSpeed + p.wobblePhase) * p.wobbleAmp;
const r = p.radius + 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 x = cx + Math.cos(p.angle) * r;
const y = cy + Math.sin(p.angle) * r;
/* Soft fade near viewport edges */
const edgeFade = Math.max(
0,
Math.min(x / 80, (w - x) / 80, y / 80, (h - y) / 80, 1),
Math.min(x / 80, (width - x) / 80, y / 80, (height - y) / 80, 1),
);
if (edgeFade <= 0) return;
if (edgeFade <= 0) {
continue;
}
ctx.globalAlpha = edgeFade;
ctx.fillStyle = palette.particles[p.colorIndex];
ctx.fillStyle = colors[particle.colorIndex] ?? colors[0] ?? "#ffffff";
ctx.beginPath();
ctx.arc(x, y, p.size, 0, Math.PI * 2);
ctx.arc(x, y, particle.size, 0, Math.PI * 2);
ctx.fill();
});
}
ctx.globalAlpha = 1;
animationFrameRef.current = window.requestAnimationFrame(draw);
};
gsap.ticker.add(tick);
animationFrameRef.current = window.requestAnimationFrame(draw);
return () => {
gsap.ticker.remove(tick);
if (animationFrameRef.current !== null) {
window.cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
};
},
{
scope: containerRef,
dependencies: [mounted, isDark, followSpeed, mobileSpeed, orbitRadius, palette],
},
);
}, [mounted]);
/* ── Render ── */
return (
<div
ref={containerRef}
@@ -281,14 +326,13 @@ export default function AnimatedBackgroundContainer({
}}
/>
{/* Grain texture */}
<div
aria-hidden
style={{
position: "absolute",
inset: 0,
zIndex: 1,
opacity: isDark ? 0.05 : 0.03,
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",

View File

@@ -34,7 +34,7 @@ export default function ProjectsPage() {
return (
<ScrollArea className="px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10">
<AnimatedPageTitle position={0}><span>Project I've Been</span><span> Working on</span> </AnimatedPageTitle>
<AnimatedPageTitle position={0}><span>Projects I've Been</span><span> Working on</span> </AnimatedPageTitle>
<div className="pt-10" />
{projects.map((project, i) => (
<div id={project.id} key={i} className="scroll-mt-10">