Red and cyan ghosts jitter apart before snapping back together.
export function glitch(el) {
const _reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const _duration = _reducedMotion ? 0.01 : 0.6
const _delay = _reducedMotion ? 0 : 0
const text = el.textContent ?? ''
el.setAttribute('aria-label', text)
el.style.position = 'relative'
const clones = [0, 1].map((i) => {
const clone = document.createElement('span')
clone.textContent = text
clone.className = i ? 'k-glitch-cyan' : 'k-glitch-red'
clone.setAttribute('aria-hidden', 'true')
el.append(clone)
return clone
})
const easing = 'steps(6)'
const legMs = (_duration / 6) * 1000
const delayMs = _delay * 1000
// 6 alternating legs, matching GSAP's repeat: 5, yoyo: true — an even leg
// count lands back on the "from" keyframe, so both clones settle neutral
// without needing an explicit reset tween at the end.
const anim0 = clones[0].animate(
[
{ transform: 'translateX(0px)', clipPath: 'inset(0% 0 0% 0)' },
{ transform: 'translateX(-6px)', clipPath: 'inset(10% 0 40% 0)' },
],
{ duration: legMs, delay: delayMs, iterations: 6, direction: 'alternate', easing, fill: 'both' },
)
const anim1 = clones[1].animate(
[
{ transform: 'translateX(0px)', clipPath: 'inset(0% 0 0% 0)' },
{ transform: 'translateX(6px)', clipPath: 'inset(40% 0 10% 0)' },
],
{ duration: legMs, delay: delayMs, iterations: 6, direction: 'alternate', easing, fill: 'both' },
)
return () => {
anim0.cancel()
anim1.cancel()
clones.forEach((c) => c.remove())
}
}
/* CSS */
.k-glitch-red,
.k-glitch-cyan {
position: absolute;
inset-inline-start: 0;
inset-block-start: 0;
pointer-events: none;
}
.k-glitch-red {
color: #ff004c;
mix-blend-mode: screen;
}
.k-glitch-cyan {
color: #00e5ff;
mix-blend-mode: screen;
}