Glitch Effect
Two copies of the label, sliced into bands by clip-path, jump ±3 px with magenta and cyan ghosts — a permanent glitch in pure CSS.
You'll get access to the interactive demo with a free account.
This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Buttons has 27 effects, including 2 free. Explore the category →
3 usage examples



How it works
.btn-glitch::before and ::after re-create the label with content: attr(data-text) — the button's data-text attribute must carry the same text as its content. Both pseudo-elements are position: absolute; top: 0; width: 100%; height: 100%, centered with display: flex, and share the button's #6366f1 background: wherever a band is visible, it fully covers the original text.
Each copy shows a single horizontal band. ::before is offset left: 2px with a magenta ghost (text-shadow: -2px 0 #f0f) and animated by glitchTop (1.4 s); ::after is offset left: -2px with a cyan ghost (text-shadow: 2px 0 #0ff) and animated by glitchBottom (1.7 s). Each keyframe's clip-path: inset(top 0 bottom) cuts a band between 22% and 66% of the height — where the text sits.
steps(1, end) is what gives the effect its character: no interpolation between keyframes. Every 20% of the cycle (280 ms for the top band, 340 ms for the bottom one), the band jumps to a new position and a new translateX between −3 and +3 px. The two durations share no divisor: the pattern only repeats every 23.8 s and looks random.
overflow: hidden on .demo-btn clips the ±3 px overflow at the edges, and position: relative on .btn-glitch anchors the pseudo-elements. The animation runs from load, with no hover: the button is never still.
Accessibility
- prefers-reduced-motion not handled: a permanent movement in the field of view. Add
@media (prefers-reduced-motion: reduce) { .btn-glitch::before, .btn-glitch::after { display: none } }— what remains is an ordinary indigo button with a perfectly readable label. - Screen readers:
content: attr(data-text)is generated content, which Chrome, Firefox and Safari include in a button's accessible name — the label may be announced three times (“Play Play Play”). Setaria-labelon the button with the visible text: it becomes the accessible name and the pseudo-elements are ignored. - Readability: at any instant one band shifts part of the glyphs by up to 3 px and adds colored ghosts. Fine for a short label (one or two words, 16 px, weight 600); avoid it on long text or below 14 px.
- Contrast: white text on
#6366f1= 4.47:1, just under the 4.5:1 AA threshold; the magenta and cyan ghosts further degrade local legibility.#4f46e5gives 6.3:1 — change it in all three places (button and both pseudo-elements). - Flicker: the bands change 3.6 and 2.9 times per second, but the moving area is a few pixels tall on a small element — far from the full-screen flashes WCAG 2.3.1 targets. The reduced-motion rule above remains the right safeguard for motion-sensitive users. Native focus ring preserved (no
outline: none).
Browser compatibility
Requires unprefixed clip-path: inset(), content: attr(), the steps() timing function and text-shadow. No JavaScript, zero dependencies.
Without clip-path, both copies render whole on top of each other: the text stays readable but moves as a block by ±3 px with its colored shadows. Duplicate the declarations as -webkit-clip-path for Safari 9.1 to 13.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
| data-text (HTML — button attribute) | same as the label | Text of both copies. Must stay equal to the visible label; in a framework, bind both to the same variable. |
| animation-duration (CSS — ::before 1.4s / ::after 1.7s) | 1.4s / 1.7s | Jump speed (five per cycle). Keep two durations with no common divisor to avoid a visible pattern; 0.8s / 1.1s = frantic. |
| translateX (CSS — @keyframes glitchTop / glitchBottom) | −3 to +3 px | Horizontal shake amplitude. ±6 px visibly tears the text; ±1 px gives a shiver. |
| clip-path: inset(…) (CSS — each keyframe) | bands between 22% and 66% of the height | Position and thickness of the slices. They target the text zone (≈ 30–70% of the height with the default padding) — readjust if you change the padding. |
| text-shadow (CSS — ::before #f0f / ::after #0ff) | −2px 0 #f0f / 2px 0 #0ff | Chromatic aberration. A single channel (#f0f only) is more restrained; 0 0 6px instead of the offset gives a halo rather than a double image. |
| left: 2px / -2px (CSS — ::before / ::after) | ±2px | Base offset of the copies from the original text. At 0, the copies differ only by their shadows. |
| steps(1, end) (CSS — animation-timing-function) | steps(1, end) | Jump with no interpolation. steps(2, end) adds an intermediate position; linear turns the glitch into a continuous slide — no longer a glitch. |
FAQ
.btn-glitch::before, .btn-glitch::after { display: none }. Show them again with their animations under .btn-glitch:hover::before and .btn-glitch:hover::after (display: flex plus each one's animation declaration). The animation restarts from its 0% on every hover.data-text, not the button's content. If you change the label (translation, a “Loading…” state), update the attribute at the same time: btn.dataset.text = btn.textContent. A missing attribute yields empty bands: the text then looks punched through.position: relative turns the position: absolute ::before/::after into layers exactly superimposed on the button (top: 0; width/height: 100%). overflow: hidden clips what sticks out when the copies slide by ±3 px and because of the left: ±2px — without it, colored edges appear outside the button.