Glitch Text
Cyberpunk glitch headline — two pseudo-element copies tear the text into slices and slide, in pure CSS, dead still under reduced motion.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/glitch-text.jsonPrompt
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: -1layer inside an isolated root, so the real glyphs are always the topmost, fully opaque paint. That is what keeps the text at fullforeground-on-background contrast at any intensity, and it means the component never has to know whether it is sitting onbg-cardorbg-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 separatearia-hiddenlayer 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.slicesdivides 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
--primaryand--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
!importantrather than slowing it down, and no prop can override it. The text, layout and semantics are identical; only the effect is gone.
Truncate Middle
Middle-ellipsis text that keeps both ends readable — a fixed head/tail count, or as many grapheme clusters as the measured width actually fits.
Search Highlight
Marks the parts of a text that match a search query — substring, per-word or fuzzy, with escaped queries and a current-match cursor.