Fuzzy Text

CRT television static noise effect with chromatic aberration, scanlines, and jitter. Text appears fuzzy with VHS/old TV static look, clearing on hover.

Classic Fuzzy Static

Text with continuous noise/static effect that clears on hover.

Hover to Clear

Green terminal style - hover to clear the static and reveal clean text.

404 Error Style

High intensity noise that never clears - perfect for error pages.

Installation

npm install framer-motion
pnpm install framer-motion
yarn add framer-motion
bun add framer-motion

Copy and paste the following code into your project.

'use client';

import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { motion, useInView } from 'framer-motion';

interface FuzzyTextProps {
  text: string;
  className?: string;
  fontSize?: string;
  noiseIntensity?: number;
  enableHover?: boolean;
  baseColor?: string;
  glowColor?: string;
  speed?: number;
}

export const FuzzyText = ({
  text,
  className = '',
  fontSize = '4rem',
  noiseIntensity = 1,
  enableHover = true,
  baseColor = '#fff',
  glowColor = '#fff',
  speed = 50,
}: Readonly<FuzzyTextProps>) => {
  const containerRef = useRef<HTMLSpanElement>(null);
  const [isHovered, setIsHovered] = useState(false);
  const [displayText, setDisplayText] = useState(text);
  const animationRef = useRef<NodeJS.Timeout | null>(null);

  // Noise characters for the fuzzy effect
  const noiseChars = useMemo(
    () => '!<>-_\\/[]{}—=+*^?#_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
    [],
  );

  const getRandomChar = useCallback(() => {
    return noiseChars[Math.floor(Math.random() * noiseChars.length)];
  }, [noiseChars]);

  // Apply noise to text when not hovered
  useEffect(() => {
    if (!enableHover) {
      // Always show noise when hover is disabled
      animationRef.current = setInterval(() => {
        const noisyText = text
          .split('')
          .map((char) => {
            if (char === ' ') return char;
            return Math.random() < noiseIntensity * 0.3 ? getRandomChar() : char;
          })
          .join('');
        setDisplayText(noisyText);
      }, speed);

      return () => {
        if (animationRef.current) clearInterval(animationRef.current);
      };
    }

    if (!isHovered) {
      // Apply noise effect when not hovered
      animationRef.current = setInterval(() => {
        const noisyText = text
          .split('')
          .map((char) => {
            if (char === ' ') return char;
            return Math.random() < noiseIntensity * 0.3 ? getRandomChar() : char;
          })
          .join('');
        setDisplayText(noisyText);
      }, speed);
    } else {
      // Show clear text when hovered
      setDisplayText(text);
      if (animationRef.current) {
        clearInterval(animationRef.current);
      }
    }

    return () => {
      if (animationRef.current) clearInterval(animationRef.current);
    };
  }, [isHovered, text, noiseIntensity, enableHover, getRandomChar, speed]);

  const uniqueId = useMemo(
    () => `fuzzy-${Math.random().toString(36).substr(2, 9)}`,
    [],
  );

  return (
    <span
      ref={containerRef}
      className={`relative inline-block cursor-pointer ${className}`}
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
      style={{
        fontSize,
        fontWeight: 'bold',
      }}
    >
      {/* SVG Filter for noise effect */}
      <svg width="0" height="0" className="absolute">
        <defs>
          <filter id={`${uniqueId}-noise`}>
            <feTurbulence
              type="fractalNoise"
              baseFrequency={isHovered ? '0' : '0.8'}
              numOctaves="4"
              seed={Math.random() * 100}
              stitchTiles="stitch"
              result="noise"
            />
            <feDisplacementMap
              in="SourceGraphic"
              in2="noise"
              scale={isHovered ? 0 : 2 * noiseIntensity}
              xChannelSelector="R"
              yChannelSelector="G"
            />
          </filter>
        </defs>
      </svg>

      {/* Main text with noise effect */}
      <motion.span
        className="relative inline-block"
        style={{
          color: baseColor,
          filter: `url(#${uniqueId}-noise)`,
          textShadow: `
            0 0 ${isHovered ? 20 : 5}px ${glowColor},
            0 0 ${isHovered ? 40 : 10}px ${glowColor},
            0 0 ${isHovered ? 60 : 15}px ${glowColor}
          `,
        }}
        animate={{
          x: isHovered ? 0 : [0, -1, 1, -1, 0],
          y: isHovered ? 0 : [0, 1, -1, 1, 0],
        }}
        transition={{
          duration: 0.1,
          repeat: isHovered ? 0 : Infinity,
          repeatType: 'loop',
        }}
      >
        {displayText}
      </motion.span>

      {/* Chromatic aberration layers */}
      {!isHovered && (
        <>
          {/* Red channel offset */}
          <motion.span
            className="pointer-events-none absolute left-0 top-0"
            style={{
              color: 'transparent',
              textShadow: `0 0 2px rgba(255, 0, 0, ${0.4 * noiseIntensity})`,
              mixBlendMode: 'screen',
            }}
            animate={{
              x: [-2, 2, -2],
              opacity: [0.5, 0.8, 0.5],
            }}
            transition={{
              duration: 0.15,
              repeat: Infinity,
              repeatType: 'reverse',
            }}
          >
            {displayText}
          </motion.span>

          {/* Blue channel offset */}
          <motion.span
            className="pointer-events-none absolute left-0 top-0"
            style={{
              color: 'transparent',
              textShadow: `0 0 2px rgba(0, 100, 255, ${0.4 * noiseIntensity})`,
              mixBlendMode: 'screen',
            }}
            animate={{
              x: [2, -2, 2],
              opacity: [0.5, 0.8, 0.5],
            }}
            transition={{
              duration: 0.15,
              repeat: Infinity,
              repeatType: 'reverse',
            }}
          >
            {displayText}
          </motion.span>
        </>
      )}

      {/* Scanlines */}
      <motion.span
        className="pointer-events-none absolute inset-0 z-10"
        style={{
          background: `repeating-linear-gradient(
            0deg,
            transparent,
            transparent 2px,
            rgba(0, 0, 0, ${isHovered ? 0.05 : 0.15}) 2px,
            rgba(0, 0, 0, ${isHovered ? 0.05 : 0.15}) 4px
          )`,
        }}
        animate={{
          backgroundPosition: ['0 0', '0 4px'],
        }}
        transition={{
          duration: 0.5,
          repeat: Infinity,
          ease: 'linear',
        }}
      />

      {/* Occasional glitch flicker */}
      <motion.span
        className="pointer-events-none absolute inset-0"
        style={{
          background: 'rgba(255, 255, 255, 0.03)',
        }}
        animate={{
          opacity: isHovered ? 0 : [0, 1, 0, 0, 1, 0],
        }}
        transition={{
          duration: 0.5,
          repeat: Infinity,
          times: [0, 0.1, 0.12, 0.8, 0.82, 1],
        }}
      />
    </span>
  );
};

export default FuzzyText;

Props

PropTypeDefault
text?
string
required
fontSize?
string
'4rem'
noiseIntensity?
number
1
enableHover?
boolean
true
baseColor?
string
'#fff'
glowColor?
string
'#fff'
speed?
number
50
className?
string
''

Examples

// White text on black background
<FuzzyText
  text="STATIC NOISE"
  baseColor="#ffffff"
  glowColor="#ffffff"
  enableHover={true}
/>

// Green terminal style
<FuzzyText
  text="ACCESS DENIED"
  baseColor="#00ff00"
  glowColor="#00ff00"
  noiseIntensity={1.2}
/>

// Red error/warning
<FuzzyText
  text="404 NOT FOUND"
  baseColor="#ff0040"
  glowColor="#ff0040"
  enableHover={false}
/>

// Subtle effect
<FuzzyText
  text="Subtle Glitch"
  noiseIntensity={0.5}
  speed={80}
/>

// Fast intense noise
<FuzzyText
  text="CHAOS"
  noiseIntensity={2}
  speed={20}
/>