Fazekit

Code

"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface GlitchTextProps {
  text: string;
  as?: "h1" | "h2" | "h3" | "p" | "span";
  /** When bursts fire. Hover/focus always triggers too. */
  trigger?: "hover" | "inView" | "loop";
  /** ms between bursts for trigger="loop". */
  interval?: number;
  /** Burst length in ms. */
  duration?: number;
  /** 0–1, how far slices jump. */
  intensity?: number;
  /** Swap a few glyphs during bursts. */
  scramble?: boolean;
  /** Faint RGB split at rest. */
  restSplit?: boolean;
  /** Scanline overlay. */
  scanlines?: boolean;
  colors?: [string, string];
  className?: string;
}

const GLYPHS = "!<>-_\\/[]{}=+*^?#01";

export function GlitchText({
  text,
  as = "h2",
  trigger = "inView",
  interval = 3200,
  duration = 650,
  intensity = 0.6,
  scramble = true,
  restSplit = true,
  scanlines = true,
  colors = ["#ff2d6f", "#00d9ff"],
  className,
}: GlitchTextProps) {
  const reduce = useReducedMotion() ?? false;
  const rootRef = React.useRef<HTMLElement>(null);
  const aRef = React.useRef<HTMLSpanElement>(null);
  const bRef = React.useRef<HTMLSpanElement>(null);
  const sliceRef = React.useRef<HTMLSpanElement>(null);
  const baseRef = React.useRef<HTMLSpanElement>(null);
  const barRef = React.useRef<HTMLSpanElement>(null);
  const running = React.useRef(false);
  const timers = React.useRef<number[]>([]);
  const [inView, setInView] = React.useState(false);

  const reset = React.useCallback(() => {
    const split = restSplit ? 1 : 0;
    if (aRef.current) {
      aRef.current.style.clipPath = "none";
      aRef.current.style.transform = `translate(${-split}px,0)`;
      aRef.current.textContent = text;
    }
    if (bRef.current) {
      bRef.current.style.clipPath = "none";
      bRef.current.style.transform = `translate(${split}px,0)`;
      bRef.current.textContent = text;
    }
    if (sliceRef.current) {
      sliceRef.current.style.opacity = "0";
      sliceRef.current.textContent = text;
    }
    if (baseRef.current) baseRef.current.style.transform = "none";
    if (barRef.current) barRef.current.style.opacity = "0";
    running.current = false;
  }, [restSplit, text]);

  React.useEffect(() => {
    reset();
  }, [reset]);

  const burst = React.useCallback(() => {
    if (reduce || running.current) return;
    running.current = true;
    const amp = 4 + intensity * 14;
    const start = performance.now();
    const mess = (s: string) =>
      s
        .split("")
        .map((ch) => (ch !== " " && Math.random() < 0.12 ? GLYPHS[Math.floor(Math.random() * GLYPHS.length)] : ch))
        .join("");
    const step = () => {
      const t = performance.now() - start;
      if (t > duration) return reset();
      const band = () => {
        const top = Math.random() * 90;
        const h = 4 + Math.random() * 28;
        return `inset(${top}% 0 ${Math.max(0, 100 - top - h)}% 0)`;
      };
      const rnd = () => (Math.random() - 0.5) * 2 * amp;
      if (aRef.current) {
        aRef.current.style.clipPath = band();
        aRef.current.style.transform = `translate(${rnd()}px,${rnd() * 0.15}px)`;
        if (scramble) aRef.current.textContent = mess(text);
      }
      if (bRef.current) {
        bRef.current.style.clipPath = band();
        bRef.current.style.transform = `translate(${rnd()}px,${rnd() * 0.15}px)`;
      }
      if (sliceRef.current) {
        sliceRef.current.style.opacity = Math.random() < 0.6 ? "1" : "0";
        sliceRef.current.style.clipPath = band();
        sliceRef.current.style.transform = `translate(${rnd() * 1.4}px,0)`;
        if (scramble) sliceRef.current.textContent = mess(text);
      }
      if (baseRef.current) baseRef.current.style.transform = Math.random() < 0.25 ? `translate(${rnd() * 0.3}px,0) skewX(${rnd() * 0.4}deg)` : "none";
      if (barRef.current) {
        barRef.current.style.opacity = "1";
        barRef.current.style.top = `${(t / duration) * 100}%`;
      }
      timers.current = [window.setTimeout(step, 40 + Math.random() * 40)];
    };
    step();
  }, [reduce, intensity, duration, scramble, text, reset]);

  React.useEffect(() => {
    const el = rootRef.current;
    if (!el) return;
    const io = new IntersectionObserver(([e]) => setInView(e.isIntersecting), { threshold: 0.4 });
    io.observe(el);
    return () => io.disconnect();
  }, []);

  React.useEffect(() => {
    if (!inView) return;
    if (trigger === "inView") {
      const id = window.setTimeout(burst, 250);
      return () => clearTimeout(id);
    }
    if (trigger === "loop") {
      const id = window.setInterval(() => !document.hidden && burst(), interval);
      const first = window.setTimeout(burst, 300);
      return () => {
        clearInterval(id);
        clearTimeout(first);
      };
    }
  }, [inView, trigger, interval, burst]);

  React.useEffect(
    () => () => {
      timers.current.forEach(clearTimeout);
    },
    [],
  );

  const Tag = as;
  const layer = "pointer-events-none absolute inset-0 whitespace-pre-wrap will-change-transform select-none";
  return (
    <Tag
      ref={rootRef as React.Ref<HTMLHeadingElement>}
      onMouseEnter={burst}
      onFocus={burst}
      className={cn("relative inline-block isolate", className)}
      data-text={text}
    >
      <span className="relative block">
        <span ref={baseRef} className="relative block">
          {text}
        </span>
        <span ref={aRef} aria-hidden className={cn(layer, "mix-blend-multiply dark:mix-blend-screen")} style={{ color: colors[0], opacity: 0.85 }} />
        <span ref={bRef} aria-hidden className={cn(layer, "mix-blend-multiply dark:mix-blend-screen")} style={{ color: colors[1], opacity: 0.85 }} />
        <span ref={sliceRef} aria-hidden className={cn(layer, "bg-background text-foreground")} style={{ opacity: 0 }} />
        {scanlines && (
          // Lines in the page background colour: invisible on the background, visible through the glyphs.
          <span
            aria-hidden
            className="pointer-events-none absolute inset-0 bg-[repeating-linear-gradient(0deg,transparent_0_3px,color-mix(in_oklch,var(--color-background)_18%,transparent)_3px_4px)]"
          />
        )}
        <span
          ref={barRef}
          aria-hidden
          className="pointer-events-none absolute -inset-x-2 h-[3px] bg-gradient-to-r from-transparent via-cyan-400/70 to-transparent opacity-0 blur-[1px]"
          style={{ top: 0 }}
        />
      </span>
    </Tag>
  );
}

More in Text Effects

View all →