# Glitch Text

> RGB-split text with slice jitter, in bursts or on hover.

Category: Text. Docs: https://neonkit.heyaadi.com/components/glitch-text

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/glitch-text.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/glitch-text`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { GlitchText } from "@/components/ui/glitch-text"

<GlitchText as="h1" intensity="heavy">SIGNAL LOST</GlitchText>
```

## Examples

### Bursts

trigger="always" fires a 200–400ms burst every few seconds, and pauses while off screen.

```tsx
import { GlitchText } from "@/components/ui/glitch-text";

export default function GlitchTextDemo() {
  return (
    <div className="flex flex-col items-center gap-4 text-center">
      <GlitchText as="h2" intensity="heavy" className="text-[clamp(2.75rem,10vw,5.5rem)] font-semibold leading-none tracking-[-0.03em] text-white/90">
        SIGNAL LOST
      </GlitchText>
      <p className="text-sm text-white/55">
        Relay 07 dropped out. Retrying in <span className="font-mono tabular-nums text-white/80">00:30</span>
      </p>
    </div>
  );
}
```

### Intensity

```tsx
"use client";

import * as React from "react";
import { GlitchText } from "@/components/ui/glitch-text";

const levels = ["subtle", "medium", "heavy"] as const;

export default function GlitchTextIntensity() {
  const [active, setActive] = React.useState(false);

  return (
    <div className="flex w-full flex-col items-center gap-10">
      <div className="grid w-full max-w-xl grid-cols-1 gap-8 sm:grid-cols-3">
        {levels.map((level) => (
          <div key={level} className="flex flex-col items-center gap-3">
            <GlitchText trigger="manual" active={active} intensity={level} className="text-4xl font-semibold tracking-tight text-white/90">
              Static
            </GlitchText>
            <span className="text-[13px] capitalize text-white/55">{level}</span>
          </div>
        ))}
      </div>
      <button
        type="button"
        aria-pressed={active}
        onClick={() => setActive((value) => !value)}
        className="inline-flex h-9 cursor-pointer items-center rounded-full border border-white/10 bg-white/[0.03] px-4 text-[13px] font-medium text-white/70 outline-none transition-[color,border-color,transform] duration-150 hover:border-white/20 hover:text-white/90 focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon-accent)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a] active:scale-[0.97] aria-pressed:border-[rgb(var(--neon-accent)/0.5)] aria-pressed:text-white/90"
      >
        {active ? "Stop interference" : "Cause interference"}
      </button>
    </div>
  );
}
```

### Inside a link

trigger="hover" also answers to the link or button around it, so keyboard focus sets it off too.

```tsx
import { GlitchText } from "@/components/ui/glitch-text";

const links = ["Work", "Lab", "Archive", "Contact"];

export default function GlitchTextNav() {
  return (
    <nav aria-label="Studio" className="flex flex-wrap items-center justify-center gap-1">
      {links.map((link) => (
        <a
          key={link}
          href={`#${link.toLowerCase()}`}
          className="rounded-lg px-2.5 py-2 text-lg font-medium sm:px-4 tracking-tight text-white/70 outline-none transition-colors hover:text-white focus-visible:text-white focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon-accent)/0.6)]"
        >
          <GlitchText trigger="hover" intensity="subtle">
            {link}
          </GlitchText>
        </a>
      ))}
    </nav>
  );
}
```

### In view

One burst each time it scrolls into view.

```tsx
import Link from "next/link";
import { GlitchText } from "@/components/ui/glitch-text";

export default function GlitchTextNotFound() {
  return (
    <div className="flex max-w-sm flex-col items-center gap-5 text-center">
      <GlitchText as="h2" trigger="inView" intensity="heavy" color="amber" className="font-mono text-7xl font-medium tabular-nums tracking-tight text-white/90">
        404
      </GlitchText>
      <p className="text-sm leading-6 text-white/55">
        This page dropped out of the signal. It may have moved, or it never existed.
      </p>
      <Link
        href="/"
        className="rounded-md text-sm font-medium text-[rgb(251_191_36)] underline decoration-[rgb(251_191_36/0.35)] underline-offset-4 outline-none transition-colors hover:decoration-[rgb(251_191_36)] focus-visible:ring-2 focus-visible:ring-[rgb(251_191_36/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]"
      >
        Back to the homepage
      </Link>
    </div>
  );
}
```

## API reference

### GlitchText

Also accepts native attributes for the element it renders.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `as` | `"span" \| "p" \| "div" \| "h1" \| "h2" \| "h3" \| "h4"` | `"span"` | Element to render. |
| `trigger` | `"hover" \| "always" \| "inView" \| "manual"` | `"always"` | Glitch while hovered (or while a surrounding link or button is hovered or keyboard-focused), in periodic bursts, once per scroll into view, or while active. |
| `active` | `boolean` | `false` | With trigger="manual", glitches while true. |
| `intensity` | `"subtle" \| "medium" \| "heavy"` | `"medium"` | How far the slices jump. Medium adds a skew; heavy also tears a band out of the text. |
| `color` | `NeonColor` | `inherited` | First split layer. Falls back to --neon-accent, then cyan. |
| `colorAlt` | `NeonColor` | — | Second split layer. Defaults to the neon hue turned 150°, so it always contrasts. |

## Accessibility

- The text is rendered once for screen readers; the two colored copies are aria-hidden.
- Bursts are skipped entirely under reduced motion, leaving plain text.
- The base text keeps its inherited color, so it reads as normal copy between bursts.

## Source

`components/ui/glitch-text.tsx`

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, toNeonRgb, type NeonColor } from "@/lib/neon";

// Two colored copies sit behind the text. During a burst they jump between
// horizontal slices (clip-path) and offsets in steps, so the split reads as a
// signal fault rather than a smooth wobble. The alternate color defaults to
// the neon hue turned 150 degrees, so it always contrasts with the accent.
const css = `
.nk-glitch-text { --nk-glitch-alt: rgb(240 70 235); }
@supports (color: hsl(from red h s l)) {
  .nk-glitch-text { --nk-glitch-alt: hsl(from rgb(var(--neon)) calc(h + 150) s l); }
}
.nk-glitch-text-a { color: rgb(var(--neon)); text-shadow: 0 0 0.12em rgb(var(--neon) / calc(0.7 * var(--neon-glow, 1))); }
.nk-glitch-text-b { color: var(--nk-glitch-alt); text-shadow: 0 0 0.12em color-mix(in srgb, var(--nk-glitch-alt) calc(clamp(0, var(--neon-glow, 1), 1) * 100%), transparent); }
.nk-glitch-text-a, .nk-glitch-text-b { opacity: 0; }
.nk-glitch-text:is([data-active], [data-burst]) :is(.nk-glitch-text-a, .nk-glitch-text-b),
.nk-glitch-text[data-trigger="hover"]:hover :is(.nk-glitch-text-a, .nk-glitch-text-b),
:is(a, button):is(:hover, :focus-visible) .nk-glitch-text[data-trigger="hover"] :is(.nk-glitch-text-a, .nk-glitch-text-b) { opacity: 0.9; }
.nk-glitch-text:is([data-active], [data-burst]) .nk-glitch-text-a,
.nk-glitch-text[data-trigger="hover"]:hover .nk-glitch-text-a,
:is(a, button):is(:hover, :focus-visible) .nk-glitch-text[data-trigger="hover"] .nk-glitch-text-a { animation: nk-glitch-text-a var(--nk-glitch-speed) steps(1, end) infinite; }
.nk-glitch-text:is([data-active], [data-burst]) .nk-glitch-text-b,
.nk-glitch-text[data-trigger="hover"]:hover .nk-glitch-text-b,
:is(a, button):is(:hover, :focus-visible) .nk-glitch-text[data-trigger="hover"] .nk-glitch-text-b { animation: nk-glitch-text-b calc(var(--nk-glitch-speed) * 1.17) steps(1, end) infinite; }
.nk-glitch-text:is([data-active], [data-burst]) .nk-glitch-text-base,
.nk-glitch-text[data-trigger="hover"]:hover .nk-glitch-text-base,
:is(a, button):is(:hover, :focus-visible) .nk-glitch-text[data-trigger="hover"] .nk-glitch-text-base { animation: nk-glitch-text-base calc(var(--nk-glitch-speed) * 1.31) steps(1, end) infinite; }
@keyframes nk-glitch-text-a {
  0% { clip-path: inset(6% 0 58% 0); transform: translate(calc(var(--nk-glitch-x) * -1), 0); }
  17% { clip-path: inset(48% 0 20% 0); transform: translate(calc(var(--nk-glitch-x) * 1.5), 0); }
  33% { clip-path: inset(0 0 0 0); transform: translate(calc(var(--nk-glitch-x) * -0.5), 0); }
  50% { clip-path: inset(70% 0 4% 0); transform: translate(var(--nk-glitch-x), 0); }
  67% { clip-path: inset(18% 0 52% 0); transform: translate(calc(var(--nk-glitch-x) * -1.8), 0); }
  83% { clip-path: inset(38% 0 30% 0); transform: translate(calc(var(--nk-glitch-x) * 0.6), 0); }
  100% { clip-path: inset(6% 0 58% 0); transform: translate(calc(var(--nk-glitch-x) * -1), 0); }
}
@keyframes nk-glitch-text-b {
  0% { clip-path: inset(62% 0 8% 0); transform: translate(var(--nk-glitch-x), 0); }
  20% { clip-path: inset(0 0 0 0); transform: translate(calc(var(--nk-glitch-x) * 0.6), 0); }
  40% { clip-path: inset(12% 0 64% 0); transform: translate(calc(var(--nk-glitch-x) * -1.4), 0); }
  60% { clip-path: inset(44% 0 36% 0); transform: translate(calc(var(--nk-glitch-x) * 1.7), 0); }
  80% { clip-path: inset(80% 0 2% 0); transform: translate(calc(var(--nk-glitch-x) * -0.8), 0); }
  100% { clip-path: inset(62% 0 8% 0); transform: translate(var(--nk-glitch-x), 0); }
}
@keyframes nk-glitch-text-base {
  0%, 100% { transform: none; clip-path: none; }
  22% { transform: translate(calc(var(--nk-glitch-x) * 0.35), 0) skewX(var(--nk-glitch-skew)); clip-path: var(--nk-glitch-cut, none); }
  44% { transform: none; clip-path: none; }
  66% { transform: translate(calc(var(--nk-glitch-x) * -0.25), 0) skewX(calc(var(--nk-glitch-skew) * -0.6)); }
  80% { transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .nk-glitch-text .nk-glitch-text-a, .nk-glitch-text .nk-glitch-text-b, .nk-glitch-text .nk-glitch-text-base { animation: none !important; opacity: 0 !important; }
  .nk-glitch-text .nk-glitch-text-base { opacity: 1 !important; }
}
`;

const intensities = {
  subtle: { x: "0.03em", skew: "0deg", speed: "360ms", burst: [200, 260], cut: "none" },
  medium: { x: "0.06em", skew: "3deg", speed: "300ms", burst: [240, 340], cut: "none" },
  heavy: { x: "0.11em", skew: "9deg", speed: "240ms", burst: [300, 400], cut: "polygon(0 0, 100% 0, 100% 46%, 0 46%, 0 58%, 100% 58%, 100% 100%, 0 100%)" },
} as const;

type Trigger = "hover" | "always" | "inView" | "manual";

export interface GlitchTextProps
  extends Omit<React.HTMLAttributes<HTMLElement>, "color"> {
  /** Element to render. */
  as?: "span" | "p" | "div" | "h1" | "h2" | "h3" | "h4";
  /** When it glitches: on hover (its own, or a link or button around it, keyboard focus included), in periodic bursts, once each time it scrolls into view, or while `active`. */
  trigger?: Trigger;
  /** With `trigger="manual"`: glitches while true. */
  active?: boolean;
  /** How far the slices jump and how hard the text skews. */
  intensity?: keyof typeof intensities;
  /** The first split layer. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  /** The second split layer. Defaults to the neon hue turned 150°. */
  colorAlt?: NeonColor;
}

const GlitchText = React.forwardRef<HTMLElement, GlitchTextProps>(
  (
    {
      className,
      style,
      color,
      colorAlt,
      as: Tag = "span",
      trigger = "always",
      active = false,
      intensity = "medium",
      children,
      ...props
    },
    ref,
  ) => {
    const rootRef = React.useRef<HTMLElement>(null);
    React.useImperativeHandle(ref, () => rootRef.current as HTMLElement);
    const preset = intensities[intensity];

    React.useEffect(() => {
      const node = rootRef.current;
      if (!node || (trigger !== "always" && trigger !== "inView")) return;
      if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;

      const [min, max] = preset.burst;
      let burstTimer: ReturnType<typeof setTimeout> | undefined;
      let restTimer: ReturnType<typeof setTimeout> | undefined;
      let visible = typeof IntersectionObserver === "undefined";

      const burst = () => {
        node.setAttribute("data-burst", "");
        clearTimeout(burstTimer);
        burstTimer = setTimeout(() => node.removeAttribute("data-burst"), min + Math.random() * (max - min));
      };
      const schedule = (delay: number) => {
        clearTimeout(restTimer);
        restTimer = setTimeout(() => {
          if (visible && !document.hidden) burst();
          schedule(2200 + Math.random() * 3200);
        }, delay);
      };

      const io =
        typeof IntersectionObserver === "undefined"
          ? null
          : new IntersectionObserver(
              ([entry]) => {
                const wasVisible = visible;
                visible = entry.isIntersecting;
                if (trigger === "inView" && visible && !wasVisible) burst();
              },
              { threshold: trigger === "inView" ? 0.6 : 0 },
            );
      io?.observe(node);
      if (trigger === "always") schedule(900 + Math.random() * 1200);
      else if (!io) burst();

      return () => {
        io?.disconnect();
        clearTimeout(burstTimer);
        clearTimeout(restTimer);
        node.removeAttribute("data-burst");
      };
    }, [trigger, preset]);

    const vars = {
      "--nk-glitch-x": preset.x,
      "--nk-glitch-skew": preset.skew,
      "--nk-glitch-speed": preset.speed,
      "--nk-glitch-cut": preset.cut,
      ...(colorAlt ? { "--nk-glitch-alt": `rgb(${toNeonRgb(colorAlt)})` } : null),
      ...style,
    } as React.CSSProperties;

    const layer = "nk-glitch-text-layer pointer-events-none absolute inset-0 -z-10 select-none";

    return (
      <Tag
        ref={rootRef as React.Ref<HTMLHeadingElement>}
        data-trigger={trigger}
        data-active={(trigger === "manual" && active) || undefined}
        style={neonVars(color, vars)}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "nk-glitch-text relative isolate inline-block",
          className,
        )}
        {...props}
      >
        <style>{css}</style>
        <span className="nk-glitch-text-base inline-block">{children}</span>
        <span aria-hidden className={cn(layer, "nk-glitch-text-a")}>
          {children}
        </span>
        <span aria-hidden className={cn(layer, "nk-glitch-text-b")}>
          {children}
        </span>
      </Tag>
    );
  },
);
GlitchText.displayName = "GlitchText";

export { GlitchText };
```
