Glitch Text
On hover the word tears into offset slices of colour for a moment, like a signal breaking up, then snaps back clean.
Create a glitch text hover effect in pure CSS. Put the word in an element with data-text set to the same string, and add ::before and ::after with content: attr(data-text) stacked exactly on top. Colour one layer with the accent and the other with a cool light tone, and keep them invisible at rest. On :hover and :focus-visible, show both layers and run a 600ms steps(1) keyframe on each that jumps clip-path: inset(top% 0 bottom% 0) between four or five horizontal slices while shifting translate by a few pixels left and right, with the two layers out of phase. Keep the real text readable underneath. Put the word in a visually hidden span for screen readers and mark the glitching element aria-hidden. Under prefers-reduced-motion, show a static two-colour offset instead. The word reads "SIGNAL LOST".
Source
'use client'
import s from './demo.module.css'
export default function GlitchText({ labels }: { labels: Record<string, string> }) {
return (
<span className={s.wrap} tabIndex={0}>
<span className={s.sr}>{labels.text}</span>
<span className={s.glitch} data-text={labels.text} aria-hidden="true">
{labels.text}
</span>
</span>
)
}
.wrap {
border-radius: 6px;
outline: none;
cursor: default;
}
.wrap:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 6px; }
.sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.glitch {
position: relative;
display: inline-block;
font: 800 clamp(2rem, 7vw, 2.6rem) / 1 var(--font-display);
letter-spacing: 0.04em;
color: var(--stage-ink);
}
.glitch::before,
.glitch::after {
content: attr(data-text);
position: absolute;
inset: 0;
opacity: 0;
}
.glitch::before { color: var(--stage-accent); }
.glitch::after { color: oklch(80% 0.1 220); }
.wrap:hover .glitch::before,
.wrap:focus-visible .glitch::before { opacity: 1; animation: slice-a 600ms steps(1) infinite; }
.wrap:hover .glitch::after,
.wrap:focus-visible .glitch::after { opacity: 1; animation: slice-b 600ms steps(1) infinite; }
@keyframes slice-a {
0% { clip-path: inset(10% 0 70% 0); translate: -4px 0; }
20% { clip-path: inset(55% 0 25% 0); translate: 3px 0; }
40% { clip-path: inset(30% 0 50% 0); translate: -2px 0; }
60% { clip-path: inset(80% 0 5% 0); translate: 4px 0; }
80% { clip-path: inset(0 0 85% 0); translate: -3px 0; }
}
@keyframes slice-b {
0% { clip-path: inset(65% 0 15% 0); translate: 3px 0; }
20% { clip-path: inset(5% 0 80% 0); translate: -4px 0; }
40% { clip-path: inset(40% 0 40% 0); translate: 2px 0; }
60% { clip-path: inset(20% 0 65% 0); translate: -3px 0; }
80% { clip-path: inset(75% 0 10% 0); translate: 4px 0; }
}
@media (prefers-reduced-motion: reduce) {
.wrap:hover .glitch::before,
.wrap:hover .glitch::after,
.wrap:focus-visible .glitch::before,
.wrap:focus-visible .glitch::after { animation: none; clip-path: inset(0 0 50% 0); }
.wrap:hover .glitch::before, .wrap:focus-visible .glitch::before { translate: -2px 0; }
.wrap:hover .glitch::after, .wrap:focus-visible .glitch::after { clip-path: inset(50% 0 0 0); translate: 2px 0; }
}