Animated Background System
The portfolio background is chosen at runtime by AdaptiveBackground. Capable devices get a Three.js nebula (GrokNebulaBackground). Everyone else, and the loading state while Three.js is fetched, gets the Canvas2D particle field (GlobalBackground3D).
The root layout mounts this through ClientOnlyBackground in src/shared/components/ClientOnlyBackground.tsx, which waits until the client has mounted so the server HTML stays empty.
How the choice is made
src/features/three/utils/deviceCapabilities.ts probes the browser after mount:
- WebGL support (
webglorexperimental-webgl) - Low-end hardware:
deviceMemoryunder 4 GB, or fewer than 4 CPU cores - A mobile user-agent flag (recorded, not used in the decision)
shouldUseSimpleBackground is true when WebGL is missing or the device is low-end. AdaptiveBackground stores that result in a three-state flag that starts as null so the first client render matches SSR (nothing is painted). After startTransition, it renders the nebula or the Canvas2D fallback.
// src/features/three/AdaptiveBackground.tsx
const GrokNebulaBackground = dynamic(() => import("./GrokNebulaBackground"), {
ssr: false,
loading: () => <GlobalBackground3D />,
});
export default function AdaptiveBackground(): React.ReactElement | null {
const [use3D, setUse3D] = useState<boolean | null>(null);
useEffect(() => {
const capabilities = detectDeviceCapabilities();
startTransition(() => {
setUse3D(!capabilities.shouldUseSimpleBackground);
});
}, []);
if (use3D === null) return null;
if (use3D) return <GrokNebulaBackground />;
return <GlobalBackground3D />;
}Files
src/features/three/
├── AdaptiveBackground.tsx # capability check + dynamic import
├── GrokNebulaBackground.tsx # Three.js nebula (react-three-fiber)
├── GlobalBackground3D.tsx # Canvas2D fallback and loading state
├── Canvas2D.tsx # shared 2D canvas loop
└── utils/deviceCapabilities.tsGlobalBackground3D is the 2D implementation. The name is historical; the adaptive entry point is AdaptiveBackground.
Three.js nebula
GrokNebulaBackground renders 8,000 points with @react-three/fiber and @react-three/drei Points. Positions and velocities are seeded with seedrandom("42") inside a ±10 unit cube. Each frame, simplex noise (simplex-noise, scale 0.01) nudges the cloud, velocities bounce at BOUNDARY_LIMIT (10), and the pointer repels nearby points inside MOUSE_INFLUENCE_RADIUS (5).
The canvas uses frameloop="demand", dpr={[1, 2]}, and a camera at [0, 0, 5] with a 75° field of view. Point color follows the document theme:
| Theme | Point color | Light color |
|---|---|---|
| dark | #00BFFF | #A0C4FF |
| light | #0066FF | #4A90E2 |
| souk | #FFD700 | #E5B887 |
Animation pauses when battery saver is on, Save-Data is set, the tab is hidden, or prefers-reduced-motion: reduce matches. A MutationObserver on <html class> keeps the theme in sync.
AdaptiveBackground does not pass a theme prop, so the component mounts with its default ("dark") and then follows the live document class. Passing theme="light" explicitly returns null from GrokNebulaBackground.
Canvas2D fallback
GlobalBackground3D is shown when the device should use the simple background, and as the loading placeholder while the nebula chunk downloads. It draws through Canvas2D (dprClamp={[1, 2]}, lazy, suspendOnSaver, suspendWhenHidden). Reduced motion renders a single static frame.
Particle field
Particle count scales with viewport area and is clamped between 80 and 300:
const density = 0.00025; // particles per px²
const target = Math.min(300, Math.max(80, Math.floor(width * height * density)));Each particle is { x, y, vx, vy, size, hue }, seeded from 42. Velocity is (rand - 0.5) * 0.2, size is 0.6–2.0, and hue covers the full wheel. Points wrap a few pixels past the edges so they do not pop at the border.
Lighting and theme
A linear gradient fills the background (#111214 → #0b0c10 in dark mode, #f8fafc → #ffffff in light mode). A slow radial glow (rgba(80,160,255,0.18) dark, rgba(80,120,200,0.12) light) moves with sin / cos of the frame time. Particles are drawn with globalCompositeOperation = "lighter" at 70% saturation. Dark mode uses 70% lightness and 0.35 alpha; light mode uses 40% lightness and 0.25 alpha.
Theme updates use the same MutationObserver pattern as the nebula: watch class on document.documentElement.
The canvas sits at fixed inset-0 z-[-10] pointer-events-none, so page content stays above it. Before mount, the component returns an empty fixed placeholder so hydration does not shift layout.
Integration
// src/app/layout.tsx
import ClientOnlyBackground from '@/shared/components/ClientOnlyBackground';
// ClientOnlyBackground renders <AdaptiveBackground /> only after mount.
<ClientOnlyBackground />What to check
- Capable desktop with WebGL shows the nebula after the Canvas2D loading frame
- No WebGL, or under 4 GB RAM / under 4 cores, stays on Canvas2D
- Light and dark themes recolor both implementations
- Reduced motion, a hidden tab, and battery saver stop the loop
- Content remains above the background (
z-[-10])