|
| 1 | +import { useEffect, useRef } from 'react'; |
| 2 | + |
| 3 | +/** |
| 4 | + * Subtle falling code rain canvas — decorative background for the connect page. |
| 5 | + * |
| 6 | + * Renders columns of faint code-like characters that scroll downward at varying |
| 7 | + * speeds, giving the impression of live code being streamed. Designed to sit |
| 8 | + * behind the glass-morphism card without competing for attention. |
| 9 | + */ |
| 10 | +export function CodeRain() { |
| 11 | + const canvasRef = useRef<HTMLCanvasElement>(null); |
| 12 | + |
| 13 | + useEffect(() => { |
| 14 | + const canvas = canvasRef.current; |
| 15 | + if (!canvas) return; |
| 16 | + |
| 17 | + const ctx = canvas.getContext('2d'); |
| 18 | + if (!ctx) return; |
| 19 | + |
| 20 | + // Code-like snippets that feel realistic |
| 21 | + const snippets = [ |
| 22 | + 'const ', 'let ', 'fn ', 'def ', 'import ', 'export ', 'return ', |
| 23 | + 'async ', 'await ', 'if ', 'else ', 'for ', 'while ', 'match ', |
| 24 | + '=> ', '-> ', ':: ', '() ', '[] ', '{}', '..', '// ', |
| 25 | + 'true', 'false', 'null', 'nil', 'self', 'this', |
| 26 | + 'pub ', 'use ', 'mod ', 'impl ', 'trait ', 'type ', |
| 27 | + '<T>', 'Ok()', 'Err', 'Some', 'None', |
| 28 | + 'println!', 'console.', 'print(', 'log(', |
| 29 | + '= ', '!= ', '== ', '>= ', '<= ', '&& ', '|| ', |
| 30 | + '0x', '127', '443', '8080', '3000', |
| 31 | + 'utf-8', 'json', 'ssh', 'tcp', 'http', |
| 32 | + 'fn main', 'class ', 'struct ', 'enum ', |
| 33 | + '.map(', '.filter(', '.then(', '.catch(', |
| 34 | + 'Result<', 'Vec<', 'Option<', 'Promise<', |
| 35 | + '|> ', ':ok', ':error', 'defmodule ', |
| 36 | + ]; |
| 37 | + |
| 38 | + /** Pick a random snippet */ |
| 39 | + const randomSnippet = () => snippets[Math.floor(Math.random() * snippets.length)]; |
| 40 | + |
| 41 | + // --- Column state --- |
| 42 | + |
| 43 | + interface Column { |
| 44 | + x: number; |
| 45 | + y: number; |
| 46 | + speed: number; // px per frame |
| 47 | + opacity: number; // base opacity for this column |
| 48 | + chars: string; // current text being "typed" |
| 49 | + charIndex: number; // how many chars revealed so far |
| 50 | + fontSize: number; |
| 51 | + } |
| 52 | + |
| 53 | + let columns: Column[] = []; |
| 54 | + let w = 0; |
| 55 | + let h = 0; |
| 56 | + let animId = 0; |
| 57 | + |
| 58 | + const resize = () => { |
| 59 | + const dpr = window.devicePixelRatio || 1; |
| 60 | + const rect = canvas.getBoundingClientRect(); |
| 61 | + w = rect.width; |
| 62 | + h = rect.height; |
| 63 | + canvas.width = w * dpr; |
| 64 | + canvas.height = h * dpr; |
| 65 | + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); |
| 66 | + initColumns(); |
| 67 | + }; |
| 68 | + |
| 69 | + const initColumns = () => { |
| 70 | + const colGap = 26; // px between columns |
| 71 | + const count = Math.ceil(w / colGap); |
| 72 | + columns = Array.from({ length: count }, (_, i) => makeColumn(i * colGap, true)); |
| 73 | + }; |
| 74 | + |
| 75 | + const makeColumn = (x: number, randomizeY: boolean): Column => ({ |
| 76 | + x, |
| 77 | + y: randomizeY ? Math.random() * h : -20, |
| 78 | + speed: 0.2 + Math.random() * 0.45, |
| 79 | + opacity: 0.08 + Math.random() * 0.12, |
| 80 | + chars: randomSnippet() + randomSnippet() + ' ' + randomSnippet(), |
| 81 | + charIndex: 0, |
| 82 | + fontSize: 10 + Math.floor(Math.random() * 3), |
| 83 | + }); |
| 84 | + |
| 85 | + // --- Render loop --- |
| 86 | + |
| 87 | + const draw = () => { |
| 88 | + ctx.clearRect(0, 0, w, h); |
| 89 | + |
| 90 | + for (const col of columns) { |
| 91 | + ctx.font = `${col.fontSize}px "SF Mono", "Fira Code", "Cascadia Code", Menlo, Consolas, monospace`; |
| 92 | + |
| 93 | + // Draw each character vertically |
| 94 | + const lineH = col.fontSize + 4; |
| 95 | + const visible = col.chars.slice(0, Math.floor(col.charIndex)); |
| 96 | + |
| 97 | + for (let j = 0; j < visible.length; j++) { |
| 98 | + // Fade trailing characters |
| 99 | + const distFromHead = visible.length - 1 - j; |
| 100 | + const fade = Math.max(0, 1 - distFromHead * 0.08); |
| 101 | + const alpha = col.opacity * fade; |
| 102 | + |
| 103 | + // Head character gets a brighter teal tint |
| 104 | + if (j === visible.length - 1) { |
| 105 | + ctx.fillStyle = `rgba(0, 220, 240, ${Math.min(alpha * 3, 0.5)})`; |
| 106 | + } else { |
| 107 | + ctx.fillStyle = `rgba(180, 200, 220, ${alpha})`; |
| 108 | + } |
| 109 | + |
| 110 | + ctx.fillText(visible[j], col.x, col.y + j * lineH); |
| 111 | + } |
| 112 | + |
| 113 | + // Advance "typing" |
| 114 | + col.charIndex += col.speed * 0.5; |
| 115 | + |
| 116 | + // Scroll downward |
| 117 | + col.y += col.speed; |
| 118 | + |
| 119 | + // Reset when off-screen |
| 120 | + if (col.y > h + 40) { |
| 121 | + col.y = -(col.chars.length * (col.fontSize + 4)); |
| 122 | + col.chars = randomSnippet() + randomSnippet() + ' ' + randomSnippet(); |
| 123 | + col.charIndex = 0; |
| 124 | + col.speed = 0.2 + Math.random() * 0.45; |
| 125 | + col.opacity = 0.08 + Math.random() * 0.12; |
| 126 | + } |
| 127 | + |
| 128 | + // When fully typed, get new text |
| 129 | + if (col.charIndex > col.chars.length + 6) { |
| 130 | + col.chars = randomSnippet() + randomSnippet() + ' ' + randomSnippet(); |
| 131 | + col.charIndex = 0; |
| 132 | + } |
| 133 | + } |
| 134 | + |
| 135 | + animId = requestAnimationFrame(draw); |
| 136 | + }; |
| 137 | + |
| 138 | + resize(); |
| 139 | + animId = requestAnimationFrame(draw); |
| 140 | + |
| 141 | + window.addEventListener('resize', resize); |
| 142 | + return () => { |
| 143 | + window.removeEventListener('resize', resize); |
| 144 | + cancelAnimationFrame(animId); |
| 145 | + }; |
| 146 | + }, []); |
| 147 | + |
| 148 | + return ( |
| 149 | + <canvas |
| 150 | + ref={canvasRef} |
| 151 | + aria-hidden="true" |
| 152 | + style={{ |
| 153 | + position: 'absolute', |
| 154 | + inset: 0, |
| 155 | + width: '100%', |
| 156 | + height: '100%', |
| 157 | + pointerEvents: 'none', |
| 158 | + zIndex: 0, |
| 159 | + }} |
| 160 | + /> |
| 161 | + ); |
| 162 | +} |
0 commit comments