Skip to content

Components / Text

Glitch Text

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.

Live demo

SIGNAL

404 · not found, still measured

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 5.8 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

Chrome 55 · Firefox 54 · Safari 13.1; needs clip-path.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import GlitchText from '@moonarc/core/GlitchText';
---
<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)" />
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/glitch-text.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

GlitchText.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>

Props

PropTypeDefaultDescription
textstringnoneThe text; repeated by two pseudo-elements, so it must be a plain string.
asHTMLTag'span'Element to render.
periodnumber4Seconds between bursts. The burst itself is the last tenth of the period.
shiftnumberpreset (ui: 4)Horizontal offset of the slices in px. Unset, the preset's --ma-travel-hover.
skewnumber3Skew of the base text during the burst, in degrees. 0 keeps the base text still.
colorAstring'oklch(0.65 0.22 25)'Colour of the first copy.
colorBstring'oklch(0.72 0.14 210)'Colour of the second copy.

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.

Why it is built this way

Replaces: GlitchText (React Bits) · glitch text (Kokonut, Animata). See the migration table.

Noise

Backgrounds

Film grain over a gradient or image, blended and flickering at 12 fps.

every browseralways

A highlight sweeps across the text every few seconds.

every browseralways

Random characters resolve into the real text from left to right when it scrolls into view, on load, or on hover.

every browserscrollhoverload

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown