'use client'; import { useRef, useEffect } from 'react'; const LetterGlitch = ({ // Dark base + full-spectrum neon accents (emerald / magenta / blue / periwinkle). glitchColors = ['#141225', '#2ee6a6', '#ff5cc8', '#3b9dff', '#d3cbc0'], glitchSpeed = 50, centerVignette = false, outerVignette = true, smooth = true, characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$&*()-_+=/[]{};:<>.,0123456789' }: { glitchColors: string[]; glitchSpeed: number; centerVignette: boolean; outerVignette: boolean; smooth: boolean; characters: string; }) => { const canvasRef = useRef(null); const animationRef = useRef(null); const letters = useRef< { char: string; color: string; targetColor: string; colorProgress: number; }[] >([]); const grid = useRef({ columns: 0, rows: 0 }); const context = useRef(null); const lastGlitchTime = useRef(Date.now()); // Parsed-color cache: the palette is a handful of hex strings reused across // thousands of cells every frame, so parse each one only once. const rgbCache = useRef(new Map()); const lettersAndSymbols = Array.from(characters); const fontSize = 16; const charWidth = 10; const charHeight = 20; const getRandomChar = () => { return lettersAndSymbols[Math.floor(Math.random() * lettersAndSymbols.length)]; }; const getRandomColor = () => { return glitchColors[Math.floor(Math.random() * glitchColors.length)]; }; const hexToRgb = (hex: string) => { const cached = rgbCache.current.get(hex); if (cached !== undefined) return cached; const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; const normalized = hex.replace(shorthandRegex, (_m, r, g, b) => { return r + r + g + g + b + b; }); const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(normalized); const rgb = result ? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16) } : null; rgbCache.current.set(hex, rgb); return rgb; }; const interpolateColor = ( start: { r: number; g: number; b: number }, end: { r: number; g: number; b: number }, factor: number ) => { const result = { 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) }; return `rgb(${result.r}, ${result.g}, ${result.b})`; }; const calculateGrid = (width: number, height: number) => { const columns = Math.ceil(width / charWidth); const rows = Math.ceil(height / charHeight); return { columns, rows }; }; const initializeLetters = (columns: number, rows: number) => { grid.current = { columns, rows }; const totalLetters = columns * rows; letters.current = Array.from({ length: totalLetters }, () => ({ char: getRandomChar(), color: getRandomColor(), targetColor: getRandomColor(), 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 { width, height } = canvasRef.current!.getBoundingClientRect(); ctx.clearRect(0, 0, width, height); ctx.font = `${fontSize}px monospace`; ctx.textBaseline = 'top'; letters.current.forEach((letter, index) => { const x = (index % grid.current.columns) * charWidth; const y = Math.floor(index / grid.current.columns) * charHeight; ctx.fillStyle = letter.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); if (!letters.current[index]) continue; letters.current[index].char = getRandomChar(); letters.current[index].targetColor = getRandomColor(); if (!smooth) { letters.current[index].color = letters.current[index].targetColor; letters.current[index].colorProgress = 1; } else { letters.current[index].colorProgress = 0; } } }; const handleSmoothTransitions = () => { let needsRedraw = false; letters.current.forEach(letter => { if (letter.colorProgress < 1) { letter.colorProgress += 0.05; if (letter.colorProgress > 1) letter.colorProgress = 1; const startRgb = hexToRgb(letter.color); const endRgb = hexToRgb(letter.targetColor); if (startRgb && endRgb) { letter.color = interpolateColor(startRgb, endRgb, letter.colorProgress); needsRedraw = true; } } }); if (needsRedraw) { drawLetters(); } }; const animate = () => { const now = Date.now(); if (now - lastGlitchTime.current >= glitchSpeed) { updateLetters(); drawLetters(); lastGlitchTime.current = now; } if (smooth) { handleSmoothTransitions(); } animationRef.current = requestAnimationFrame(animate); }; useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; context.current = canvas.getContext('2d'); resizeCanvas(); animate(); let resizeTimeout: ReturnType; const handleResize = () => { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { cancelAnimationFrame(animationRef.current as number); resizeCanvas(); animate(); }, 100); }; window.addEventListener('resize', handleResize); // Pause the render loop while the tab is hidden — the animation is purely // decorative and would otherwise keep burning CPU in the background. const handleVisibility = () => { cancelAnimationFrame(animationRef.current as number); if (!document.hidden) { lastGlitchTime.current = Date.now(); animate(); } }; document.addEventListener('visibilitychange', handleVisibility); return () => { cancelAnimationFrame(animationRef.current!); window.removeEventListener('resize', handleResize); document.removeEventListener('visibilitychange', handleVisibility); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [glitchSpeed, smooth]); return (
{outerVignette && (
)} {centerVignette && (
)}
); }; export default LetterGlitch;