# Glitch Text (Moonarc)

Splits text into two coloured copies and jumps a few slices sideways for a fraction of a second, every few seconds; the rest of the time the text is clean. Two pseudo-element copies clipped to slices on step-end keyframes, so nothing tweens. Pure CSS, and completely still under reduced motion.

- Import: `import GlitchText from '@moonarc/core/GlitchText'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/glitch-text.json`
- Tier A · category text · trigger always
- Readout: `<GlitchText period={4}>`
- Browser support: widely (Chrome 55 · Firefox 54 · Safari 13.1)
- Measured cost: 0 B JS (CSS 5.8 kB raw)
- Page: https://moonarc.dev/components/glitch-text/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `text` | `string` | none | The text; repeated by two pseudo-elements, so it must be a plain string. |
| `as` | `HTMLTag` | `'span'` | Element to render. |
| `period` | `number` | `4` | Seconds between bursts. The burst itself is the last tenth of the period. |
| `shift` | `number` | `preset (ui: 4)` | Horizontal offset of the slices in px. Unset, the preset's --ma-travel-hover. |
| `skew` | `number` | `3` | Skew of the base text during the burst, in degrees. 0 keeps the base text still. |
| `colorA` | `string` | `'oklch(0.65 0.22 25)'` | Colour of the first copy. |
| `colorB` | `string` | `'oklch(0.72 0.14 210)'` | Colour of the second copy. |

## Usage

```astro
<h1><GlitchText text="Signal lost." /></h1>
<GlitchText text="404" period={2.5} shift={6} colorA="var(--ma-ink)" colorB="oklch(0.8 0.15 152)" />
```

## Reduced motion

No burst at all: the copies are removed and the text stands still. A slower glitch would still be a glitch.

## With ClientRouter

CSS-only; nothing to rebind.

## Craft

- step-end everywhere: a glitch is a sequence of wrong frames. The moment any of it interpolates it reads as a wobble.
- The burst is a tenth of the period: 400 ms of noise every 4 s. Continuous glitching reads as a loading state; a rare one reads as texture.
- The base text is real text in its own colour and never moves in layout; the skew is a transform, so the line around it is untouched.
- The slice offset defaults to the preset's hover travel: 2 px on snap, 6 px on lively, so the glitch keeps the page's scale.
- Two copies clipped to different slice sequences: the eye reads a split, not a flicker.
- Generated content is part of an element's accessible name, so each copy declares an empty alternative (content: attr(data-text) / '') and a screen reader reads the text once.

## Replaces

- GlitchText (React Bits)
- glitch text (Kokonut, Animata)

## Source

```astro
---
/**
 * GlitchText — an RGB split and a few jumping slices, every few seconds.
 * The text is painted once; two pseudo-element copies (content: attr) in
 * two colours are clipped to horizontal slices and offset by the hover
 * travel token, on step-end keyframes so nothing tweens — a glitch that
 * eases is a wobble. The burst is the last tenth of the period; the rest
 * of the time the text is clean. Zero JS. Reduced motion: still.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';

interface Props extends HTMLAttributes<'span'> {
  /** The text. */
  text: string;
  /** Element to render. */
  as?: HTMLTag;
  /** Seconds between bursts. */
  period?: number;
  /** Horizontal offset of the split in px. Unset: the preset's --ma-travel-hover. */
  shift?: number;
  /** Skew of the base text during the burst, degrees. */
  skew?: number;
  /** Colours of the two copies. */
  colorA?: string;
  colorB?: string;
}

const { text, as: Tag = 'span', period = 4, shift, skew = 3, colorA = 'oklch(0.65 0.22 25)', colorB = 'oklch(0.72 0.14 210)', class: className, style, ...rest } = Astro.props;
const vars = [`--ma-glitch-period:${period}s`, shift !== undefined && `--ma-glitch-shift:${shift}px`, `--ma-glitch-skew:${skew}deg`, `--ma-glitch-a:${colorA}`, `--ma-glitch-b:${colorB}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<Tag class:list={['ma-glitch', className]} data-text={text} style={vars} {...rest}>{text}</Tag>

<style is:global>
  @layer components {
    :where(.ma-glitch) {
      position: relative;
      display: inline-block;
      animation: ma-glitch-root var(--ma-glitch-period, var(--ma-dur-ambient)) step-end infinite;
    }
    :where(.ma-glitch)::before,
    :where(.ma-glitch)::after {
      /* paint only: an empty alternative after the slash keeps the two copies out of the element's accessible name, which
         read the text three times. The first line is for engines without that syntax. */
      content: attr(data-text);
      content: attr(data-text) / '';
      position: absolute;
      inset: 0;
      pointer-events: none;
      clip-path: inset(100% 0 0 0);
      animation: ma-glitch-a var(--ma-glitch-period, var(--ma-dur-ambient)) step-end infinite;
    }
    :where(.ma-glitch)::before {
      color: var(--ma-glitch-a);
    }
    :where(.ma-glitch)::after {
      color: var(--ma-glitch-b);
      animation-name: ma-glitch-b;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-glitch) {
        animation: none;
      }
      :where(.ma-glitch)::before,
      :where(.ma-glitch)::after {
        content: none;
        animation: none;
      }
    }
  }

  /* the burst is the last tenth of the period; step-end so every frame is a jump */
  @keyframes ma-glitch-a {
    0%,
    89% {
      clip-path: inset(100% 0 0 0);
      translate: 0 0;
    }
    90% {
      clip-path: inset(8% 0 62% 0);
      translate: var(--ma-glitch-shift, var(--ma-travel-hover)) 0;
    }
    92% {
      clip-path: inset(52% 0 18% 0);
      translate: calc(-1 * var(--ma-glitch-shift, var(--ma-travel-hover))) 0;
    }
    94% {
      clip-path: inset(24% 0 44% 0);
      translate: var(--ma-glitch-shift, var(--ma-travel-hover)) 0;
    }
    96% {
      clip-path: inset(70% 0 6% 0);
      translate: calc(-0.5 * var(--ma-glitch-shift, var(--ma-travel-hover))) 0;
    }
    98%,
    100% {
      clip-path: inset(100% 0 0 0);
      translate: 0 0;
    }
  }
  @keyframes ma-glitch-b {
    0%,
    89% {
      clip-path: inset(100% 0 0 0);
      translate: 0 0;
    }
    90% {
      clip-path: inset(60% 0 10% 0);
      translate: calc(-1 * var(--ma-glitch-shift, var(--ma-travel-hover))) 0;
    }
    92% {
      clip-path: inset(14% 0 66% 0);
      translate: var(--ma-glitch-shift, var(--ma-travel-hover)) 0;
    }
    94% {
      clip-path: inset(40% 0 30% 0);
      translate: calc(-1 * var(--ma-glitch-shift, var(--ma-travel-hover))) 0;
    }
    96% {
      clip-path: inset(2% 0 82% 0);
      translate: calc(0.5 * var(--ma-glitch-shift, var(--ma-travel-hover))) 0;
    }
    98%,
    100% {
      clip-path: inset(100% 0 0 0);
      translate: 0 0;
    }
  }
  @keyframes ma-glitch-root {
    0%,
    89% {
      transform: none;
    }
    91% {
      transform: skewX(calc(-1 * var(--ma-glitch-skew, 3deg)));
    }
    93% {
      transform: skewX(var(--ma-glitch-skew, 3deg));
    }
    95%,
    100% {
      transform: none;
    }
  }
</style>

```
