Glitch Text Hover
A restrained RGB-split glitch: the word rests with a faint chromatic fringe, throws a micro-glitch every few seconds, and on hover, focus or tap shears into offset bands with separated red and cyan channels and a quick flicker.
DOM + CSS0 deps1 file · 8 KB TSXreduced-motion aware
Props
"use client";
/*
* GlitchText: a restrained RGB-split glitch for any word or line.
*
* Layers (all but the real text are aria-hidden copies): a red and a cyan
* channel behind the text, and two horizontal "tear" slices on top. At rest the
* channels sit a hair apart, so the word already carries a faint chromatic
* fringe. Every few seconds it throws one short micro-glitch; on hover, focus
* or tap it runs the full sequence: bands of the word shear sideways, the
* channels separate, and a frame or two flicker. Reduced motion skips the
* sequence and just opens the channels a little. Pure CSS keyframes, no deps.
*/
import { useEffect, useRef, type CSSProperties, type ReactNode } from "react";
/** Per keyframe: band 1 [top, bottom] %, shift 1, band 2 [top, bottom] %, shift 2, red x, cyan x, red y (px), text opacity.
* Shifts are multiples of the glitch distance. A shift of 0 hides that band. */
const FRAMES: number[][] = [
[50, 50, 0, 50, 50, 0, -1.2, 1.2, 0, 1],
[14, 30, 2.6, 50, 50, 0, -2.2, 2.4, 0, 1],
[14, 30, -1.8, 62, 74, 3.2, -1.4, 1, 1, 1],
[50, 50, 0, 50, 50, 0, 2, -2, 0, 0.88],
[38, 47, 4, 70, 88, -2.4, -3, 3, -1, 1],
[50, 50, 0, 50, 50, 0, -0.8, 0.8, 0, 1],
[8, 22, -3.2, 50, 50, 0, -2, 2, 0, 1],
[50, 50, 0, 56, 66, 2, 1.2, -1.2, 1, 1],
[50, 50, 0, 50, 50, 0, 0, 0, 0, 0.55],
[26, 40, 1.6, 72, 80, -3, -2.6, 2.6, 0, 1],
[50, 50, 0, 50, 50, 0, -1, 1, 0, 1],
[50, 50, 0, 50, 50, 0, -1.2, 1.2, 0, 1],
];
const D = "calc(var(--gth-d) * var(--gth-k, 1) * ";
const pct = (i: number) => +((i / FRAMES.length) * 100).toFixed(3);
const KEEP = "animation-timing-function:step-end;";
const hole = (a1: number, b1: number, a2: number, b2: number) =>
`polygon(-40% -20%,140% -20%,140% ${a1}%,-40% ${a1}%,-40% ${b1}%,140% ${b1}%,140% ${a2}%,-40% ${a2}%,-40% ${b2}%,140% ${b2}%,140% 120%,-40% 120%)`;
const sliceFrames = (name: string, band: 0 | 1) =>
`@keyframes ${name}{` +
FRAMES.map((f, i) => {
const a = f[band * 3] ?? 50;
const b = f[band * 3 + 1] ?? 50;
const x = f[band * 3 + 2] ?? 0;
return `${pct(i)}%{${KEEP}opacity:${x === 0 ? 0 : 1};clip-path:inset(${a}% -40% ${100 - b}% -40%);transform:translateX(${D}${x}));}`;
}).join("") +
"}";
const chanFrames = (name: string, col: 6 | 7) =>
`@keyframes ${name}{` +
FRAMES.map((f, i) => `${pct(i)}%{${KEEP}transform:translate(${D}${f[col] ?? 0}),${col === 6 ? f[8] ?? 0 : -(f[8] ?? 0)}px);}`).join("") +
"}";
const baseFrames =
"@keyframes gth-base{" +
FRAMES.map(
(f, i) => `${pct(i)}%{${KEEP}opacity:${f[9] ?? 1};clip-path:${hole(f[0] ?? 50, f[1] ?? 50, f[3] ?? 50, f[4] ?? 50)};}`,
).join("") +
"}";
const PLAY = "560ms linear infinite";
const CSS = [
".gth{position:relative;display:inline-block;isolation:isolate;--gth-d:max(var(--gth-i,3px),0.03em);--gth-rs:0.4;--gth-fx:360ms;border-radius:3px}",
".gth:focus-visible{outline:2px solid #3b82f6;outline-offset:6px;border-radius:4px}",
".gth-t{position:relative;z-index:1}",
".gth-l{position:absolute;inset:0;pointer-events:none;user-select:none;white-space:inherit}",
".gth-r,.gth-c{z-index:0;opacity:.9;will-change:transform}",
".gth-r{color:#ff2d55;transform:translate(calc(var(--gth-d) * var(--gth-rs) * -1),0)}",
".gth-c{color:#17d8ff;transform:translate(calc(var(--gth-d) * var(--gth-rs)),0)}",
".gth-s{z-index:2;opacity:0}",
".gth[data-tear] .gth-t{clip-path:" + hole(60, 68, 100, 100) + "}",
".gth[data-tear] .gth-s1{opacity:1;clip-path:inset(60% -40% 32% -40%);transform:translateX(calc(var(--gth-d) * 1.1))}",
".gth:hover .gth-t,.gth:focus-within .gth-t,.gth[data-on=true] .gth-t,.gth[data-fx] .gth-t{animation:gth-base " + PLAY + "}",
".gth:hover .gth-s1,.gth:focus-within .gth-s1,.gth[data-on=true] .gth-s1,.gth[data-fx] .gth-s1{animation:gth-s1 " + PLAY + "}",
".gth:hover .gth-s2,.gth:focus-within .gth-s2,.gth[data-on=true] .gth-s2,.gth[data-fx] .gth-s2{animation:gth-s2 " + PLAY + "}",
".gth:hover .gth-r,.gth:focus-within .gth-r,.gth[data-on=true] .gth-r,.gth[data-fx] .gth-r{animation:gth-r " + PLAY + "}",
".gth:hover .gth-c,.gth:focus-within .gth-c,.gth[data-on=true] .gth-c,.gth[data-fx] .gth-c{animation:gth-c " + PLAY + "}",
".gth[data-fx]:not(:hover):not(:focus-within):not([data-on=true]){--gth-k:.6}",
".gth[data-fx]:not(:hover):not(:focus-within):not([data-on=true]) .gth-l,.gth[data-fx]:not(:hover):not(:focus-within):not([data-on=true]) .gth-t{animation-duration:var(--gth-fx);animation-iteration-count:1}",
baseFrames,
sliceFrames("gth-s1", 0),
sliceFrames("gth-s2", 1),
chanFrames("gth-r", 6),
chanFrames("gth-c", 7),
"@media (prefers-reduced-motion:reduce){.gth-l,.gth-t{animation:none!important;transition:transform 200ms ease}.gth:hover .gth-r,.gth:focus-within .gth-r,.gth[data-on=true] .gth-r{transform:translate(calc(var(--gth-d) * -1.6),0)}.gth:hover .gth-c,.gth:focus-within .gth-c,.gth[data-on=true] .gth-c{transform:translate(calc(var(--gth-d) * 1.6),0)}.gth:hover .gth-s,.gth:focus-within .gth-s,.gth[data-on=true] .gth-s{opacity:0}}",
].join("");
const IDLE_MIN = 2400;
const IDLE_SPAN = 2800;
const IDLE_MS = 360;
export function GlitchText({
text = "GLITCH",
intensity = 3,
idle = true,
split = 0.4,
tear = false,
active,
focusable = false,
className = "",
}: {
text?: string;
/** Glitch distance in px (never less than 0.03em, so large type still reads as split). */
intensity?: number;
/** Throw a brief micro-glitch every few seconds while visible. */
idle?: boolean;
/** Resting channel separation as a fraction of the glitch distance (0 = none). */
split?: number;
/** Hold one thin sheared slice at rest. */
tear?: boolean;
/** Force the full glitch from outside (for example from a parent link). */
active?: boolean;
/** Make the word tab-focusable and glitch on keyboard focus. */
focusable?: boolean;
className?: string;
}): ReactNode {
const ref = useRef<HTMLSpanElement>(null);
const px = Number.isFinite(intensity) ? Math.min(24, Math.max(0, intensity)) : 3;
const rs = Number.isFinite(split) ? Math.min(2, Math.max(0, split)) : 0.4;
// Idle micro-glitch: one short pass at random intervals, only while on screen and the tab is visible.
useEffect(() => {
const el = ref.current;
if (!el || !idle) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let visible = true;
let timer = 0;
let off = 0;
const io = new IntersectionObserver((es) => {
visible = es.some((e) => e.isIntersecting);
});
io.observe(el);
const fire = () => {
if (visible && !document.hidden) {
el.setAttribute("data-fx", "");
off = window.setTimeout(() => el.removeAttribute("data-fx"), IDLE_MS + 40);
}
timer = window.setTimeout(fire, IDLE_MIN + Math.random() * IDLE_SPAN);
};
timer = window.setTimeout(fire, 900);
return () => {
io.disconnect();
window.clearTimeout(timer);
window.clearTimeout(off);
el.removeAttribute("data-fx");
};
}, [idle]);
// Touch has no hover: a tap throws the sequence once.
const tap = (e: { pointerType: string }) => {
const el = ref.current;
if (!el || e.pointerType === "mouse") return;
el.setAttribute("data-fx", "");
window.setTimeout(() => el.removeAttribute("data-fx"), 640);
};
return (
<span
ref={ref}
className={"gth font-black tracking-tight " + className}
data-on={active ? "true" : undefined}
data-tear={tear ? "" : undefined}
tabIndex={focusable ? 0 : undefined}
onPointerDown={tap}
style={{ "--gth-i": px + "px", "--gth-rs": rs } as CSSProperties}
>
<style>{CSS}</style>
<span aria-hidden="true" className="gth-l gth-r">
{text}
</span>
<span aria-hidden="true" className="gth-l gth-c">
{text}
</span>
<span className="gth-t">{text}</span>
<span aria-hidden="true" className="gth-l gth-s gth-s1">
{text}
</span>
<span aria-hidden="true" className="gth-l gth-s gth-s2">
{text}
</span>
</span>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this text effect
A glitch for a single word or line that stays readable. Behind the real text sit a red and a cyan copy, a hair apart at rest (a fraction of the glitch distance, `split`, default 0.4), so the type carries a chromatic fringe even in a still frame. On top sit two copies of the text clipped to horizontal bands. On hover, keyboard focus or tap the sequence runs: twelve hard-stepped frames over 560ms in which the bands slide sideways by up to four times the glitch distance, the channels swap sides and spread, and one frame dips to half opacity for the flicker, while the real text is cut out of the bands that are displaced so you never see doubled letters. Every few seconds, while the word is on screen and the tab is visible, `idle` throws one 360ms pass at 60 percent strength. `tear` holds one thin sheared slice at rest. Distances are `intensity` px but never less than 0.03em, so the effect scales with large display type. Touch devices, which have no hover, get the sequence on tap, and `focusable` makes the word tab-focusable with a visible ring; `active` forces the effect from outside, for example from a parent link. It is plain CSS keyframes plus one small effect for the idle timer, with no dependencies, no styled-jsx and no stylesheet to import. Text colour is inherited, so it works on light and dark pages; the channels are fixed red and cyan.
Curator’s note
Rebuilt in October so the word already looks glitched before anyone touches it: a faint channel fringe at rest, a short micro-glitch every few seconds, and a full shear on hover.

