Fracture
Text glitches with ember-red and deep-violet channel ghosts sheared apart by jump-cut clip-path slices, sparking at each shear line. Hover, continuous, or timed-interval triggers; freezes static under reduced motion.
cssfree
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
export interface FractureColors {
/** Color of the ember-red channel ghost. */
ember?: string;
/** Color of the deep-violet channel ghost. */
violet?: string;
/** Color of the base (undisplaced) text. */
base?: string;
/** Color of the spark-fleck particles fired at each shear line. */
spark?: string;
}
export interface FractureProps extends Omit<React.ComponentPropsWithoutRef<"span">, "children"> {
/** The text to render and glitch. */
children: string;
/** Element tag rendered. @default "span" */
as?: "span" | "div" | "p" | "h1" | "h2" | "h3";
/**
* What fires the glitch. "always" loops continuously (still bursty, not
* nonstop noise — `cadence` sets the gap between bursts); "hover" bursts
* rapidly while the pointer is over the text; "interval" bursts on a timer
* even without interaction. @default "hover"
*/
trigger?: "always" | "hover" | "interval";
/** Channel-split offset and slice strength, 0-1. @default 0.6 */
intensity?: number;
/** Seconds between bursts for "always"/"interval" triggers. @default 3.2 */
cadence?: number;
/** Seconds a single burst lasts. @default 0.5 */
burstDuration?: number;
/** Ghost-layer, base, and spark colors. */
colors?: FractureColors;
/** Freeze the animation. */
paused?: boolean;
}
const DEFAULT_COLORS: Required<FractureColors> = {
ember: "#c1121f",
violet: "#5a189a",
base: "#fdf1e3",
spark: "#ff6b35",
};
/**
* Fracture — text shears under stress: ember-red and deep-violet channel
* ghosts jump apart along clip-path slices, with a spark-fleck particle
* firing at each shear line. Reads as metal fracturing, not a VHS dropout.
* Freezes static under reduced motion.
*/
export function Fracture({
children,
as = "span",
trigger = "hover",
intensity = 0.6,
cadence = 3.2,
burstDuration = 0.5,
colors,
paused = false,
className,
style,
...props
}: FractureProps) {
const reducedMotion = useReducedMotion();
const c = { ...DEFAULT_COLORS, ...colors };
const Tag = as;
const [burst, setBurst] = React.useState(false);
React.useEffect(() => {
if (reducedMotion || trigger !== "interval" || paused) return;
const cadenceMs = Math.max(cadence, 0.2) * 1000;
const burstMs = Math.max(burstDuration, 0.05) * 1000;
let timeoutId: ReturnType<typeof setTimeout>;
const intervalId = setInterval(() => {
setBurst(true);
timeoutId = setTimeout(() => setBurst(false), burstMs);
}, cadenceMs);
return () => {
clearInterval(intervalId);
clearTimeout(timeoutId);
};
}, [trigger, cadence, burstDuration, reducedMotion, paused]);
if (reducedMotion) {
return (
<Tag className={cn("inline-block", className)} style={{ color: c.base, ...style }} {...props}>
{children}
</Tag>
);
}
return (
<Tag
data-crucible="fracture"
data-trigger={trigger}
data-burst={burst ? "" : undefined}
aria-label={children}
className={cn("relative isolate inline-block", className)}
style={
{
"--fracture-intensity": intensity,
"--fracture-cadence": `${cadence}s`,
"--fracture-burst-duration": `${burstDuration}s`,
...(paused ? { "--fracture-play": "paused" } : {}),
color: c.base,
...style,
} as React.CSSProperties
}
{...props}
>
<style>{`
/* Ghost layers only: sliced, displaced, and visible solely mid-burst. */
@keyframes crucible-fracture-glitch {
0% { opacity: 0; transform: translate3d(0, 0, 0); clip-path: inset(0 0 0 0); }
4% { opacity: 1; transform: translate3d(calc(var(--fracture-intensity) * var(--fracture-sign, 1) * -9px), calc(var(--fracture-intensity) * 2px), 0); clip-path: inset(6% 0 78% 0); }
8% { opacity: 1; transform: translate3d(calc(var(--fracture-intensity) * var(--fracture-sign, 1) * 7px), 0, 0); clip-path: inset(42% 0 18% 0); }
12% { opacity: 1; transform: translate3d(calc(var(--fracture-intensity) * var(--fracture-sign, 1) * -5px), calc(var(--fracture-intensity) * -2px), 0); clip-path: inset(70% 0 3% 0); }
17% { opacity: 0; transform: translate3d(0, 0, 0); clip-path: inset(0 0 0 0); }
100% { opacity: 0; transform: translate3d(0, 0, 0); clip-path: inset(0 0 0 0); }
}
/* Base layer stays whole and legible: a 1-2px stress jitter, no slicing. */
@keyframes crucible-fracture-jitter {
0% { transform: translate3d(0, 0, 0); }
4% { transform: translate3d(calc(var(--fracture-intensity) * -1.5px), calc(var(--fracture-intensity) * 1px), 0); }
8% { transform: translate3d(calc(var(--fracture-intensity) * 1.5px), 0, 0); }
12% { transform: translate3d(calc(var(--fracture-intensity) * -1px), calc(var(--fracture-intensity) * -1px), 0); }
17% { transform: translate3d(0, 0, 0); }
100% { transform: translate3d(0, 0, 0); }
}
@keyframes crucible-fracture-spark {
0%, 5% { opacity: 0; transform: scale(0.4); }
7% { opacity: 1; transform: scale(1.2); }
11% { opacity: 0; transform: scale(0.6); }
100% { opacity: 0; }
}
[data-crucible="fracture"] .fracture-layer { will-change: transform, clip-path, opacity; opacity: 0; }
[data-crucible="fracture"] .fracture-base { will-change: transform; }
[data-crucible="fracture"] .fracture-ember { --fracture-sign: -1; mix-blend-mode: screen; }
[data-crucible="fracture"] .fracture-violet { --fracture-sign: 1; mix-blend-mode: screen; }
[data-crucible="fracture"] .fracture-spark { opacity: 0; }
[data-crucible="fracture"][data-trigger="always"] .fracture-layer {
animation: crucible-fracture-glitch var(--fracture-cadence) steps(1, jump-end) infinite;
animation-play-state: var(--fracture-play, running);
}
[data-crucible="fracture"][data-trigger="always"] .fracture-base {
animation: crucible-fracture-jitter var(--fracture-cadence) steps(1, jump-end) infinite;
animation-play-state: var(--fracture-play, running);
}
[data-crucible="fracture"][data-trigger="always"] .fracture-spark {
animation: crucible-fracture-spark var(--fracture-cadence) steps(1, jump-end) infinite;
animation-play-state: var(--fracture-play, running);
}
[data-crucible="fracture"][data-trigger="hover"] .fracture-layer,
[data-crucible="fracture"][data-trigger="hover"] .fracture-base,
[data-crucible="fracture"][data-trigger="hover"] .fracture-spark {
animation: none;
}
[data-crucible="fracture"][data-trigger="hover"]:hover .fracture-layer {
animation: crucible-fracture-glitch var(--fracture-burst-duration) steps(1, jump-end) infinite;
animation-play-state: var(--fracture-play, running);
}
[data-crucible="fracture"][data-trigger="hover"]:hover .fracture-base {
animation: crucible-fracture-jitter var(--fracture-burst-duration) steps(1, jump-end) infinite;
animation-play-state: var(--fracture-play, running);
}
[data-crucible="fracture"][data-trigger="hover"]:hover .fracture-spark {
animation: crucible-fracture-spark var(--fracture-burst-duration) steps(1, jump-end) infinite;
animation-play-state: var(--fracture-play, running);
}
[data-crucible="fracture"][data-trigger="interval"] .fracture-layer,
[data-crucible="fracture"][data-trigger="interval"] .fracture-base,
[data-crucible="fracture"][data-trigger="interval"] .fracture-spark {
animation: none;
}
[data-crucible="fracture"][data-trigger="interval"][data-burst] .fracture-layer {
animation: crucible-fracture-glitch var(--fracture-burst-duration) steps(1, jump-end) 1;
}
[data-crucible="fracture"][data-trigger="interval"][data-burst] .fracture-base {
animation: crucible-fracture-jitter var(--fracture-burst-duration) steps(1, jump-end) 1;
}
[data-crucible="fracture"][data-trigger="interval"][data-burst] .fracture-spark {
animation: crucible-fracture-spark var(--fracture-burst-duration) steps(1, jump-end) 1;
}
@media (prefers-reduced-motion: reduce) {
[data-crucible="fracture"] .fracture-layer,
[data-crucible="fracture"] .fracture-base,
[data-crucible="fracture"] .fracture-spark {
animation: none !important;
}
}
`}</style>
<span aria-hidden className="fracture-base relative z-10">
{children}
</span>
<span
aria-hidden
className="fracture-layer fracture-ember pointer-events-none absolute inset-0 select-none"
style={{ color: c.ember }}
>
{children}
</span>
<span
aria-hidden
className="fracture-layer fracture-violet pointer-events-none absolute inset-0 select-none"
style={{ color: c.violet }}
>
{children}
</span>
<span
aria-hidden
className="fracture-spark pointer-events-none absolute rounded-full"
style={{ top: "28%", left: "18%", width: 4, height: 4, background: c.spark, boxShadow: `0 0 8px 2px ${c.spark}` }}
/>
<span
aria-hidden
className="fracture-spark pointer-events-none absolute rounded-full"
style={{ top: "62%", left: "78%", width: 3, height: 3, background: c.spark, boxShadow: `0 0 6px 2px ${c.spark}` }}
/>
</Tag>
);
}
Installation
CLI
npx shadcn@latest add @crucible/fractureHonors prefers-reduced-motion with a designed static fallback, and passes className through.