"use client";
import * as React from "react";
export type GlitchTrigger = "hover" | "always" | "interval";
export interface GlitchTextProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
/** The text to render. */
text?: string;
/** Element to render. */
as?: "span" | "p" | "h1" | "h2" | "h3" | "div";
/** Strength of the split and slice offsets, 0–1. */
intensity?: number;
/** When bursts happen: while hovered/focused, continuously at random, or on a fixed interval. */
trigger?: GlitchTrigger;
/** Colour of the first split channel (shifted left). */
colorA?: string;
/** Colour of the second split channel (shifted right). */
colorB?: string;
/** Length of one glitch burst, in ms. */
duration?: number;
/** Time between bursts for `trigger="interval"`, in ms (never below the flash cap). */
interval?: number;
}
/* --------------------------- reduced-motion hook --------------------------- */
const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";
function subscribeReducedMotion(onChange: () => void) {
const mq = window.matchMedia(REDUCED_QUERY);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia(REDUCED_QUERY).matches,
() => false,
);
}
/* ------------------------------ flash safety ------------------------------ */
/**
* Photosensitivity guard. A burst (colour layers appearing, then disappearing) counts as one
* flash, and bursts never start less than 1000/3 ms apart — at most three per second, inside
* the WCAG 2.3.1 "three flashes" threshold. Within a burst the slices move every FRAME_MS but
* stay visible, and only thin bands of the glyphs change.
*/
const MIN_BURST_GAP = Math.ceil(1000 / 3);
const FRAME_MS = 70;
const rand = (min: number, max: number) => min + Math.random() * (max - min);
/** A random horizontal band of the text, as an inset() clip path. */
function slice(intensity: number) {
const height = rand(6, 18 + 22 * intensity);
const top = rand(0, 100 - height);
return `inset(${top.toFixed(1)}% 0 ${(100 - top - height).toFixed(1)}% 0)`;
}
/* -------------------------------- component -------------------------------- */
/**
* Text with a tasteful RGB-split glitch. Two aria-hidden copies in the channel colours sit behind
* the real text; during a burst they jump sideways in clipped horizontal slices while the main
* text jitters slightly. Bursts are capped at three per second and the effect is off entirely
* with reduced motion.
*/
export function GlitchText({
text = "Signal lost",
as = "span",
intensity = 0.5,
trigger = "hover",
colorA = "#FF7448",
colorB = "#2BB3C0",
duration = 420,
interval = 2400,
className = "",
style,
onPointerEnter,
onPointerLeave,
onFocus,
onBlur,
...props
}: GlitchTextProps) {
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLElement>(null);
const mainRef = React.useRef<HTMLSpanElement>(null);
const aRef = React.useRef<HTMLSpanElement>(null);
const bRef = React.useRef<HTMLSpanElement>(null);
const hoverRef = React.useRef<((active: boolean) => void) | null>(null);
const Tag = as as React.ElementType;
React.useEffect(() => {
const root = rootRef.current;
const main = mainRef.current;
const a = aRef.current;
const b = bRef.current;
if (reduceMotion || !root || !main || !a || !b) return;
const k = Math.min(1, Math.max(0, intensity));
const burstLength = Math.max(FRAME_MS, duration);
let timer = 0;
let bursting = false;
let lastStart = -Infinity;
let hovering = false;
let visible = true;
const clear = () => {
a.style.opacity = "0";
b.style.opacity = "0";
main.style.transform = "";
};
const frame = () => {
// Offsets scale with the font size so the effect looks the same at any size.
const em = parseFloat(getComputedStyle(root).fontSize) || 16;
const shift = em * (0.03 + 0.09 * k);
a.style.opacity = "1";
b.style.opacity = "1";
a.style.clipPath = slice(k);
b.style.clipPath = slice(k);
a.style.transform = `translate3d(${-rand(0.4, 1) * shift}px, 0, 0)`;
b.style.transform = `translate3d(${rand(0.4, 1) * shift}px, 0, 0)`;
main.style.transform = Math.random() < 0.35 * k ? `translate3d(${rand(-0.25, 0.25) * shift}px, 0, 0)` : "";
};
const canRun = () => visible && !document.hidden;
const nextGap = () => {
if (trigger === "interval") return Math.max(MIN_BURST_GAP, interval);
if (trigger === "always") return rand(500, 2200) * (1.4 - 0.6 * k);
return 650; // hover: repeat while the pointer stays
};
const schedule = (delay: number) => {
window.clearTimeout(timer);
timer = window.setTimeout(burst, Math.max(0, delay));
};
function burst() {
if (!canRun() || (trigger === "hover" && !hovering)) return;
const now = performance.now();
// Flash cap: never start a burst sooner than MIN_BURST_GAP after the previous one.
if (now - lastStart < MIN_BURST_GAP) {
schedule(MIN_BURST_GAP - (now - lastStart));
return;
}
lastStart = now;
bursting = true;
const end = now + burstLength;
const step = () => {
if (!canRun() || performance.now() >= end) {
bursting = false;
clear();
if (trigger !== "hover" || hovering) schedule(nextGap());
return;
}
frame();
timer = window.setTimeout(step, FRAME_MS);
};
step();
}
hoverRef.current = (active) => {
if (trigger !== "hover") return;
hovering = active;
if (active && !bursting) schedule(0);
};
const resume = () => {
window.clearTimeout(timer);
bursting = false;
clear();
if (!canRun()) return;
if (trigger === "always") schedule(rand(200, 900));
else if (trigger === "interval") schedule(Math.max(MIN_BURST_GAP, interval));
else if (hovering) schedule(0);
};
const io = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
resume();
});
io.observe(root);
document.addEventListener("visibilitychange", resume);
// Inside a link or button, keyboard focus on that control also triggers the hover glitch.
const host = root.parentElement?.closest<HTMLElement>("a[href], button, [tabindex]") ?? null;
const onHostFocus = () => hoverRef.current?.(true);
const onHostBlur = () => hoverRef.current?.(false);
host?.addEventListener("focus", onHostFocus);
host?.addEventListener("blur", onHostBlur);
return () => {
window.clearTimeout(timer);
io.disconnect();
document.removeEventListener("visibilitychange", resume);
host?.removeEventListener("focus", onHostFocus);
host?.removeEventListener("blur", onHostBlur);
hoverRef.current = null;
clear();
};
}, [reduceMotion, intensity, trigger, duration, interval]);
const layer =
"pointer-events-none absolute inset-0 -z-10 whitespace-pre-wrap opacity-0 select-none [clip-path:inset(50%_0_50%_0)]";
return (
<Tag
ref={rootRef}
className={`relative isolate inline-block ${className}`}
style={{ ["--gt-a" as string]: colorA, ["--gt-b" as string]: colorB, ...style }}
onPointerEnter={(event: React.PointerEvent<HTMLElement>) => {
onPointerEnter?.(event);
hoverRef.current?.(true);
}}
onPointerLeave={(event: React.PointerEvent<HTMLElement>) => {
onPointerLeave?.(event);
hoverRef.current?.(false);
}}
onFocus={(event: React.FocusEvent<HTMLElement>) => {
onFocus?.(event);
hoverRef.current?.(true);
}}
onBlur={(event: React.FocusEvent<HTMLElement>) => {
onBlur?.(event);
hoverRef.current?.(false);
}}
{...props}
>
<span ref={mainRef} className="relative inline-block whitespace-pre-wrap">
{text}
</span>
<span ref={aRef} aria-hidden="true" className={`${layer} text-[var(--gt-a)]`}>
{text}
</span>
<span ref={bRef} aria-hidden="true" className={`${layer} text-[var(--gt-b)]`}>
{text}
</span>
</Tag>
);
}