Glitch Text

RGB channel-split distortion with scanlines and noise on hover — cyberpunk aesthetic

textglitchdistortionhovercyberpunkanimation
Preview

Source Code

"use client";

import { useState, useRef, useEffect, useCallback } from "react";

interface GlitchTextProps {
  text: string;
  className?: string;
  glitchOnHover?: boolean;
  autoGlitch?: boolean;
  autoGlitchInterval?: number;
  intensity?: number;
}

export default function GlitchText({
  text,
  className = "",
  glitchOnHover = true,
  autoGlitch = false,
  autoGlitchInterval = 3000,
  intensity = 1,
}: GlitchTextProps) {
  const [isGlitching, setIsGlitching] = useState(false);
  const containerRef = useRef<HTMLSpanElement>(null);
  const animFrameRef = useRef<number>(0);
  const canvasRef = useRef<HTMLCanvasElement | null>(null);

  const triggerGlitch = useCallback(() => {
    setIsGlitching(true);
    setTimeout(() => setIsGlitching(false), 200 + intensity * 100);
  }, [intensity]);

  // Auto-glitch mode
  useEffect(() => {
    if (!autoGlitch) return;
    const interval = setInterval(triggerGlitch, autoGlitchInterval);
    return () => clearInterval(interval);
  }, [autoGlitch, autoGlitchInterval, triggerGlitch]);

  // Canvas-based scanline + noise overlay
  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    let canvas = canvasRef.current;
    if (!canvas) {
      canvas = document.createElement("canvas");
      canvas.style.cssText =
        "position:absolute;inset:0;pointer-events:none;mix-blend-mode:screen;z-index:2;";
      container.appendChild(canvas);
      canvasRef.current = canvas;
    }

    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const animate = () => {
      const w = container.offsetWidth;
      const h = container.offsetHeight;
      canvas!.width = w;
      canvas!.height = h;

      ctx.clearRect(0, 0, w, h);

      if (isGlitching) {
        // Random RGB shift blocks
        const blockCount = Math.floor(3 + intensity * 4);
        for (let i = 0; i < blockCount; i++) {
          const bx = Math.random() * w;
          const by = Math.random() * h;
          const bw = 20 + Math.random() * w * 0.4;
          const bh = 2 + Math.random() * 6;

          ctx.fillStyle = `rgba(${Math.random() > 0.5 ? "255,0,0" : "0,255,255"},${0.08 + Math.random() * 0.12})`;
          ctx.fillRect(bx + (Math.random() - 0.5) * 10 * intensity, by, bw, bh);
        }

        // Scanlines
        ctx.fillStyle = "rgba(0,0,0,0.03)";
        for (let y = 0; y < h; y += 2) {
          ctx.fillRect(0, y, w, 1);
        }

        // Noise pixels
        const noiseCount = Math.floor(15 * intensity);
        for (let i = 0; i < noiseCount; i++) {
          const nx = Math.random() * w;
          const ny = Math.random() * h;
          ctx.fillStyle = `rgba(255,255,255,${0.1 + Math.random() * 0.15})`;
          ctx.fillRect(nx, ny, Math.random() * 3 + 1, 1);
        }
      }

      animFrameRef.current = requestAnimationFrame(animate);
    };

    animate();
    return () => cancelAnimationFrame(animFrameRef.current);
  }, [isGlitching, intensity]);

  // Clean up canvas on unmount
  useEffect(() => {
    return () => {
      if (canvasRef.current && containerRef.current) {
        containerRef.current.removeChild(canvasRef.current);
        canvasRef.current = null;
      }
    };
  }, []);

  const glitchOffset = isGlitching ? intensity * 3 : 0;

  return (
    <span
      ref={containerRef}
      className={`relative inline-block ${className}`}
      onMouseEnter={glitchOnHover ? triggerGlitch : undefined}
      style={{ position: "relative" }}
    >
      {/* Red channel offset */}
      <span
        aria-hidden="true"
        className="absolute inset-0 z-0"
        style={{
          color: "rgba(255, 0, 0, 0.7)",
          transform: isGlitching
            ? `translate(${glitchOffset}px, ${-glitchOffset * 0.5}px)`
            : "none",
          clipPath: isGlitching
            ? `polygon(0 ${15 + Math.random() * 20}%, 100% ${15 + Math.random() * 20}%, 100% ${50 + Math.random() * 20}%, 0 ${50 + Math.random() * 20}%)`
            : "none",
          transition: "none",
        }}
      >
        {text}
      </span>

      {/* Cyan channel offset */}
      <span
        aria-hidden="true"
        className="absolute inset-0 z-0"
        style={{
          color: "rgba(0, 255, 255, 0.7)",
          transform: isGlitching
            ? `translate(${-glitchOffset}px, ${glitchOffset * 0.5}px)`
            : "none",
          clipPath: isGlitching
            ? `polygon(0 ${40 + Math.random() * 20}%, 100% ${40 + Math.random() * 20}%, 100% ${70 + Math.random() * 20}%, 0 ${70 + Math.random() * 20}%)`
            : "none",
          transition: "none",
        }}
      >
        {text}
      </span>

      {/* Main text */}
      <span
        className="relative z-10"
        style={{
          transform: isGlitching
            ? `translate(${(Math.random() - 0.5) * 2 * intensity}px, 0)`
            : "none",
        }}
      >
        {text}
      </span>
    </span>
  );
}