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

View File

@@ -34,7 +34,7 @@ export default function ProjectsPage() {
return ( return (
<ScrollArea className="px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10"> <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" /> <div className="pt-10" />
{projects.map((project, i) => ( {projects.map((project, i) => (
<div id={project.id} key={i} className="scroll-mt-10"> <div id={project.id} key={i} className="scroll-mt-10">