Animated Background System
A detailed exploration of the custom-built Canvas2D particle animation that powers the portfolio's signature visual effect.
🎨 Overview
Despite the component name GlobalBackground3D, this system uses HTML5 Canvas 2D (not WebGL or Three.js) for optimal performance and compatibility. It creates an elegant particle system with dynamic lighting effects.
🚀 Key Features
- 80-300 particles based on viewport density
- Animated movement with smooth velocity vectors
- Dynamic lighting using moving radial gradients
- Theme adaptation for light and dark modes
- Performance optimized with reduced motion support
- Battery conscious - pauses when tab hidden or battery saver enabled
📁 Component Location
src/features/three/GlobalBackground3D.tsxNote: Named "3D" for historical reasons, but uses 2D Canvas rendering.
🔧 Technical Implementation
Canvas Setup
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { alpha: true });
// Set canvas size with device pixel ratio
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = window.innerWidth * dpr;
canvas.height = window.innerHeight * dpr;
canvas.style.width = `${window.innerWidth}px`;
canvas.style.height = `${window.innerHeight}px`;
ctx.scale(dpr, dpr);Particle System
Particle Generation
interface Particle {
x: number;
y: number;
vx: number; // velocity X
vy: number; // velocity Y
size: number;
hue: number; // color hue
}
// Density-based particle count
const particleDensity = 0.00025; // particles per square pixel
const numParticles = Math.floor(
(canvas.width * canvas.height) * particleDensity
);
// Generate particles with deterministic seeding
const particles: Particle[] = [];
for (let i = 0; i < numParticles; i++) {
particles.push({
x: seededRandom() * canvas.width,
y: seededRandom() * canvas.height,
vx: (seededRandom() - 0.5) * 0.5,
vy: (seededRandom() - 0.5) * 0.5,
size: seededRandom() * 2 + 1,
hue: seededRandom() * 60 + 180, // Blue-cyan range
});
}Particle Movement
function updateParticles() {
particles.forEach(particle => {
// Update position
particle.x += particle.vx;
particle.y += particle.vy;
// Wrap around edges
if (particle.x < 0) particle.x = canvas.width;
if (particle.x > canvas.width) particle.x = 0;
if (particle.y < 0) particle.y = canvas.height;
if (particle.y > canvas.height) particle.y = 0;
});
}Dynamic Lighting
Moving Light Source
let time = 0;
function getLightPosition() {
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const radiusX = canvas.width * 0.3;
const radiusY = canvas.height * 0.3;
return {
x: centerX + Math.cos(time * 0.001) * radiusX,
y: centerY + Math.sin(time * 0.0015) * radiusY,
};
}
function animationLoop(timestamp: number) {
time = timestamp;
const light = getLightPosition();
// Create radial gradient for lighting effect
const gradient = ctx.createRadialGradient(
light.x, light.y, 0,
light.x, light.y, canvas.width * 0.5
);
// Theme-aware colors
if (isDarkMode) {
gradient.addColorStop(0, 'rgba(100, 150, 255, 0.3)');
gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
} else {
gradient.addColorStop(0, 'rgba(255, 255, 255, 0.5)');
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
}
requestAnimationFrame(animationLoop);
}Rendering
function render() {
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw background gradient
const bgGradient = ctx.createLinearGradient(
0, 0, canvas.width, canvas.height
);
if (isDarkMode) {
bgGradient.addColorStop(0, '#0a0a0f');
bgGradient.addColorStop(1, '#1a1a2e');
} else {
bgGradient.addColorStop(0, '#f8f9fa');
bgGradient.addColorStop(1, '#e9ecef');
}
ctx.fillStyle = bgGradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw lighting effect
ctx.fillStyle = lightGradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw particles
particles.forEach(particle => {
ctx.beginPath();
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${particle.hue}, 70%, ${
isDarkMode ? 60 : 40
}%, 0.6)`;
ctx.fill();
});
}🎭 Theme Adaptation
Theme Detection
"use client";
import { useEffect, useState } from 'react';
function GlobalBackground3D() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
// Initial theme detection
const updateTheme = () => {
setIsDark(
document.documentElement.classList.contains('dark')
);
};
updateTheme();
// Watch for theme changes
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (
mutation.type === 'attributes' &&
mutation.attributeName === 'class'
) {
updateTheme();
}
});
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
});
return () => observer.disconnect();
}, []);
return <canvas ref={canvasRef} />;
}Theme Colors
Dark Mode:
- Background: Dark blue gradient (#0a0a0f → #1a1a2e)
- Particles: Bright cyan-blue (HSL 180-240, 70%, 60%)
- Lighting: Blue radial gradient with transparency
Light Mode:
- Background: Light gray gradient (#f8f9fa → #e9ecef)
- Particles: Subtle blue-gray (HSL 180-240, 70%, 40%)
- Lighting: White radial gradient with transparency
⚡ Performance Optimizations
Reduced Motion Support
useEffect(() => {
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
if (prefersReducedMotion) {
// Render single static frame
render();
return;
}
// Start animation loop
const animationId = requestAnimationFrame(animationLoop);
return () => cancelAnimationFrame(animationId);
}, []);Battery Saver Detection
useEffect(() => {
let animationPaused = false;
async function checkBattery() {
if ('getBattery' in navigator) {
const battery = await (navigator as any).getBattery();
animationPaused = battery.charging === false &&
battery.level < 0.2;
}
}
checkBattery();
function animationLoop() {
if (!animationPaused) {
render();
requestAnimationFrame(animationLoop);
}
}
animationLoop();
}, []);Tab Visibility
useEffect(() => {
let animationActive = true;
function handleVisibilityChange() {
animationActive = !document.hidden;
}
document.addEventListener('visibilitychange', handleVisibilityChange);
function animationLoop() {
if (animationActive) {
render();
}
requestAnimationFrame(animationLoop);
}
animationLoop();
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);Device Pixel Ratio Clamping
// Limit DPR to prevent excessive memory usage on high-DPI displays
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = window.innerWidth * dpr;
canvas.height = window.innerHeight * dpr;🐛 Recent Fixes (January 2025)
Z-Index Layering
Problem: Background appeared above content on some pages.
Solution:
<canvas
className="fixed inset-0 z-[-10] pointer-events-none"
/>Changed from z-0 to z-[-10] to ensure proper layering.
SSR Hydration
Problem: Flash and layout shift on initial page load.
Solution:
if (!mounted) {
return (
<div
className="fixed inset-0 z-[-10] opacity-0"
aria-hidden="true"
/>
);
}Placeholder div prevents hydration mismatch.
Dynamic Theme Detection
Problem: Theme changes didn't update immediately.
Solution:
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (
mutation.type === 'attributes' &&
mutation.attributeName === 'class'
) {
updateTheme();
}
});
});MutationObserver watches for class changes on <html>.
🎨 Customization
Adjusting Particle Density
// More particles
const particleDensity = 0.0005; // Default: 0.00025
// Fewer particles
const particleDensity = 0.0001;Changing Colors
// Dark mode - purple theme
gradient.addColorStop(0, 'rgba(138, 43, 226, 0.3)');
gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
// Light mode - warm theme
gradient.addColorStop(0, 'rgba(255, 200, 150, 0.5)');
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');Adjusting Movement Speed
// Faster particles
vx: (seededRandom() - 0.5) * 1.0, // Default: 0.5
vy: (seededRandom() - 0.5) * 1.0,
// Slower particles
vx: (seededRandom() - 0.5) * 0.2,
vy: (seededRandom() - 0.5) * 0.2,Lighting Speed
// Faster light movement
x: centerX + Math.cos(time * 0.002) * radiusX, // Default: 0.001
y: centerY + Math.sin(time * 0.003) * radiusY, // Default: 0.0015📚 Integration
Root Layout
// src/app/layout.tsx
import GlobalBackground3D from '@/features/three/GlobalBackground3D';
export default function RootLayout({ children }) {
return (
<html>
<body>
<GlobalBackground3D />
{children}
</body>
</html>
);
}CSS Requirements
/* Ensure content appears above background */
main {
position: relative;
z-index: 1;
}
/* Disable pointer events on background */
canvas {
pointer-events: none;
}🧪 Testing
Manual Testing Checklist
- Particles render on page load
- Theme switches correctly (light/dark)
- Animation pauses with reduced motion enabled
- Background stays behind content (z-index)
- Responsive to window resize
- No performance issues on low-end devices
- Battery saver detection works
- Tab visibility pauses animation
Performance Testing
// Measure FPS
let frameCount = 0;
let lastTime = performance.now();
function measureFPS() {
frameCount++;
const currentTime = performance.now();
if (currentTime >= lastTime + 1000) {
const fps = frameCount;
console.log(`FPS: ${fps}`);
frameCount = 0;
lastTime = currentTime;
}
requestAnimationFrame(measureFPS);
}
measureFPS();