'use client';
import { forwardRef, HTMLAttributes, useEffect, useState, useCallback, useRef } from 'react';
import styles from './char-glitch.module.css';
export interface CharGlitchProps extends HTMLAttributes {
/** Text to display */
children: string;
/** Glitch intensity */
intensity?: 'subtle' | 'medium' | 'intense';
/** Visual variant */
variant?: 'blood' | 'cyber' | 'matrix' | 'corrupt';
/** Trigger mode */
mode?: 'random' | 'hover' | 'continuous' | 'wave';
/** Interval between random glitches in ms */
interval?: number;
/** Characters to use for scramble effect */
glitchChars?: string;
/** Enable scramble reveal effect */
scramble?: boolean;
}
const GLITCH_CHARS = '!@#$%^&*()_+-=[]{}|;:,.<>?/~`0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
export const CharGlitch = forwardRef(
(
{
children,
intensity = 'medium',
variant = 'blood',
mode = 'random',
interval = 100,
glitchChars = GLITCH_CHARS,
scramble = false,
className,
...props
},
ref
) => {
const [glitchingIndices, setGlitchingIndices] = useState>(new Set());
const [scrambledChars, setScrambledChars] = useState([]);
const [revealed, setRevealed] = useState([]);
const intervalRef = useRef();
const triggerGlitch = useCallback((index: number) => {
setGlitchingIndices(prev => new Set(prev).add(index));
setTimeout(() => {
setGlitchingIndices(prev => {
const next = new Set(prev);
next.delete(index);
return next;
});
}, intensity === 'subtle' ? 300 : intensity === 'intense' ? 80 : 150);
}, [intensity]);
useEffect(() => {
if (scramble) {
setScrambledChars(children.split('').map(() =>
glitchChars[Math.floor(Math.random() * glitchChars.length)]
));
setRevealed(new Array(children.length).fill(false));
let index = 0;
const revealInterval = setInterval(() => {
if (index < children.length) {
setRevealed(prev => {
const next = [...prev];
next[index] = true;
return next;
});
index++;
} else {
clearInterval(revealInterval);
}
}, 50);
return () => clearInterval(revealInterval);
}
}, [children, scramble, glitchChars]);
useEffect(() => {
if (mode === 'random' && !scramble) {
intervalRef.current = setInterval(() => {
const randomIndex = Math.floor(Math.random() * children.length);
triggerGlitch(randomIndex);
}, interval);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}
if (mode === 'continuous' && !scramble) {
children.split('').forEach((_, i) => {
setTimeout(() => triggerGlitch(i), i * 50);
});
intervalRef.current = setInterval(() => {
children.split('').forEach((_, i) => {
setTimeout(() => triggerGlitch(i), i * 50);
});
}, interval * children.length);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}
if (mode === 'wave' && !scramble) {
let waveIndex = 0;
intervalRef.current = setInterval(() => {
triggerGlitch(waveIndex % children.length);
waveIndex++;
}, interval);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}
}, [mode, interval, children, triggerGlitch, scramble]);
const containerClasses = [
styles.container,
styles[intensity],
styles[variant],
mode === 'hover' && styles.hover,
scramble && styles.scramble,
className
].filter(Boolean).join(' ');
return (
{children.split('').map((char, i) => {
const isGlitching = glitchingIndices.has(i);
const displayChar = scramble && !revealed[i] ? scrambledChars[i] : char;
return (
{displayChar}
);
})}
);
}
);
CharGlitch.displayName = 'CharGlitch';
export default CharGlitch;