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.tsx

Note: 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();

📖 Learn More

On this page