experimental
Glitch Text
A span-level display primitive that composites text across three layers — a base plus hue-rotated red and cyan channel copies — for a permanent sub-pixel chromatic aberration at rest. On hover (or inView/loop) it fires a single-shot clip-path slice burst that carves staggered, per-instance-seeded horizontal bands through the text, mimicking a corrupted signal, then snaps clean. Single-shot by design: premium, not a looping novelty. Polymorphic, hydration-safe, and fully reduced-motion aware.
SIGNAL LOST
Install
1. Register the namespace (once per project):
json
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
bash
npx shadcn add @bottega/glitch-textUsage
Usagetsx
<GlitchText>
{/* children */}
</GlitchText>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| children* | ReactNode | — | Text content — string or inline elements for best channel-copy fidelity. |
| as | enum | span | Polymorphic root element. Defaults to "span" for inline use. |
| trigger | enum | hover | What fires the glitch burst. Defaults to "hover". |
| intensity | enum | medium | Channel offset distance + band count. Defaults to "medium". |
| speed | enum | normal | Burst duration multiplier. Defaults to "normal". |
| color | enum | default | Base text color token. Defaults to "default". |
| className | string | Layout/spacing passthrough. | |
| aria-label | string | — | Accessible label override; channel copies become aria-hidden. |
Source
glitch-text.tsxtsx
"use client";
import {
createElement,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import { useReducedMotion } from "motion/react";
import styles from "./glitch-text.module.css";
type GlitchTag = "span" | "p" | "h1" | "h2" | "h3" | "div";
export interface GlitchTextProps {
/** Text content — string or inline elements for best channel-copy fidelity. */
children: ReactNode;
/** Polymorphic root element. Defaults to "span" for inline use. */
as?: GlitchTag;
/** What fires the glitch burst. Defaults to "hover". */
trigger?: "hover" | "loop" | "inView" | "none";
/** Channel offset distance + band count. Defaults to "medium". */
intensity?: "subtle" | "medium" | "heavy";
/** Burst duration multiplier. Defaults to "normal". */
speed?: "slow" | "normal" | "fast";
/** Base text color token. Defaults to "default". */
color?: "default" | "accent" | "muted";
/** Layout/spacing passthrough. */
className?: string;
/** Accessible label override; channel copies become aria-hidden. */
"aria-label"?: string;
}
const INTENSITY = {
subtle: { offset: 2, drift: 0, bands: 3 },
medium: { offset: 4, drift: 0, bands: 5 },
heavy: { offset: 7, drift: 2, bands: 8 },
} as const;
// Motion timings here are CSS keyframe durations (the burst), authored as
// seconds — distinct from the --duration token used for UI transitions.
const SPEED = { slow: "0.6s", normal: "0.4s", fast: "0.2s" } as const;
const COLOR_CLASS = {
default: "text-foreground",
accent: "text-[var(--accent)]",
muted: "text-[var(--muted)]",
} as const;
/**
* Deterministic string hash → 32-bit int. Lets us seed the per-instance band
* pattern WITHOUT Math.random(), so server and client first paint are identical
* (hydration-safe) while different instances still get distinct patterns.
*/
function hashSeed(input: string): number {
let h = 2166136261;
for (let i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
/** Small seeded PRNG (mulberry32) — deterministic per instance. */
function makeRng(seed: number): () => number {
let a = seed;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Build N deterministic horizontal-band clip-path strings from a seed. */
function seededBands(seed: number, count: number): string[] {
const rng = makeRng(seed);
const bands: string[] = [];
for (let i = 0; i < count; i++) {
const top = Math.round(rng() * 70); // 0–70%
const bottom = Math.round(rng() * (90 - top)); // leave a visible slice
bands.push(`inset(${top}% 0 ${bottom}% 0)`);
}
return bands;
}
export function GlitchText({
children,
as = "span",
trigger = "hover",
intensity = "medium",
speed = "normal",
color = "default",
className = "",
"aria-label": ariaLabel,
}: GlitchTextProps) {
const Tag = as;
const reactId = useId();
const rootRef = useRef<HTMLElement | null>(null);
const setRootRef = useCallback((node: HTMLElement | null) => {
rootRef.current = node;
}, []);
// Mount-gate (contract): server + first client render are byte-identical and
// static; animation only turns on after hydration.
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced
const animate = mounted && !reduce;
// Single-shot burst state (hover / inView). Toggled off after one run.
const [bursting, setBursting] = useState(false);
// Reduced-motion one-frame flash state.
const [flash, setFlash] = useState(false);
const burstTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const cfg = INTENSITY[intensity];
const burstDuration = SPEED[speed];
// Seed is derived deterministically from stable inputs — no randomness at
// paint time, identical on server and client.
const seed = useMemo(
() => hashSeed(`${reactId}:${typeof children === "string" ? children : "node"}:${intensity}`),
[reactId, children, intensity],
);
const bands = useMemo(() => seededBands(seed, cfg.bands), [seed, cfg.bands]);
// Map first four seeded bands onto the CSS band slots the keyframes consume.
const styleVars = useMemo<CSSProperties>(
() =>
({
"--gt-offset": `${cfg.offset}px`,
"--gt-drift": `${cfg.drift}px`,
"--gt-burst": burstDuration,
"--gt-band-a": bands[0] ?? "inset(45% 0 35% 0)",
"--gt-band-b": bands[1] ?? "inset(20% 0 65% 0)",
"--gt-band-c": bands[2 % bands.length] ?? "inset(70% 0 12% 0)",
"--gt-band-d": bands[3 % bands.length] ?? "inset(8% 0 80% 0)",
}) as CSSProperties,
[cfg.offset, cfg.drift, burstDuration, bands],
);
const fireBurst = useCallback(() => {
if (burstTimer.current) return; // already running → single-shot restraint
setBursting(true);
// Snap clean once the CSS keyframe completes.
const ms = parseFloat(burstDuration) * 1000 + 40;
burstTimer.current = setTimeout(() => {
setBursting(false);
burstTimer.current = null;
}, ms);
}, [burstDuration]);
const fireFlash = useCallback(() => {
// Reduced-motion path: a single 16ms color flash, never a loop.
setFlash(true);
const t = setTimeout(() => setFlash(false), 16);
return () => clearTimeout(t);
}, []);
// inView trigger: fire once on intersection, then disconnect (no re-fire).
useEffect(() => {
if (!animate || trigger !== "inView") return;
const node = rootRef.current;
if (!node || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
fireBurst();
io.disconnect();
break;
}
}
},
{ threshold: 0.2 },
);
io.observe(node);
return () => io.disconnect();
}, [animate, trigger, fireBurst]);
// Cleanup any pending burst timer on unmount.
useEffect(() => {
return () => {
if (burstTimer.current) clearTimeout(burstTimer.current);
};
}, []);
const handlePointerEnter = useCallback(() => {
if (trigger !== "hover") return;
if (reduce) {
fireFlash();
return;
}
if (animate) fireBurst();
}, [trigger, reduce, animate, fireBurst, fireFlash]);
// The visible, readable text node — always plain text in the DOM.
const content = (
<>
<span className={styles.base}>{children}</span>
{mounted && (animate || reduce) ? (
// Channel copies are mount-gated: server + first client render emit ONLY
// the static base, so SSR HTML === first client render (no #418, no flash
// for non-reduced users whose SSR `reduce` was null-derived). After mount
// either the animated layer turns on, or (reduced-motion) the copies exist
// solely to support the single data-flash frame, sitting at opacity 0.
<>
<span className={`${styles.channel} ${styles.red}`} aria-hidden="true">
{children}
</span>
<span className={`${styles.channel} ${styles.cyan}`} aria-hidden="true">
{children}
</span>
</>
) : null}
</>
);
// Polymorphic tag via createElement — avoids JSX widening `as` over every
// intrinsic element (which collapses `children` to `never`).
return createElement(
Tag,
{
ref: setRootRef as never,
className: [styles.root, COLOR_CLASS[color], className].filter(Boolean).join(" "),
style: styleVars,
"data-burst": animate && bursting ? "true" : undefined,
"data-loop": animate && trigger === "loop" ? "true" : undefined,
"data-flash": reduce && flash ? "true" : undefined,
onPointerEnter: handlePointerEnter,
"aria-label": ariaLabel,
},
content,
);
}
Dependencies
- motion
- @bottega/tokens