# Glitch text

> A controlled digital glitch on cue.

- Type: Component (distort)
- Page: https://textlab.dev/components/glitch-text
- Markdown: https://textlab.dev/components/glitch-text.md
- Registry item: https://textlab.dev/r/glitch-text.json
- Source file: `registry/textlab/glitch-text/glitch-text.tsx`
- Dependencies: none
- Variants: slice, shift, scan
- Keywords: glitch, rgb split, distortion, cyberpunk, error, vhs

## When to use

- Gaming, music and cyber themes.
- Error and 404 pages with personality.

## When not to use

- Serious or high-trust contexts like payments.
- More than one glitch on screen.

## Installation

### CLI

```bash
npx shadcn@latest add @textlab/glitch-text
pnpm dlx shadcn@latest add @textlab/glitch-text
```

The `@textlab` name needs `"registries": { "@textlab": "https://textlab.dev/r/{name}.json" }` in `components.json`. Without it, use the full URL:

```bash
npx shadcn@latest add https://textlab.dev/r/glitch-text.json
```

Import the tokens once in your root layout: `import "@/components/textlab/tokens.css";`

### Manual

1. Install the dependencies: `npm install motion@12`
2. Copy `text-fx` (https://textlab.dev/r/text-fx.json) and this component into `components/textlab/`.

## Usage

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

export function Example() {
  return <GlitchText text="Signal lost" trigger="hover" />;
}
```

## API reference

### GlitchText

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"slice" \| "shift" \| "scan"` | `"slice"` | Tear bands sideways, jolt the word, or roll a bright scan band through it. |
| `trigger` | `"interval" \| "hover" \| "mount"` | `"interval"` | When it glitches. |
| `every` | `number` | `3.5` | Seconds between bursts for interval. |
| `as` | `ElementType` | `"span"` | Rendered element. |
| `reducedMotion` | `"user" \| "always" \| "never"` | `"user"` | user follows the OS setting; always shows the still version. |
| `className` | `string` | – | Merged onto the root. |

## Accessibility

- Bursts are 500 ms with at least 3 s between them by default, well under flash thresholds.
- The readable text only jolts by a few hundredths of an em, and only during a burst.

## Motion

- Hard steps, not tweens, so it looks digital.
- Long rests between bursts keep it intentional.

## Reduced motion

No glitching; plain text.

## Performance

- CSS keyframes on clip-path and transform; idle between bursts.

## Notes for AI

- Use only the props documented above. Do not invent props.
- Keep the screen-reader text and the reduced-motion branch when editing the source.
- Pick a variant from: slice, shift, scan.

## Related

- [Chromatic text](https://textlab.dev/components/chromatic-text.md): The color channels split and rejoin.
- [Slice reveal](https://textlab.dev/components/slice-reveal.md): The title arrives in offset horizontal slices.
- [Scramble text](https://textlab.dev/components/scramble-text.md): Letters decode into place.

## Also in distort

- [Gooey text](https://textlab.dev/components/gooey-text.md): Words melt into each other.
- [Liquid text](https://textlab.dev/components/liquid-text.md): Ripples run through the letters under the cursor.
- [Ink bleed](https://textlab.dev/components/ink-bleed.md): Text soaks in like ink on paper.
- [Heat haze](https://textlab.dev/components/heat-haze.md): A mirage warps the air around the type.

## Source

### components/textlab/glitch-text/glitch-text.tsx

```tsx
"use client";

import { useEffect, useState, type CSSProperties, type ElementType } from "react";
import { srOnly } from "../lib/a11y";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";

export type GlitchTextVariant = "slice" | "shift" | "scan";

export interface GlitchTextProps {
  text: string;
  /** slice tears horizontal bands sideways, shift jolts the whole word with split channels, scan rolls a bright band through it. */
  variant?: GlitchTextVariant;
  /** When it glitches: on hover, on an interval, or once on mount. */
  trigger?: "hover" | "interval" | "mount";
  /** Seconds between bursts for trigger="interval". */
  every?: number;
  as?: ElementType;
  reducedMotion?: ReducedMotion;
  className?: string;
  style?: CSSProperties;
}

// Each burst is a double hit: a sharp tear, a beat of calm, a smaller aftershock, all hard-stepped
// over half a second, then a long rest. Never more than two bursts a second.
const CSS = `
@keyframes tl-glitch-a {
  0% { clip-path: inset(10% 0 72% 0); transform: translate(-0.09em, 0) skewX(-8deg); }
  8% { clip-path: inset(62% 0 20% 0); transform: translate(0.11em, 0); }
  16% { clip-path: inset(34% 0 50% 0); transform: translate(-0.05em, 0) skewX(6deg); }
  24% { clip-path: inset(84% 0 4% 0); transform: translate(0.07em, 0); }
  32% { clip-path: inset(2% 0 88% 0); transform: translate(-0.1em, 0); }
  40%, 58% { clip-path: inset(50% 0 50% 0); transform: none; }
  64% { clip-path: inset(44% 0 40% 0); transform: translate(0.05em, 0); }
  74% { clip-path: inset(70% 0 18% 0); transform: translate(-0.04em, 0); }
  84%, 100% { clip-path: inset(50% 0 50% 0); transform: none; }
}
@keyframes tl-glitch-b {
  0% { clip-path: inset(68% 0 10% 0); transform: translate(0.1em, 0); }
  10% { clip-path: inset(6% 0 78% 0); transform: translate(-0.08em, 0) skewX(7deg); }
  20% { clip-path: inset(42% 0 36% 0); transform: translate(0.06em, 0); }
  30% { clip-path: inset(22% 0 62% 0); transform: translate(-0.11em, 0); }
  40%, 60% { clip-path: inset(50% 0 50% 0); transform: none; }
  68% { clip-path: inset(14% 0 72% 0); transform: translate(-0.05em, 0); }
  78% { clip-path: inset(56% 0 30% 0); transform: translate(0.04em, 0); }
  86%, 100% { clip-path: inset(50% 0 50% 0); transform: none; }
}
@keyframes tl-glitch-jolt {
  0%, 40%, 60%, 86%, 100% { transform: none; opacity: 1; }
  6% { transform: translate(-0.03em, 0.01em); opacity: 0.82; }
  14% { transform: translate(0.025em, 0) skewX(-3deg); }
  26% { transform: translate(-0.015em, -0.01em); opacity: 0.9; }
  68% { transform: translate(0.02em, 0); opacity: 0.88; }
}
@keyframes tl-glitch-split-a {
  0%, 40%, 60%, 86%, 100% { clip-path: inset(0 0 100% 0); transform: none; }
  4% { clip-path: inset(0); transform: translate(-0.07em, 0.01em); }
  16% { clip-path: inset(0); transform: translate(-0.04em, -0.01em); }
  28% { clip-path: inset(0); transform: translate(-0.08em, 0); }
  66% { clip-path: inset(0); transform: translate(-0.035em, 0); }
}
@keyframes tl-glitch-split-b {
  0%, 40%, 60%, 86%, 100% { clip-path: inset(0 0 100% 0); transform: none; }
  4% { clip-path: inset(0); transform: translate(0.07em, -0.01em); }
  16% { clip-path: inset(0); transform: translate(0.05em, 0.01em); }
  28% { clip-path: inset(0); transform: translate(0.08em, 0); }
  66% { clip-path: inset(0); transform: translate(0.035em, 0); }
}
@keyframes tl-glitch-shift { 0%, 40%, 60%, 86%, 100% { transform: none; } 6% { transform: translate(-0.06em, 0.02em) skewX(-8deg); } 16% { transform: translate(0.07em, -0.01em); } 28% { transform: translate(-0.03em, 0) skewX(5deg); } 68% { transform: translate(0.03em, 0.01em); } }
@keyframes tl-glitch-scan { from { background-position: 0 -120%; } to { background-position: 0 220%; } }
.tl-glitch-layer { position: absolute; inset: 0; pointer-events: none; clip-path: inset(50% 0 50% 0); }
[data-glitching] > .tl-glitch-a { animation: tl-glitch-a 0.5s steps(1) 1; color: var(--tl-glitch-1); }
[data-glitching] > .tl-glitch-b { animation: tl-glitch-b 0.5s steps(1) 1; color: var(--tl-glitch-2); }
[data-glitching][data-variant="slice"] > .tl-glitch-main { animation: tl-glitch-jolt 0.5s steps(1) 1; }
[data-glitching][data-variant="shift"] > .tl-glitch-main { animation: tl-glitch-shift 0.5s steps(1) 1; }
[data-glitching][data-variant="shift"] > .tl-glitch-a { animation-name: tl-glitch-split-a; opacity: 0.8; }
[data-glitching][data-variant="shift"] > .tl-glitch-b { animation-name: tl-glitch-split-b; opacity: 0.8; }
[data-variant="scan"] > .tl-glitch-scan { clip-path: none; background: linear-gradient(transparent 0%, transparent 38%, var(--tl-glitch-2) 50%, transparent 62%) no-repeat; background-size: 100% 40%; background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-position: 0 -120%; }
[data-glitching][data-variant="scan"] > .tl-glitch-scan { animation: tl-glitch-scan 0.6s linear 1; }
[data-glitching][data-variant="scan"] > .tl-glitch-main { animation: tl-glitch-jolt 0.6s steps(1) 1; }
`;

/**
 * A controlled digital glitch on cue: short, hard-stepped bursts with long rests, so it reads as
 * intentional rather than broken.
 */
export function GlitchText({ text, variant = "slice", trigger = "interval", every = 3.5, as: Tag = "span", reducedMotion = "user", className, style }: GlitchTextProps) {
  const reduced = useReduced(reducedMotion);
  const [burst, setBurst] = useState(0);
  const [glitching, setGlitching] = useState(false);

  useEffect(() => {
    if (reduced) return;
    if (trigger === "mount") {
      const id = setTimeout(() => setBurst((b) => b + 1), 300);
      return () => clearTimeout(id);
    }
    if (trigger !== "interval") return;
    const id = setInterval(() => setBurst((b) => b + 1), every * 1000);
    return () => clearInterval(id);
  }, [reduced, trigger, every]);

  // Derive the burst window while rendering instead of syncing it in an effect.
  const [seen, setSeen] = useState(0);
  if (burst !== seen) {
    setSeen(burst);
    setGlitching(true);
  }
  useEffect(() => {
    if (!glitching) return;
    const id = setTimeout(() => setGlitching(false), 650);
    return () => clearTimeout(id);
  }, [glitching, burst]);

  if (reduced) {
    return (
      <Tag className={className} style={style}>
        {text}
      </Tag>
    );
  }

  return (
    <Tag
      className={className}
      style={{ position: "relative", display: "inline-block", "--tl-glitch-1": "oklch(0.68 0.25 25)", "--tl-glitch-2": "oklch(0.78 0.15 200)", ...style } as CSSProperties}
      data-variant={variant}
      data-glitching={glitching ? "" : undefined}
      onPointerEnter={trigger === "hover" ? () => setBurst((b) => b + 1) : undefined}
    >
      <style href="tl-glitch-text" precedence="default">{CSS}</style>
      <span style={srOnly}>{text}</span>
      <span aria-hidden="true" className="tl-glitch-main" style={{ display: "inline-block" }}>
        {text}
      </span>
      {variant !== "scan" && (
        <>
          <span aria-hidden="true" className="tl-glitch-layer tl-glitch-a">
            {text}
          </span>
          <span aria-hidden="true" className="tl-glitch-layer tl-glitch-b">
            {text}
          </span>
        </>
      )}
      {variant === "scan" && (
        <span aria-hidden="true" className="tl-glitch-layer tl-glitch-scan">
          {text}
        </span>
      )}
    </Tag>
  );
}

export default GlitchText;
```

Full library index: https://textlab.dev/llms.txt
