Workspace / component / text
Glitch Text
paxfx add blocks/component-text-glitchlocal onlyTier: low. Reduced motion: Static text with a subtle chromatic shadow, no flicker.
"use client";
import { type CSSProperties } from "react";
import { cn, useReducedMotion } from "@pax-sh/blocks";
export interface GlitchTextProps {
text?: string;
/** Heading level or inline element to render. */
as?: "h1" | "h2" | "h3" | "p" | "span";
/** 0 to 1; scales ghost offsets and burst strength. */
aggression?: number;
className?: string;
}
/**
* Chromatic-aberration slices built from two clipped ghost copies in acid
* and mute, flickering on an idle-heavy CSS keyframe interval. Hovering
* the text widens the offsets and speeds both loops for a stronger burst.
* Reduced motion drops the ghosts for a static chromatic shadow.
*/
export function GlitchText({
text = "Break the frame.",
as: Tag = "h2",
aggression = 0.5,
className,
}: GlitchTextProps) {
const reduced = useReducedMotion();
const amp = 2 + aggression * 6;
const burst = amp * 2.2;
return (
<Tag
className={cn("m-0 font-semibold tracking-tight", className)}
data-pax-asset="component-text-glitch"
>
{reduced ? (
<span
style={{
textShadow:
"0.035em 0 0 var(--pax-acid, #c8ff00), -0.035em 0 0 var(--pax-mute, #6b645c)",
}}
>
{text}
</span>
) : (
<>
<style>{`
@keyframes pax-glitch-slice-a {
0%, 84%, 100% { clip-path: inset(0 0 100% 0); transform: translate(0, 0); opacity: 0; }
85% { clip-path: inset(8% 0 66% 0); transform: translate(calc(var(--pax-glitch-amp) * -1), calc(var(--pax-glitch-amp) * 0.35)); opacity: 0.9; }
89% { clip-path: inset(52% 0 22% 0); transform: translate(var(--pax-glitch-amp), 0); opacity: 0.9; }
93% { clip-path: inset(28% 0 48% 0); transform: translate(calc(var(--pax-glitch-amp) * 0.6), calc(var(--pax-glitch-amp) * -0.35)); opacity: 0.9; }
96% { clip-path: inset(0 0 100% 0); transform: translate(0, 0); opacity: 0; }
}
@keyframes pax-glitch-slice-b {
0%, 86%, 100% { clip-path: inset(100% 0 0 0); transform: translate(0, 0); opacity: 0; }
87% { clip-path: inset(62% 0 10% 0); transform: translate(var(--pax-glitch-amp), calc(var(--pax-glitch-amp) * -0.35)); opacity: 0.85; }
91% { clip-path: inset(16% 0 60% 0); transform: translate(calc(var(--pax-glitch-amp) * -1), 0); opacity: 0.85; }
95% { clip-path: inset(44% 0 30% 0); transform: translate(calc(var(--pax-glitch-amp) * -0.6), calc(var(--pax-glitch-amp) * 0.35)); opacity: 0.85; }
98% { clip-path: inset(100% 0 0 0); transform: translate(0, 0); opacity: 0; }
}
.pax-glitch-root:hover { --pax-glitch-amp: var(--pax-glitch-burst); }
.pax-glitch-root:hover .pax-glitch-ghost-a { animation-duration: 0.9s; }
.pax-glitch-root:hover .pax-glitch-ghost-b { animation-duration: 1.1s; }
`}</style>
<span
className="pax-glitch-root relative inline-block"
style={
{
"--pax-glitch-amp": `${amp}px`,
"--pax-glitch-burst": `${burst}px`,
} as CSSProperties
}
>
<span className="relative z-[1]">{text}</span>
<span
aria-hidden="true"
className="pax-glitch-ghost-a pointer-events-none absolute inset-0 select-none text-[var(--pax-acid,#c8ff00)]"
style={{ animation: "pax-glitch-slice-a 3.4s steps(1) infinite" }}
>
{text}
</span>
<span
aria-hidden="true"
className="pax-glitch-ghost-b pointer-events-none absolute inset-0 select-none text-[var(--pax-mute,#6b645c)]"
style={{ animation: "pax-glitch-slice-b 4.3s steps(1) infinite" }}
>
{text}
</span>
</span>
</>
)}
</Tag>
);
}