A restrained RGB-split glitch with clip-path scanline slices on hover or a slow loop.
npx shadcn@latest add @paragon/glitch-text"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface GlitchTextProps
extends Omit<React.ComponentProps<"span">, "children"> {
/** The text to render. */
children: React.ReactNode;
/** Channel-split displacement in px at peak. */
intensity?: number;
/** Cyan channel color (offset one way). */
colorA?: string;
/** Magenta/red channel color (offset the other way). */
colorB?: string;
/**
* `hover` — glitch while hovered (default).
* `loop` — a brief glitch burst on a slow interval.
*/
trigger?: "hover" | "loop";
/** Render plain text with no glitch. */
static?: boolean;
}
/**
* GlitchText — a restrained RGB-split glitch. Two colored ghost copies of the
* text (cyan and magenta) sit exactly behind the real text; on trigger they
* jitter apart on offset channels while horizontal `clip-path` slices flicker
* across them, so the word shears into scanline slivers for a beat and snaps
* back. Tuned for a tasteful enterprise flicker, not a chaotic mess.
*
* The channel ghosts are `aria-hidden`; the real, legible text stays on top and
* in the DOM. All motion is `transform` and `clip-path` on the ghosts.
* `hover` glitches while hovered; `loop` bursts on a slow interval and pauses
* offscreen. Reduced motion (or `static`) renders the plain word.
*/
export function GlitchText({
children,
intensity = 3,
colorA = "#22d3ee",
colorB = "#f43f5e",
trigger = "hover",
static: isStatic = false,
className,
style,
...props
}: GlitchTextProps) {
const id = React.useId().replace(/[:]/g, "");
const reducedMotion = useReducedMotion() ?? false;
const hostRef = React.useRef<HTMLSpanElement>(null);
const [hovered, setHovered] = React.useState(false);
const [bursting, setBursting] = React.useState(false);
const [visible, setVisible] = React.useState(true);
const animated = !isStatic && !reducedMotion;
const isLoop = trigger === "loop";
// Loop: pulse a short glitch burst on a slow interval, paused offscreen.
React.useEffect(() => {
const el = hostRef.current;
if (!el || !animated || !isLoop) return;
const io = new IntersectionObserver(
([e]) => setVisible(e.isIntersecting),
{ threshold: 0 },
);
io.observe(el);
return () => io.disconnect();
}, [animated, isLoop]);
React.useEffect(() => {
if (!animated || !isLoop || !visible) return;
let off: ReturnType<typeof setTimeout>;
const iv = setInterval(() => {
setBursting(true);
off = setTimeout(() => setBursting(false), 520);
}, 3200);
return () => {
clearInterval(iv);
clearTimeout(off);
};
}, [animated, isLoop, visible]);
const on = isLoop ? bursting : hovered;
const keyframes = `@keyframes glitch-a-${id} {
0%,100% { transform: translate(0,0); clip-path: inset(0 0 0 0); }
20% { transform: translate(${-intensity}px, ${intensity * 0.4}px); clip-path: inset(12% 0 58% 0); }
40% { transform: translate(${intensity * 0.6}px, ${-intensity * 0.3}px); clip-path: inset(64% 0 8% 0); }
60% { transform: translate(${-intensity * 0.8}px, 0); clip-path: inset(38% 0 40% 0); }
80% { transform: translate(${intensity * 0.5}px, ${intensity * 0.2}px); clip-path: inset(80% 0 4% 0); }
}
@keyframes glitch-b-${id} {
0%,100% { transform: translate(0,0); clip-path: inset(0 0 0 0); }
20% { transform: translate(${intensity}px, ${-intensity * 0.4}px); clip-path: inset(70% 0 12% 0); }
40% { transform: translate(${-intensity * 0.6}px, ${intensity * 0.3}px); clip-path: inset(6% 0 72% 0); }
60% { transform: translate(${intensity * 0.8}px, 0); clip-path: inset(46% 0 30% 0); }
80% { transform: translate(${-intensity * 0.5}px, ${-intensity * 0.2}px); clip-path: inset(20% 0 66% 0); }
}
@media (prefers-reduced-motion: reduce) {
.glitch-${id} [data-ghost] { animation: none !important; opacity: 0 !important; }
}`;
if (isStatic || reducedMotion) {
return (
<span
data-slot="glitch-text"
className={cn("inline-block", className)}
style={style}
{...props}
>
{children}
</span>
);
}
const ghostBase: React.CSSProperties = {
position: "absolute",
inset: 0,
pointerEvents: "none",
animationDuration: "520ms",
animationTimingFunction: "steps(2, end)",
animationIterationCount: isLoop ? 1 : "infinite",
animationPlayState: on ? "running" : "paused",
opacity: on ? 0.75 : 0,
transition: "opacity 120ms var(--ease-out)",
willChange: "transform, clip-path",
};
return (
<>
<style href={`paragon-glitch-text-${id}`} precedence="paragon">
{keyframes}
</style>
<span
ref={hostRef}
data-slot="glitch-text"
className={cn(`glitch-${id} relative inline-block`, className)}
style={style}
onPointerEnter={isLoop ? undefined : () => setHovered(true)}
onPointerLeave={isLoop ? undefined : () => setHovered(false)}
{...props}
>
<span
aria-hidden
data-ghost
style={{
...ghostBase,
color: colorA,
animationName: on ? `glitch-a-${id}` : undefined,
}}
>
{children}
</span>
<span
aria-hidden
data-ghost
style={{
...ghostBase,
color: colorB,
animationName: on ? `glitch-b-${id}` : undefined,
}}
>
{children}
</span>
<span className="relative">{children}</span>
</span>
</>
);
}