"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface GlitchTextProps {
text: string;
as?: "h1" | "h2" | "h3" | "p" | "span";
/** When bursts fire. Hover/focus always triggers too. */
trigger?: "hover" | "inView" | "loop";
/** ms between bursts for trigger="loop". */
interval?: number;
/** Burst length in ms. */
duration?: number;
/** 0–1, how far slices jump. */
intensity?: number;
/** Swap a few glyphs during bursts. */
scramble?: boolean;
/** Faint RGB split at rest. */
restSplit?: boolean;
/** Scanline overlay. */
scanlines?: boolean;
colors?: [string, string];
className?: string;
}
const GLYPHS = "!<>-_\\/[]{}=+*^?#01";
export function GlitchText({
text,
as = "h2",
trigger = "inView",
interval = 3200,
duration = 650,
intensity = 0.6,
scramble = true,
restSplit = true,
scanlines = true,
colors = ["#ff2d6f", "#00d9ff"],
className,
}: GlitchTextProps) {
const reduce = useReducedMotion() ?? false;
const rootRef = React.useRef<HTMLElement>(null);
const aRef = React.useRef<HTMLSpanElement>(null);
const bRef = React.useRef<HTMLSpanElement>(null);
const sliceRef = React.useRef<HTMLSpanElement>(null);
const baseRef = React.useRef<HTMLSpanElement>(null);
const barRef = React.useRef<HTMLSpanElement>(null);
const running = React.useRef(false);
const timers = React.useRef<number[]>([]);
const [inView, setInView] = React.useState(false);
const reset = React.useCallback(() => {
const split = restSplit ? 1 : 0;
if (aRef.current) {
aRef.current.style.clipPath = "none";
aRef.current.style.transform = `translate(${-split}px,0)`;
aRef.current.textContent = text;
}
if (bRef.current) {
bRef.current.style.clipPath = "none";
bRef.current.style.transform = `translate(${split}px,0)`;
bRef.current.textContent = text;
}
if (sliceRef.current) {
sliceRef.current.style.opacity = "0";
sliceRef.current.textContent = text;
}
if (baseRef.current) baseRef.current.style.transform = "none";
if (barRef.current) barRef.current.style.opacity = "0";
running.current = false;
}, [restSplit, text]);
React.useEffect(() => {
reset();
}, [reset]);
const burst = React.useCallback(() => {
if (reduce || running.current) return;
running.current = true;
const amp = 4 + intensity * 14;
const start = performance.now();
const mess = (s: string) =>
s
.split("")
.map((ch) => (ch !== " " && Math.random() < 0.12 ? GLYPHS[Math.floor(Math.random() * GLYPHS.length)] : ch))
.join("");
const step = () => {
const t = performance.now() - start;
if (t > duration) return reset();
const band = () => {
const top = Math.random() * 90;
const h = 4 + Math.random() * 28;
return `inset(${top}% 0 ${Math.max(0, 100 - top - h)}% 0)`;
};
const rnd = () => (Math.random() - 0.5) * 2 * amp;
if (aRef.current) {
aRef.current.style.clipPath = band();
aRef.current.style.transform = `translate(${rnd()}px,${rnd() * 0.15}px)`;
if (scramble) aRef.current.textContent = mess(text);
}
if (bRef.current) {
bRef.current.style.clipPath = band();
bRef.current.style.transform = `translate(${rnd()}px,${rnd() * 0.15}px)`;
}
if (sliceRef.current) {
sliceRef.current.style.opacity = Math.random() < 0.6 ? "1" : "0";
sliceRef.current.style.clipPath = band();
sliceRef.current.style.transform = `translate(${rnd() * 1.4}px,0)`;
if (scramble) sliceRef.current.textContent = mess(text);
}
if (baseRef.current) baseRef.current.style.transform = Math.random() < 0.25 ? `translate(${rnd() * 0.3}px,0) skewX(${rnd() * 0.4}deg)` : "none";
if (barRef.current) {
barRef.current.style.opacity = "1";
barRef.current.style.top = `${(t / duration) * 100}%`;
}
timers.current = [window.setTimeout(step, 40 + Math.random() * 40)];
};
step();
}, [reduce, intensity, duration, scramble, text, reset]);
React.useEffect(() => {
const el = rootRef.current;
if (!el) return;
const io = new IntersectionObserver(([e]) => setInView(e.isIntersecting), { threshold: 0.4 });
io.observe(el);
return () => io.disconnect();
}, []);
React.useEffect(() => {
if (!inView) return;
if (trigger === "inView") {
const id = window.setTimeout(burst, 250);
return () => clearTimeout(id);
}
if (trigger === "loop") {
const id = window.setInterval(() => !document.hidden && burst(), interval);
const first = window.setTimeout(burst, 300);
return () => {
clearInterval(id);
clearTimeout(first);
};
}
}, [inView, trigger, interval, burst]);
React.useEffect(
() => () => {
timers.current.forEach(clearTimeout);
},
[],
);
const Tag = as;
const layer = "pointer-events-none absolute inset-0 whitespace-pre-wrap will-change-transform select-none";
return (
<Tag
ref={rootRef as React.Ref<HTMLHeadingElement>}
onMouseEnter={burst}
onFocus={burst}
className={cn("relative inline-block isolate", className)}
data-text={text}
>
<span className="relative block">
<span ref={baseRef} className="relative block">
{text}
</span>
<span ref={aRef} aria-hidden className={cn(layer, "mix-blend-multiply dark:mix-blend-screen")} style={{ color: colors[0], opacity: 0.85 }} />
<span ref={bRef} aria-hidden className={cn(layer, "mix-blend-multiply dark:mix-blend-screen")} style={{ color: colors[1], opacity: 0.85 }} />
<span ref={sliceRef} aria-hidden className={cn(layer, "bg-background text-foreground")} style={{ opacity: 0 }} />
{scanlines && (
// Lines in the page background colour: invisible on the background, visible through the glyphs.
<span
aria-hidden
className="pointer-events-none absolute inset-0 bg-[repeating-linear-gradient(0deg,transparent_0_3px,color-mix(in_oklch,var(--color-background)_18%,transparent)_3px_4px)]"
/>
)}
<span
ref={barRef}
aria-hidden
className="pointer-events-none absolute -inset-x-2 h-[3px] bg-gradient-to-r from-transparent via-cyan-400/70 to-transparent opacity-0 blur-[1px]"
style={{ top: 0 }}
/>
</span>
</Tag>
);
}