Text

Glitch Text

Cyberpunk glitch headline — two pseudo-element copies tear the text into slices and slide, in pure CSS, dead still under reduced motion.

Preview in your theme

Loading preview…

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

// Pure-CSS glitch text: two ::before / ::after copies + clip-path tears + displacement.
// No per-frame JS — a decorative effect should not cost CPU — which also means no
// "use client", so this can sit inside a server component.
//
// Three structural decisions:
// 1) the copies live on a separate aria-hidden layer, not on the root node. Pseudo-element
//    content does reach the accessibility tree in Chrome, so putting them on the root would
//    have a screen reader read the same phrase three times; on an aria-hidden layer the
//    whole subtree stays out and the real text is the only readable source.
// 2) copy layer z-index:-1 + isolate on the root: the copies are always painted *behind*
//    the real text. However loud the intensity gets, the body text still composites as

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/glitch-text.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "GlitchText" component. Pure CSS: no
hooks, no rAF, no animation library — a decorative background effect must not
cost a JS frame, and the component must stay usable as a server component.

Contract
- forwardRef<HTMLSpanElement>, extends React.HTMLAttributes<HTMLSpanElement>
  but overrides children to `string` (not ReactNode): the displaced copies are
  produced with content: attr(data-text), and attr() can only take text.
- Props: children (string), intensity = "subtle" | "medium" | "strong"
  (default "medium"), speed (seconds per glitch cycle, default 2.5),
  trigger = "always" | "hover" | "once" (default "always"),
  color = "mono" | "chroma" (default "chroma"),
  slices (how many horizontal bands the text can be torn into, default 5).
- Clamp the numeric props: slices to 2..12 and rounded (slices = 0 makes
  100/slices Infinity and emits invalid keyframes), speed to 0.15..30s
  (0 or NaN kills the animation outright).
- No font-size or weight of its own — it inherits the surrounding typography,
  so the same component works on a 12px label and a 72px 404.

Behavior
- Structure: a relatively positioned, isolated root that holds (a) an
  aria-hidden layer whose ::before and ::after each render content:
  attr(data-text), and (b) the real text as an ordinary child.
- Put the copies on that separate aria-hidden layer, NOT on the root's own
  pseudo-elements. Chrome exposes generated content to the accessibility
  tree, so pseudo-elements on the root would make a screen reader announce
  the same headline three times. On an aria-hidden layer the whole subtree is
  excluded and the real text is the single readable source.
- Paint the copies BEHIND the real text: layer z-index: -1, root
  isolation: isolate (so the negative layer stays above the root's own
  background instead of sinking behind an ancestor card). Two consequences,
  both deliberate: the real glyphs are never occluded, so text contrast is
  always foreground-on-background no matter how strong the effect gets; and
  the component never needs to know the surface color it sits on.
- Align the copies with the real text using grid, not absolute offsets: the
  layer is `position: absolute; inset: 0; display: grid; padding: inherit;
  box-sizing: border-box`, and both pseudo-elements sit in grid-area 1/1.
  padding: inherit keeps them aligned when the consumer adds padding, and
  text-align / letter-spacing / text-transform inherit for free.
- Each copy runs one keyframe track built at render time from `slices`:
  it rests at inset(0 0 0 0) with a small horizontal offset, and `slices`
  times per cycle it jumps to a single band — clip-path: inset(top% 0
  bottom% 0) — displaced by a multiple of the shift. Each tear window lives
  inside its own 1/slices time slot (jittered start, 30% of the slot wide),
  so the percentages are strictly increasing and windows never overlap.
- animation-timing-function: steps(1, end). Interpolating clip-path smoothly
  reads as a scanline wipe; discrete jumps read as a glitch.
- Pick bands, tear timing and displacement with a deterministic hash of
  (lane, slices, index) — never Math.random(). The keyframe string has to be
  byte-identical between SSR and client or hydration complains, and render
  must stay pure.
- Emit two stylesheets with React 19's <style href precedence="medium">: one
  static sheet with the structural rules (deduped globally), one per slice
  count holding only @keyframes (instances that share a slice count share it).
  Prefix every keyframe name and custom property (glx-*) — several components
  can be on the same page.
- Wire the animation names through inherited custom properties
  (animation-name: var(--glx-name-a)) set inline on the root. That is what
  lets a static stylesheet drive dynamically generated keyframes without
  emitting one rule per instance.
- trigger="always" -> iteration-count: infinite. trigger="once" ->
  iteration-count: 1 with fill-mode: both, so it ends on the 0%/100% rest
  frame (chroma settles into a static hairline offset, mono to nothing);
  remount with a key to replay. trigger="hover" -> the layer is opacity: 0
  and animation-name: none until :hover or :focus-visible, then both switch
  on: hidden, not merely paused, so the resting state is ordinary text.
- prefers-reduced-motion is a hard stop, not a dial: the media query removes
  the whole copy layer (display: none) AND sets animation-name: none, both
  with !important so no consumer override or hover rule can beat it. Strobing
  and tearing are a genuine trigger for photosensitive and vestibular users,
  so there is no prop to force the animation back on. Everything else about
  the component (text, layout, semantics) is unchanged.

Rendering & styling
- Semantic tokens only, no hex/rgb/oklch: chroma uses var(--primary) and
  var(--destructive) for the two copies, mono uses currentColor for both.
  Be honest about the trade: a real RGB split wants literal cyan/magenta, and
  this is a semantic rewrite of chromatic aberration so it re-themes with the
  palette. On a monochrome palette chroma reads as a dark/light + red split.
  Want the classic red/cyan? Change the two entries in the ink map.
- Amplitudes in em (subtle 0.05em, medium 0.1em, strong 0.18em) so the tear
  scales with font-size instead of vanishing on a hero.
- Copy opacity: ~0.9 for chroma, ~0.55 for mono (a same-color copy at full
  strength just reads as a bold smear).
- cn() merges className; keep the root's display as a utility class
  (inline-block) so a consumer can override it with block/flex.
- Accessibility: the real text stays a normal, selectable, announced text
  node; the copy layer is aria-hidden with pointer-events: none and
  user-select: none, so selecting and copying yields the text exactly once.

Customization levers
- Amplitude: the three intensity presets are just two em values each; or skip
  the prop and set --glx-shift / --glx-rest inline for exact control.
- Palette: --glx-ink-a / --glx-ink-b (any token, or literal cyan/magenta for
  a textbook RGB split) and --glx-alpha for how loud the copies are.
- Density and rhythm: slices controls band count (2 = two huge halves,
  11-12 = thin scanlines); speed controls the cycle. Tear duty cycle lives in
  one number in the keyframe builder (0.3 of a slot) — raise it for constant
  chaos, lower it for a rare hiccup.
- Trigger: "always" for a 404 or a hero, "hover" for a link or nav item,
  "once" for a boot/reveal beat (bump iteration-count to 2-3 for a longer
  burst).
- Direction: the tears are horizontal-only by design (that is what video
  tearing looks like). For vertical bands, swap inset()'s top/bottom pair for
  the left/right pair and translate on Y.
- Kill switch: pass trigger="hover" (or drop the class) behind a "reduce
  effects" user setting to silence it app-wide without touching markup.

Concepts

  • Copies behind, not on top — the two displaced duplicates sit on a z-index: -1 layer inside an isolated root, so the real glyphs are always the topmost, fully opaque paint. That is what keeps the text at full foreground-on-background contrast at any intensity, and it means the component never has to know whether it is sitting on bg-card or bg-background.
  • attr(data-text) + an aria-hidden layer — generated content is exposed to the accessibility tree in Chrome, so putting the copies on the root's own pseudo-elements would announce the headline three times. Hosting them on a separate aria-hidden layer excludes the whole subtree; the attribute is not text content, so selection and copy-paste also yield the string once.
  • Slice tearing — a tear is one clip-path: inset(top% 0 bottom% 0) band displaced horizontally for a few frames. slices divides both the text height into bands and the cycle into time slots, so raising it makes the bands thinner and the glitches more frequent at the same time.
  • steps(1, end), not interpolation — smoothly animating a clip band slides it like a scanline wipe; jumping between discrete frames is what reads as a broken signal. The whole track is therefore a sequence of held frames, mostly resting.
  • Semantic chromatic aberration — the classic effect needs literal cyan/magenta; here the two copies are --primary and --destructive, so the split re-themes with the palette instead of fighting it. On the default monochrome palette that reads as a light/dark + red separation rather than a textbook RGB split — swapping the two ink values back to literal colors is a two-line change.
  • Reduced motion is a stop, not a dial — flicker and tearing are a photosensitivity and vestibular risk, so the media query deletes the copy layer with !important rather than slowing it down, and no prop can override it. The text, layout and semantics are identical; only the effect is gone.

On This Page