Text Effects

Glitch Text

A restrained RGB-split glitch with clip-path scanline slices on hover or a slow loop.

Install

npx shadcn@latest add @paragon/glitch-text

glitch-text.tsx

"use client";

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

export interface GlitchTextProps
  extends Omit<React.ComponentProps<"span">, "children"> {
  /** The text to render. */
  children: React.ReactNode;
  /** Channel-split displacement in px at peak. */
  intensity?: number;
  /** Cyan channel color (offset one way). */
  colorA?: string;
  /** Magenta/red channel color (offset the other way). */
  colorB?: string;
  /**
   * `hover` — glitch while hovered (default).
   * `loop` — a brief glitch burst on a slow interval.
   */
  trigger?: "hover" | "loop";
  /** Render plain text with no glitch. */
  static?: boolean;
}

/**
 * GlitchText — a restrained RGB-split glitch. Two colored ghost copies of the
 * text (cyan and magenta) sit exactly behind the real text; on trigger they
 * jitter apart on offset channels while horizontal `clip-path` slices flicker
 * across them, so the word shears into scanline slivers for a beat and snaps
 * back. Tuned for a tasteful enterprise flicker, not a chaotic mess.
 *
 * The channel ghosts are `aria-hidden`; the real, legible text stays on top and
 * in the DOM. All motion is `transform` and `clip-path` on the ghosts.
 * `hover` glitches while hovered; `loop` bursts on a slow interval and pauses
 * offscreen. Reduced motion (or `static`) renders the plain word.
 */
export function GlitchText({
  children,
  intensity = 3,
  colorA = "#22d3ee",
  colorB = "#f43f5e",
  trigger = "hover",
  static: isStatic = false,
  className,
  style,
  ...props
}: GlitchTextProps) {
  const id = React.useId().replace(/[:]/g, "");
  const reducedMotion = useReducedMotion() ?? false;
  const hostRef = React.useRef<HTMLSpanElement>(null);
  const [hovered, setHovered] = React.useState(false);
  const [bursting, setBursting] = React.useState(false);
  const [visible, setVisible] = React.useState(true);

  const animated = !isStatic && !reducedMotion;
  const isLoop = trigger === "loop";

  // Loop: pulse a short glitch burst on a slow interval, paused offscreen.
  React.useEffect(() => {
    const el = hostRef.current;
    if (!el || !animated || !isLoop) return;
    const io = new IntersectionObserver(
      ([e]) => setVisible(e.isIntersecting),
      { threshold: 0 },
    );
    io.observe(el);
    return () => io.disconnect();
  }, [animated, isLoop]);

  React.useEffect(() => {
    if (!animated || !isLoop || !visible) return;
    let off: ReturnType<typeof setTimeout>;
    const iv = setInterval(() => {
      setBursting(true);
      off = setTimeout(() => setBursting(false), 520);
    }, 3200);
    return () => {
      clearInterval(iv);
      clearTimeout(off);
    };
  }, [animated, isLoop, visible]);

  const on = isLoop ? bursting : hovered;

  const keyframes = `@keyframes glitch-a-${id} {
    0%,100% { transform: translate(0,0); clip-path: inset(0 0 0 0); }
    20% { transform: translate(${-intensity}px, ${intensity * 0.4}px); clip-path: inset(12% 0 58% 0); }
    40% { transform: translate(${intensity * 0.6}px, ${-intensity * 0.3}px); clip-path: inset(64% 0 8% 0); }
    60% { transform: translate(${-intensity * 0.8}px, 0); clip-path: inset(38% 0 40% 0); }
    80% { transform: translate(${intensity * 0.5}px, ${intensity * 0.2}px); clip-path: inset(80% 0 4% 0); }
  }
  @keyframes glitch-b-${id} {
    0%,100% { transform: translate(0,0); clip-path: inset(0 0 0 0); }
    20% { transform: translate(${intensity}px, ${-intensity * 0.4}px); clip-path: inset(70% 0 12% 0); }
    40% { transform: translate(${-intensity * 0.6}px, ${intensity * 0.3}px); clip-path: inset(6% 0 72% 0); }
    60% { transform: translate(${intensity * 0.8}px, 0); clip-path: inset(46% 0 30% 0); }
    80% { transform: translate(${-intensity * 0.5}px, ${-intensity * 0.2}px); clip-path: inset(20% 0 66% 0); }
  }
  @media (prefers-reduced-motion: reduce) {
    .glitch-${id} [data-ghost] { animation: none !important; opacity: 0 !important; }
  }`;

  if (isStatic || reducedMotion) {
    return (
      <span
        data-slot="glitch-text"
        className={cn("inline-block", className)}
        style={style}
        {...props}
      >
        {children}
      </span>
    );
  }

  const ghostBase: React.CSSProperties = {
    position: "absolute",
    inset: 0,
    pointerEvents: "none",
    animationDuration: "520ms",
    animationTimingFunction: "steps(2, end)",
    animationIterationCount: isLoop ? 1 : "infinite",
    animationPlayState: on ? "running" : "paused",
    opacity: on ? 0.75 : 0,
    transition: "opacity 120ms var(--ease-out)",
    willChange: "transform, clip-path",
  };

  return (
    <>
      <style href={`paragon-glitch-text-${id}`} precedence="paragon">
        {keyframes}
      </style>
      <span
        ref={hostRef}
        data-slot="glitch-text"
        className={cn(`glitch-${id} relative inline-block`, className)}
        style={style}
        onPointerEnter={isLoop ? undefined : () => setHovered(true)}
        onPointerLeave={isLoop ? undefined : () => setHovered(false)}
        {...props}
      >
        <span
          aria-hidden
          data-ghost
          style={{
            ...ghostBase,
            color: colorA,
            animationName: on ? `glitch-a-${id}` : undefined,
          }}
        >
          {children}
        </span>
        <span
          aria-hidden
          data-ghost
          style={{
            ...ghostBase,
            color: colorB,
            animationName: on ? `glitch-b-${id}` : undefined,
          }}
        >
          {children}
        </span>
        <span className="relative">{children}</span>
      </span>
    </>
  );
}