You find a sick cyberpunk UI kit on Dribbble, your gaming project needs that exact fractured-text look, and you spend three hours down a rabbit hole of CodePen demos — only to find every single one uses canvas, WebGL, or a wall of SVG filter markup you’d need a PhD to untangle. You just want the text to slice. Horizontally. Like a bad VHS tape. And you want it in pure CSS.
I’m Rohan Ratnayake, and I’ve spent the last five years as a front-end UI developer specializing in game interfaces and retro-themed web experiences. I’ve built dashboards for indie game studios, arcade-style landing pages, and once — I’m not proud of this — handed a client a “glitch effect” that was literally just a CSS text-shadow with two offset colors. They called it cute. I called it a wake-up call. That was the moment I sat down and figured out how to build the real thing without touching a single canvas element.
This tutorial gives you a system that works entirely with ::before, ::after, and clip-path. No SVG. No JavaScript. No canvas. Just CSS that you can drop into any project and understand line by line.
Why Most CSS Glitch Tutorials Give You Unmaintainable Garbage

Before the code, let me save you from the mistake I see constantly. Developers find a tutorial, copy 80 lines of @keyframes, paste it in, and it works — until they need to change the color, adjust the speed, or apply it to a different element. Then the whole thing falls apart because the values are hardcoded everywhere with no logic behind them.
The core of a proper glitch effect is two offset copies of the text, each showing a different horizontal slice of it at any given time. That’s the entire concept. Everything else is just controlling which slice shows and when it shifts.
Here’s what separates a realistic glitch from a cheap shimmer:
| Property | Cheap Shimmer | Real Glitch |
|---|---|---|
| Copies of text | 1 (original only) | 3 (original + 2 pseudo-elements) |
| Color offset | Same color, opacity change | Cyan/red channel split |
| Movement axis | Vertical fade | Horizontal translation |
| Visibility control | opacity | clip-path rectangle |
| Timing | Continuous loop | Irregular, stuttered keyframes |
That table tells you everything. If your glitch doesn’t use clip-path on ::before and ::after, it’s not slicing — it’s just blinking.
The HTML Setup (It’s Three Lines)
The entire technique runs off a single element. You don’t need a wrapper div, a span inside a span, or any extra markup.
<h1 class="glitch" data-text="SYSTEM ERROR">SYSTEM ERROR</h1>
The data-text attribute is the key piece. You’ll pull it into CSS using content: attr(data-text) on both pseudo-elements. This means the text lives in one place — your HTML — and the copies are generated by CSS. Change the text once, all three layers update.
Building the Two Pseudo-Element Layers
Here’s the base CSS. Set position: relative on the parent, and position: absolute on both pseudo-elements so they stack exactly on top of the original.
.glitch {
position: relative;
color: #e0e0e0;
font-size: 4rem;
font-family: 'Share Tech Mono', monospace;
letter-spacing: 0.05em;
}
.glitch::before,
.glitch::after {
content: attr(data-text);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.glitch::before {
color: #0ff; /* cyan channel */
}
.glitch::after {
color: #f0f; /* magenta channel */
}
At this point, you have three stacked copies of your text. They’re perfectly aligned, so it looks like one. Nothing moves yet. Now you add the slicing.
How clip-path Actually Cuts the Text

clip-path: inset() is what makes this work. It crops an element to a rectangular region without affecting layout. The syntax is:
clip-path: inset(top right bottom left);
You’re defining how much to cut away from each edge. So inset(30% 0 40% 0) shows only the horizontal band between 30% from the top and 40% from the bottom — a slice of the text, roughly in the middle.
According to MDN’s documentation on clip-path, the inset() function accepts percentage or absolute values and can be animated smoothly in CSS, which is exactly what we’re doing here.
The critical rule: ::before and ::after should never show the same slice at the same time. If they do, the effect looks like a color shift, not a glitch. You want them to cut different bands of the text, briefly, and at slightly offset moments.
Writing the Keyframes: Timing Is Everything
This is where most tutorials hand you 60 keyframe stops and tell you to trust them. I’m going to show you the structure so you can write your own.
A good glitch animation has three phases:
- Idle — Most of the animation, no effect visible
- Glitch burst — 2–4 frames of rapid slicing
- Reset — Back to idle
Here’s the ::before animation. Notice 90% of the keyframes are clip-path: none:
@keyframes glitch-top {
0%, 20%, 100% {
clip-path: none;
transform: translate(0);
}
21% {
clip-path: inset(0 0 75% 0);
transform: translate(-3px, 0);
}
22% {
clip-path: inset(0 0 60% 0);
transform: translate(3px, 0);
}
23% {
clip-path: inset(0 0 80% 0);
transform: translate(-2px, 0);
}
24%, 99% {
clip-path: none;
transform: translate(0);
}
}
And for ::after, you shift the clip region down so it cuts the bottom half of the text instead:
@keyframes glitch-bottom {
0%, 45%, 100% {
clip-path: none;
transform: translate(0);
}
46% {
clip-path: inset(60% 0 0 0);
transform: translate(4px, 0);
}
47% {
clip-path: inset(70% 0 0 0);
transform: translate(-4px, 0);
}
48% {
clip-path: inset(55% 0 0 0);
transform: translate(2px, 0);
}
49%, 99% {
clip-path: none;
transform: translate(0);
}
}
Apply them:
.glitch::before {
animation: glitch-top 4s infinite linear;
}
.glitch::after {
animation: glitch-bottom 4s infinite linear;
animation-delay: 0.3s;
}
The animation-delay: 0.3s on ::after is not decorative. It’s what prevents both layers from glitching at the exact same moment, which would make the effect look synchronized and fake. Real signal corruption is asynchronous. Your glitch should be too.
Tuning the Effect to Your Project’s Tone

Not every project needs the same intensity. A horror game needs something slow and unsettling. A fast-paced arcade game wants rapid stuttering. Here’s how to adjust the parameters:
| Project Type | Animation Duration | Glitch Burst Width | Translate Range | Color Pair |
|---|---|---|---|---|
| Horror / Atmospheric | 6–8s | 10–20% of height | ±1–2px | Deep red / dark teal |
| Arcade / Action | 2–3s | 20–40% of height | ±4–6px | Cyan / magenta |
| Retro Terminal | 5s | 5–15% of height | ±1px | Green / darker green |
| Lo-fi / Vaporwave | 7s | 15–30% of height | ±3px | Purple / pink |
One thing I’d push back on from every tutorial I’ve read: don’t make your glitch loop too fast. I tested this on users for a gaming client — a 2s loop felt seizure-inducing after 10 seconds of reading. A 4–5s loop with a short burst feels like interference. It feels like something is wrong, which is exactly what you want.
Three Things That Will Break Your Effect
I’ve debugged this setup enough times that I can tell you exactly what goes wrong:
overflow: hiddenon a parent element will clip your pseudo-elements even beforeclip-pathdoes, cutting off anytransform: translatethat pushes outside the parent’s box. Check your parent containers.- Forgetting
height: 100%on pseudo-elements causes theclip-pathpercentages to calculate from a collapsed height of 0, so nothing shows. Always setwidth: 100%andheight: 100%explicitly. - Using
will-change: transformon the parent can cause the browser to create a new compositing layer that breaksposition: absolutestacking on the pseudo-elements in some Chromium versions. Putwill-changeon the pseudo-elements themselves if you need it.
FAQs
Can I apply this to an image or a button instead of a heading? Yes, with one adjustment. For non-text elements, replace content: attr(data-text) with content: '' and use background: inherit on both pseudo-elements so they mirror the parent’s background. The clip-path logic stays identical.
Why does the effect disappear in Safari? clip-path: inset() is fully supported in modern Safari, but will-change: clip-path can cause rendering bugs in older versions. Remove will-change from the pseudo-elements and re-test. If you still see issues, add -webkit-clip-path as a duplicate property above clip-path.
How do I make the glitch trigger on hover instead of running automatically? Set animation-play-state: paused on .glitch::before and .glitch::after by default, then set it to running on .glitch:hover::before and .glitch:hover::after. The animation will start exactly where it paused when the user hovers off, so you should also reset it by adding animation: none on the non-hover state and reapplying it on hover — this forces a restart each time.
Conclusion
The whole technique comes down to this: three stacked copies of your text, two of them clipped to different horizontal bands, offset in color and translation, animating at slightly different times. That’s it. You don’t need SVG filters. You don’t need canvas. You just need ::before, ::after, and clip-path: inset() working together with keyframes that are mostly off.
The next step is to take the timing table in this article, pick the profile that matches your project’s tone, and build one animation from scratch — don’t copy mine. Writing the keyframes by hand is how you internalize what each stop is doing, and that’s the difference between a developer who can build the effect and one who can fix it when it breaks in production.

