/* ============================================================ combo.codes — LetterGlitch background (interactive spotlight) Canvas port of the reactbits.dev "Letter Glitch" effect, naturalized to the combo.codes palette: the code field sits GREY at rest, and lights up in brand colors under the cursor like a torch. Exposed on window.LetterGlitch. Pure presentation — no logic. ============================================================ */ function LetterGlitch({ baseColors, highlightColors, interactive = true, spotlightRadius = 150, glitchSpeed = 70, smooth = true, outerVignette = true, centerVignette = true, className = '', style = {}, }) { const canvasRef = React.useRef(null); const animationRef = React.useRef(null); const letters = React.useRef([]); const grid = React.useRef({ columns: 0, rows: 0 }); const context = React.useRef(null); const lastGlitchTime = React.useRef(Date.now()); const mouse = React.useRef({ x: -9999, y: -9999, active: false, dirty: false }); const fontSize = 16; const charWidth = 10; const charHeight = 20; const GLYPHS = [ 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', '0','1','2','3','4','5','6','7','8','9', '{','}','(',')','[',']','<','>','/','\\',';',':','.',',','=','+','-','*','_','!','?','#','$','%','&','@','|','~','^', ]; const bases = baseColors && baseColors.length ? baseColors : ['#33303C', '#3E3A48', '#4A4555', '#565061']; const glows = highlightColors && highlightColors.length ? highlightColors : ['#A855F7', '#C8FF2E', '#2FE6FF']; const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]; const getRandomChar = () => GLYPHS[Math.floor(Math.random() * GLYPHS.length)]; const hexToRgb = (hex) => { const shorthand = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; hex = hex.replace(shorthand, (m, r, g, b) => r + r + g + g + b + b); const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); return result ? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16) } : { r: 80, g: 76, b: 92 }; }; const rgbStr = (c) => 'rgb(' + c.r + ', ' + c.g + ', ' + c.b + ')'; const mix = (start, end, factor) => ({ r: Math.round(start.r + (end.r - start.r) * factor), g: Math.round(start.g + (end.g - start.g) * factor), b: Math.round(start.b + (end.b - start.b) * factor), }); const calculateGrid = (width, height) => ({ columns: Math.ceil(width / charWidth), rows: Math.ceil(height / charHeight), }); const initializeLetters = (columns, rows) => { grid.current = { columns, rows }; const total = columns * rows; letters.current = Array.from({ length: total }, () => { const base = pick(bases); return { char: getRandomChar(), base: base, baseRgb: hexToRgb(base), glowRgb: hexToRgb(pick(glows)), targetRgb: hexToRgb(base), colorProgress: 1, }; }); }; const resizeCanvas = () => { const canvas = canvasRef.current; if (!canvas) return; const parent = canvas.parentElement; if (!parent) return; const dpr = window.devicePixelRatio || 1; const rect = parent.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; canvas.style.width = rect.width + 'px'; canvas.style.height = rect.height + 'px'; if (context.current) context.current.setTransform(dpr, 0, 0, dpr, 0, 0); const { columns, rows } = calculateGrid(rect.width, rect.height); initializeLetters(columns, rows); drawLetters(); }; const drawLetters = () => { if (!context.current || letters.current.length === 0) return; const ctx = context.current; const rect = canvasRef.current.getBoundingClientRect(); ctx.clearRect(0, 0, rect.width, rect.height); ctx.font = fontSize + 'px ui-monospace, monospace'; ctx.textBaseline = 'top'; const m = mouse.current; const lit = interactive && m.active; const r = spotlightRadius; const cols = grid.current.columns; letters.current.forEach((letter, index) => { const col = index % cols; const row = (index - col) / cols; const x = col * charWidth; const y = row * charHeight; let color = letter.targetRgb; if (lit) { const dx = x + charWidth / 2 - m.x; const dy = y + charHeight / 2 - m.y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < r) { let f = 1 - dist / r; f = f * f * (3 - 2 * f); // smoothstep: bright, generous core; soft edge color = mix(letter.targetRgb, letter.glowRgb, f); } } ctx.fillStyle = rgbStr(color); ctx.fillText(letter.char, x, y); }); }; const updateLetters = () => { if (!letters.current || letters.current.length === 0) return; const updateCount = Math.max(1, Math.floor(letters.current.length * 0.05)); for (let i = 0; i < updateCount; i++) { const index = Math.floor(Math.random() * letters.current.length); const l = letters.current[index]; if (!l) continue; l.char = getRandomChar(); const next = hexToRgb(pick(bases)); l.glowRgb = hexToRgb(pick(glows)); if (!smooth) { l.baseRgb = next; l.targetRgb = next; l.colorProgress = 1; } else { l.baseRgb = l.targetRgb; l.target = next; l.targetRgb0 = l.targetRgb; l._to = next; l.colorProgress = 0; } } }; // advance grey<->grey crossfades; returns true if anything changed const advanceSmooth = () => { let changed = false; letters.current.forEach((letter) => { if (letter.colorProgress < 1 && letter._to) { letter.colorProgress += 0.05; if (letter.colorProgress > 1) letter.colorProgress = 1; letter.targetRgb = mix(letter.baseRgb, letter._to, letter.colorProgress); changed = true; } }); return changed; }; const animate = () => { const now = Date.now(); let draw = false; if (now - lastGlitchTime.current >= glitchSpeed) { updateLetters(); lastGlitchTime.current = now; draw = true; } if (smooth && advanceSmooth()) draw = true; if (mouse.current.dirty) { draw = true; mouse.current.dirty = false; } if (draw) drawLetters(); animationRef.current = requestAnimationFrame(animate); }; React.useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; context.current = canvas.getContext('2d'); resizeCanvas(); const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (!reduce) animate(); else drawLetters(); const onMove = (e) => { const rect = canvas.getBoundingClientRect(); mouse.current.x = e.clientX - rect.left; mouse.current.y = e.clientY - rect.top; mouse.current.active = true; mouse.current.dirty = true; if (reduce) drawLetters(); }; const onLeave = () => { mouse.current.active = false; mouse.current.dirty = true; if (reduce) drawLetters(); }; if (interactive) { window.addEventListener('pointermove', onMove, { passive: true }); window.addEventListener('pointerdown', onMove, { passive: true }); window.addEventListener('blur', onLeave); document.addEventListener('mouseleave', onLeave); } let timeout; const handleResize = () => { cancelAnimationFrame(animationRef.current); clearTimeout(timeout); timeout = setTimeout(() => { resizeCanvas(); if (!reduce) animate(); }, 120); }; window.addEventListener('resize', handleResize); return () => { cancelAnimationFrame(animationRef.current); clearTimeout(timeout); window.removeEventListener('resize', handleResize); window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerdown', onMove); window.removeEventListener('blur', onLeave); document.removeEventListener('mouseleave', onLeave); }; }, [glitchSpeed, smooth, interactive, spotlightRadius, JSON.stringify(bases), JSON.stringify(glows)]); return (
{outerVignette ?
: null} {centerVignette ?
: null}
); } window.LetterGlitch = LetterGlitch;