CSS Glitch Effect

An animated glitch effect using CSS clip-path, pseudo-elements, and keyframes, with no JavaScript.

Published May 19, 2026 Intermediate 7 min read

A CSS glitch effect fakes a torn video signal by drawing the same text three times and tearing two of the copies apart. The ::before and ::after pseudo-elements clone the text from a data-text attribute, each takes a different color, and rapidly changing clip-path: inset() slices exposes a different horizontal band of each clone on every keyframe. Nothing moves except the clipping windows and a few pixels of horizontal offset.

The timing is what sells it. Both animations sit idle for most of their cycle and do all their work in the last ten percent, so the text sits still and then tears for a fraction of a second, which is how a real signal fault behaves. Two variants follow: one that only glitches on hover or focus, and one built from text-shadow alone with no pseudo-elements at all.

Two intensities

Glitch
Glitch

HTML

<div class="glitch-wrapper">
  <span class="glitch" data-text="Glitch">Glitch</span>
</div>

<div class="glitch-wrapper">
  <span class="glitch glitch-intense" data-text="Glitch">Glitch</span>
</div>

CSS

.glitch-wrapper {
  display: flex;
  align-items: center;
  justify-content: center;
}

.glitch {
  position: relative;
  color: #f0f0f0;
  font-family: "DM Mono", "Fira Code", Consolas, monospace;
  font-size: 3rem;
  font-weight: 800;
  letter-spacing: .05em;
}

.glitch::before,
.glitch::after {
  content: attr(data-text);
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

.glitch::before {
  color: #57d9a3;
  animation: glitch-1 1.5s infinite;
}

.glitch::after {
  color: #ff6b6b;
  animation: glitch-2 1.5s infinite;
}

/* Intense version: faster cycle, wider offsets */
.glitch-intense::before { animation: glitch-intense-1 0.8s infinite; }
.glitch-intense::after  { animation: glitch-intense-2 0.8s infinite; }

@keyframes glitch-1 {
  0%, 90%, 100% { clip-path: inset(0 0 100% 0); left: 0; }
  92%  { clip-path: inset(20% 0 60% 0); left: -3px; }
  94%  { clip-path: inset(50% 0 30% 0); left:  2px; }
  96%  { clip-path: inset(10% 0 75% 0); left: -2px; }
  98%  { clip-path: inset(65% 0 10% 0); left:  3px; }
}

@keyframes glitch-2 {
  0%, 88%, 100% { clip-path: inset(0 0 100% 0); left: 0; }
  90%  { clip-path: inset(40% 0 40% 0); left:  3px; }
  92%  { clip-path: inset(5%  0 80% 0); left: -3px; }
  94%  { clip-path: inset(75% 0 5%  0); left:  2px; }
  96%  { clip-path: inset(30% 0 55% 0); left: -2px; }
}

@keyframes glitch-intense-1 {
  0%, 72%, 100% { clip-path: inset(0 0 100% 0); left: 0; }
  74%  { clip-path: inset(10% 0 55% 0); left: -7px; }
  78%  { clip-path: inset(45% 0 20% 0); left:  6px; }
  82%  { clip-path: inset(70% 0 5%  0); left: -5px; }
  86%  { clip-path: inset(5%  0 72% 0); left:  8px; }
  90%  { clip-path: inset(30% 0 40% 0); left: -4px; }
  94%  { clip-path: inset(85% 0 3%  0); left:  5px; }
}

@keyframes glitch-intense-2 {
  0%, 68%, 100% { clip-path: inset(0 0 100% 0); left: 0; }
  71%  { clip-path: inset(60% 0 15% 0); left:  8px; }
  75%  { clip-path: inset(15% 0 60% 0); left: -7px; }
  79%  { clip-path: inset(88% 0 3%  0); left:  5px; }
  83%  { clip-path: inset(3%  0 88% 0); left: -8px; }
  87%  { clip-path: inset(40% 0 35% 0); left:  6px; }
  91%  { clip-path: inset(22% 0 58% 0); left: -5px; }
}

Other ways to build it

Glitch on hover and focus only

Moving the animation onto :hover and :focus-visible turns a perpetual flicker into a response. Nothing moves until someone points at the link or tabs to it, which removes the accessibility problem and the constant compositing work at the same time. The two pseudo-elements still exist at rest, so they are given an explicit clip-path: inset(0 0 100% 0) outside the animation rather than relying on the first keyframe to hide them. Hover the link below, or tab to it.

HTML

<a href="/enter" class="glitch glitch-hover" data-text="Hover me">Hover me</a>

CSS

/* uses @keyframes glitch-1, @keyframes glitch-2 from the main example above; the rules here are
   what changes, not the whole file */
/* hidden at rest, rather than trusting the first keyframe to do it */
.glitch-hover::before,
.glitch-hover::after {
  clip-path: inset(0 0 100% 0);
  animation: none;
}

.glitch-hover:hover::before,
.glitch-hover:focus-visible::before {
  animation: glitch-1 1.5s infinite;
}

.glitch-hover:hover::after,
.glitch-hover:focus-visible::after {
  animation: glitch-2 1.5s infinite;
}

@media (prefers-reduced-motion: reduce) {
  .glitch-hover:hover::before,
  .glitch-hover:hover::after,
  .glitch-hover:focus-visible::before,
  .glitch-hover:focus-visible::after { animation: none; }
}

Channel split with text-shadow alone

Two colored text-shadow layers offset in opposite directions give the same split channel look with one element, no attribute, and no pseudo-elements. Animating the offsets shakes the two colors apart and back. What it cannot do is tear a horizontal band out of the middle of a word, because a shadow always follows the whole glyph. What it does better is degrade: a browser that renders none of this shows plain readable text rather than three stacked copies.

Signal

HTML

<span class="glitch-rgb">Signal</span>

CSS

.glitch-rgb {
  font-family: "DM Mono", Consolas, monospace;
  font-size: 3rem;
  font-weight: 800;
  color: #f0f0f0;
  animation: rgb-split 2.4s infinite;
}

/* the shadows carry the color, so the element itself never moves */
@keyframes rgb-split {
  0%, 86%, 100% {
    text-shadow: 2px 0 #ff6b6b, -2px 0 #57d9a3;
  }
  88% { text-shadow:  7px 0 #ff6b6b,  -7px 0 #57d9a3; }
  90% { text-shadow: -6px 0 #ff6b6b,   6px 0 #57d9a3; }
  92% { text-shadow:  9px 2px #ff6b6b, -9px -2px #57d9a3; }
  94% { text-shadow: -4px 0 #ff6b6b,   4px 0 #57d9a3; }
}

@media (prefers-reduced-motion: reduce) {
  .glitch-rgb {
    animation: none;
    text-shadow: 2px 0 #ff6b6b, -2px 0 #57d9a3;
  }
}

How it works

Two ::before and ::after pseudo-elements clone the text using content: attr(data-text). Each clone is offset horizontally with left and colored using color. Rapidly switching clip-path: inset() slices inside @keyframes creates the scan-line tearing. The parent uses position:relative to contain the pseudo-elements.

content: attr(data-text) is the piece that keeps the markup honest. The clones read their text from an attribute on the element rather than from a string hard coded in the stylesheet, so one rule serves every glitched heading on the site and the words only exist in one place. The cost is that the text has to be written twice in the HTML, once as content and once in the attribute, and they will drift apart the first time someone edits only one of them.

clip-path: inset(top right bottom left) hides everything outside the rectangle it describes. inset(0 0 100% 0) clips away the full height, which is how both clones stay invisible for the quiet part of the cycle. inset(20% 0 60% 0) leaves a band running from twenty percent down to forty percent, and jumping between bands at different keyframes is what produces the horizontal tearing.

Both clones sit at top: 0; left: 0 over a position: relative parent, so they stack exactly on the original text. The left value is animated by a few pixels in each direction, which offsets a band sideways at the same moment it becomes visible. The teal and coral colors are what make that offset read as a channel split rather than as a blur.

The keyframe percentages are unevenly spaced on purpose. 0%, 90%, 100% all share the hidden state, and the four tearing frames are packed between 92 and 98 percent. Spread the same frames evenly across the cycle and the effect becomes a constant shimmer, which looks like a rendering bug rather than a glitch. The intense variant uses a shorter duration and a wider window, so it fires more often and further.

Because both pseudo-elements are painted copies of text, none of this is visible to the accessibility tree or to a text search. The original element still holds the real content. That also means a browser that ignores clip-path shows three overlapping copies at full opacity, which is the one degradation worth planning for.

CSS properties used

content
attr(data-text) pulls the clone's text from an attribute, so the stylesheet does not have to know what the heading says.
clip-path
inset() exposes one horizontal band of a clone and hides the rest. Changing the band between keyframes is what tears the text.
position
relative on the parent and absolute on both clones, so the copies stack exactly over the original.
animation
Two separate animations at the same duration, offset by a couple of percent, which stops the two clones tearing in lockstep.
color
A different color on each clone. The teal and coral pair reads as a split color channel, which is what makes the offset look like signal damage.
text-shadow
An alternative route to the same look, used in the second variant, where two colored shadows replace the pseudo-element clones entirely.

Browser support

FeatureChromeFirefoxSafariEdge
animation455.112
::before content423.112
text-shadow43.5412
prefers-reduced-motion746310.179

clip-path is deliberately absent from the table. Can I Use records it as only partially supported on HTML elements in Chrome, Safari and Edge, so quoting a version number from that entry would be misleading. The partial mark is not about the inset() basic shape this effect uses, which is what every current browser implements. The degradation matters more than the number: a browser that ignores clip-path never hides the two clones, so instead of a glitch you get three overlapping copies of the heading at full opacity. The text-shadow variant below avoids that failure mode completely.

Accessibility notes

Rapid flickering is a seizure risk, and the guidance is specific: nothing should flash more than three times per second. The base version fires its tearing frames inside a short window near the end of a 1.5 second cycle, which sits under that limit, and the intense version at 0.8 seconds runs closer to it. Slowing the cycle rather than adding frames is the safer way to make a glitch feel more aggressive.

Anything that runs forever should stop when the system asks for less motion. A prefers-reduced-motion block that sets animation: none on both pseudo-elements leaves the heading rendered as ordinary text, which loses the effect and loses nothing else. The hover variant below is the better default for the same reason: motion only happens when someone asks for it.

The duplicated text is invisible to assistive technology, since pseudo-element content generated by CSS is not exposed the way real content is. What does matter is contrast. The colored clones sit over the real text and, during a tear, partially replace it, so the underlying color still has to pass contrast on its own without help from the effect.

What you can build with it

  • Cyberpunk and synthwave headings. The genre the effect belongs to, where a single glitched wordmark sets the tone for a page.
  • Error and 404 pages. A broken looking status code reads as intentional rather than as a template nobody styled.
  • Game and demoscene sites. Title treatments and section labels, usually alongside a monospace face and a scanline overlay.
  • Hover states on links. A brief tear on hover, which is the variant below, gives a link a strong response without any layout movement.
  • Loading and processing labels. Text that tears while a task runs, paired with something like the typewriter effect to spell the message out first.

Mistakes worth avoiding

  • Letting the data-text attribute drift out of sync with the visible text. The clones say one thing and the element says another, and the mismatch only shows during a tear.
  • Spacing the keyframes evenly. The effect stops looking like a fault and starts looking like a constant shimmer, which reads as a rendering bug.
  • Leaving the parent at position: static. Both clones then position themselves against the nearest positioned ancestor instead, and the copies land somewhere else on the page entirely.
  • Running the animation continuously with no reduced motion escape. Perpetual flicker is a genuine accessibility failure, not a stylistic preference.
  • Assuming the clones are hidden by default. They are only hidden by the clip-path in the first keyframe, so anywhere clip-path does not apply, all three copies are visible at once.

Frequently asked questions

How do you make a glitch text effect in CSS?
Put the text in a data-text attribute as well as in the element, clone it into ::before and ::after with content: attr(data-text), stack both copies over the original with absolute positioning, then animate clip-path: inset() and a small left offset so different horizontal bands of each clone appear and shift.
Why is my glitch effect showing three copies of the text?
The clip-path is not being applied. Either the first keyframe is missing its inset(0 0 100% 0) hidden state, or the animation has not started, or the browser is ignoring the property. Adding opacity: 0 to the resting state of both pseudo-elements gives you a second line of defence.
Does the glitch effect need JavaScript?
No. Everything here is @keyframes, two pseudo-elements, and one data attribute. The randomness is faked by choosing irregular keyframe percentages, since CSS has no random function.
Is a glitch animation accessible?
Only if it is controlled. Keep the flashing under three times per second, and honour prefers-reduced-motion by turning the animation off entirely. Triggering the effect on hover or focus rather than running it forever is the safest version, and it is the first variant below.
Can you do a glitch effect without pseudo-elements?
Yes. Two offset text-shadow layers in different colors give the same channel split look, and animating the offsets produces the shake. It is one element and no attributes, it degrades to plain text rather than to three overlapping copies, and it cannot produce the horizontal band tearing that clip-path gives you.