|
1 | 1 | 'use client'; |
2 | 2 |
|
3 | | -import { motion } from 'motion/react'; |
4 | | -import { useEffect, useState, useMemo } from 'react'; |
5 | | -import { GRID_SIZE } from './grid-background'; |
| 3 | +import { useEffect, useRef } from 'react'; |
| 4 | +import { useIsMobile, useReducedMotion } from '@/hooks/use-mobile'; |
| 5 | + |
| 6 | +const GRID_SIZE = 40; |
| 7 | +const LARGE_GRID_SIZE = 200; |
| 8 | + |
| 9 | +interface Cell { |
| 10 | + r: number; |
| 11 | + c: number; |
| 12 | + phase: number; |
| 13 | + speed: number; |
| 14 | +} |
6 | 15 |
|
7 | 16 | export function BlinkingGridBackground() { |
8 | | - const [mounted, setMounted] = useState(false); |
| 17 | + const canvasRef = useRef<HTMLCanvasElement>(null); |
| 18 | + const isMobile = useIsMobile(); |
| 19 | + const prefersReducedMotion = useReducedMotion(); |
9 | 20 |
|
10 | 21 | useEffect(() => { |
11 | | - setMounted(true); |
12 | | - }, []); |
13 | | - |
14 | | - // Generate enough cells to cover large viewports |
15 | | - const COLS = 50; // Wide enough for 2000px+ screens |
16 | | - const ROWS = 30; // Tall enough for 1200px+ screens |
17 | | - |
18 | | - const cells = useMemo(() => { |
19 | | - const items = []; |
20 | | - for (let r = 0; r < ROWS; r++) { |
21 | | - for (let c = 0; c < COLS; c++) { |
22 | | - // Sparsity: 12% of cells blink for a subtle effect |
23 | | - if (Math.random() > 0.12) continue; |
24 | | - items.push({ r, c, delay: Math.random() * 8 }); |
| 22 | + const canvas = canvasRef.current; |
| 23 | + if (!canvas) return; |
| 24 | + |
| 25 | + const ctx = canvas.getContext('2d'); |
| 26 | + if (!ctx) return; |
| 27 | + |
| 28 | + // Generate blinking cells (skip only if reduced motion preferred) |
| 29 | + const cells: Cell[] = []; |
| 30 | + if (!prefersReducedMotion) { |
| 31 | + const COLS = Math.ceil(2000 / GRID_SIZE); |
| 32 | + const ROWS = Math.ceil(1200 / GRID_SIZE); |
| 33 | + // Fewer cells on mobile for performance |
| 34 | + const cellDensity = isMobile ? 0.06 : 0.1; |
| 35 | + |
| 36 | + for (let r = 0; r < ROWS; r++) { |
| 37 | + for (let c = 0; c < COLS; c++) { |
| 38 | + if (Math.random() > cellDensity) continue; |
| 39 | + cells.push({ |
| 40 | + r, |
| 41 | + c, |
| 42 | + phase: Math.random() * Math.PI * 2, |
| 43 | + speed: 0.3 + Math.random() * 0.5, |
| 44 | + }); |
| 45 | + } |
25 | 46 | } |
26 | 47 | } |
27 | | - return items; |
28 | | - }, []); |
29 | 48 |
|
30 | | - if (!mounted) return null; |
| 49 | + let animationId: number; |
| 50 | + const startTime = performance.now(); |
| 51 | + |
| 52 | + const drawGrid = (isDark: boolean, width: number, height: number) => { |
| 53 | + |
| 54 | + // Draw small grid lines |
| 55 | + ctx.beginPath(); |
| 56 | + ctx.strokeStyle = isDark |
| 57 | + ? 'rgba(37, 99, 235, 0.08)' // blue-600 very subtle |
| 58 | + : 'rgba(148, 163, 184, 0.15)'; // slate-400 subtle |
| 59 | + ctx.lineWidth = 1; |
| 60 | + |
| 61 | + // Vertical lines |
| 62 | + for (let x = 0; x <= width; x += GRID_SIZE) { |
| 63 | + ctx.moveTo(x, 0); |
| 64 | + ctx.lineTo(x, height); |
| 65 | + } |
| 66 | + // Horizontal lines |
| 67 | + for (let y = 0; y <= height; y += GRID_SIZE) { |
| 68 | + ctx.moveTo(0, y); |
| 69 | + ctx.lineTo(width, y); |
| 70 | + } |
| 71 | + ctx.stroke(); |
| 72 | + |
| 73 | + // Draw large grid lines |
| 74 | + ctx.beginPath(); |
| 75 | + ctx.strokeStyle = isDark |
| 76 | + ? 'rgba(37, 99, 235, 0.05)' // blue-600 very faint |
| 77 | + : 'rgba(148, 163, 184, 0.10)'; // slate-400 faint |
| 78 | + ctx.lineWidth = 1; |
| 79 | + |
| 80 | + // Vertical lines |
| 81 | + for (let x = 0; x <= width; x += LARGE_GRID_SIZE) { |
| 82 | + ctx.moveTo(x, 0); |
| 83 | + ctx.lineTo(x, height); |
| 84 | + } |
| 85 | + // Horizontal lines |
| 86 | + for (let y = 0; y <= height; y += LARGE_GRID_SIZE) { |
| 87 | + ctx.moveTo(0, y); |
| 88 | + ctx.lineTo(width, y); |
| 89 | + } |
| 90 | + ctx.stroke(); |
| 91 | + }; |
| 92 | + |
| 93 | + const animate = (time: number) => { |
| 94 | + const elapsed = (time - startTime) / 1000; |
| 95 | + |
| 96 | + // Resize canvas to match display size |
| 97 | + const rect = canvas.getBoundingClientRect(); |
| 98 | + const dpr = window.devicePixelRatio || 1; |
| 99 | + const width = rect.width * dpr; |
| 100 | + const height = rect.height * dpr; |
| 101 | + |
| 102 | + if (canvas.width !== width || canvas.height !== height) { |
| 103 | + canvas.width = width; |
| 104 | + canvas.height = height; |
| 105 | + ctx.scale(dpr, dpr); |
| 106 | + } |
| 107 | + |
| 108 | + // Clear |
| 109 | + ctx.clearRect(0, 0, rect.width, rect.height); |
| 110 | + |
| 111 | + // Check for dark mode |
| 112 | + const isDark = document.documentElement.classList.contains('dark'); |
| 113 | + |
| 114 | + // Draw static grid lines first |
| 115 | + drawGrid(isDark, rect.width, rect.height); |
| 116 | + |
| 117 | + // Draw blinking cells (skip only if reduced motion preferred) |
| 118 | + if (!prefersReducedMotion) { |
| 119 | + cells.forEach((cell) => { |
| 120 | + // Max opacity reduced for subtlety |
| 121 | + const maxOpacity = isDark ? 0.1 : 0.18; |
| 122 | + const opacity = ((Math.sin(elapsed * cell.speed + cell.phase) + 1) / 2) * maxOpacity; |
| 123 | + if (opacity < 0.02) return; // Skip nearly invisible cells |
| 124 | + |
| 125 | + ctx.fillStyle = isDark |
| 126 | + ? `rgba(96, 165, 250, ${opacity})` // blue-400 |
| 127 | + : `rgba(59, 130, 246, ${opacity})`; // blue-500 |
| 128 | + |
| 129 | + ctx.fillRect( |
| 130 | + cell.c * GRID_SIZE, |
| 131 | + cell.r * GRID_SIZE, |
| 132 | + GRID_SIZE - 1, // Slight inset to not overlap grid lines |
| 133 | + GRID_SIZE - 1 |
| 134 | + ); |
| 135 | + }); |
| 136 | + } |
| 137 | + |
| 138 | + animationId = requestAnimationFrame(animate); |
| 139 | + }; |
| 140 | + |
| 141 | + animationId = requestAnimationFrame(animate); |
| 142 | + |
| 143 | + return () => { |
| 144 | + cancelAnimationFrame(animationId); |
| 145 | + }; |
| 146 | + }, [isMobile, prefersReducedMotion]); |
31 | 147 |
|
32 | 148 | return ( |
33 | | - // Absolute positioning within parent, with radial fade mask |
34 | | - <div className="absolute inset-0 overflow-hidden pointer-events-none [mask-image:radial-gradient(ellipse_80%_70%_at_50%_40%,black_10%,transparent_70%)]"> |
35 | | - <svg className="w-full h-full" width="100%" height="100%"> |
36 | | - {cells.map((cell, i) => ( |
37 | | - <motion.rect |
38 | | - key={i} |
39 | | - // Coordinates match the grid pattern exactly |
40 | | - x={cell.c * GRID_SIZE} |
41 | | - y={cell.r * GRID_SIZE} |
42 | | - width={GRID_SIZE} |
43 | | - height={GRID_SIZE} |
44 | | - className="fill-blue-400/30 dark:fill-blue-400/25" |
45 | | - initial={{ opacity: 0 }} |
46 | | - animate={{ opacity: [0, 0.5, 0] }} |
47 | | - transition={{ |
48 | | - duration: 3 + Math.random() * 4, |
49 | | - repeat: Infinity, |
50 | | - delay: cell.delay, |
51 | | - ease: 'easeInOut', |
52 | | - }} |
53 | | - /> |
54 | | - ))} |
55 | | - </svg> |
| 149 | + <div className="absolute inset-0 overflow-hidden pointer-events-none"> |
| 150 | + <canvas |
| 151 | + ref={canvasRef} |
| 152 | + className="w-full h-full" |
| 153 | + style={{ width: '100%', height: '100%' }} |
| 154 | + /> |
56 | 155 | </div> |
57 | 156 | ); |
58 | 157 | } |
0 commit comments