Cleave
Masked line, word, or character reveal that draws text clear of hiding as it scrolls into view, each segment trailing a molten-amber edge glow along its baseline.
gsapfree
"use client";
import * as React from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
if (typeof window !== "undefined") {
gsap.registerPlugin(ScrollTrigger, useGSAP);
}
export interface CleaveProps extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
/** The text to reveal. */
children: string;
/** Split granularity. "line" groups words by measured wrapped line. @default "line" */
by?: "line" | "word" | "char";
/** Element tag rendered for the wrapper. @default "div" */
as?: "div" | "p" | "h1" | "h2" | "h3" | "span";
/** Seconds before the reveal starts. */
delay?: number;
/** Seconds between each segment's reveal, tail to head. */
stagger?: number;
/** Seconds each segment takes to slide clear of its mask. */
duration?: number;
/** Tie progress directly to scroll position instead of a one-shot trigger. @default false */
scrub?: boolean;
/** ScrollTrigger `start`, relative to the wrapper entering the scroller's viewport. */
start?: string;
/** ScrollTrigger `end` (only used when `scrub` is true). */
end?: string;
/** Re-run every time the element re-enters the viewport (non-scrub only). */
repeat?: boolean;
/**
* The element ScrollTrigger measures scroll progress against. Defaults to
* the window. Pass a ref or element to drive the reveal from an internal
* `overflow-y-auto` container instead.
*/
scroller?: string | Element | React.RefObject<HTMLElement | null>;
/** Color of the edge-glow that rides each segment's leading baseline. */
glowColor?: string;
}
/** Merges Unicode word segments so trailing spaces/punctuation stay attached, like anneal's char/word split. */
function splitWords(text: string): string[] {
if (typeof Intl !== "undefined" && typeof Intl.Segmenter === "function") {
const seg = new Intl.Segmenter(undefined, { granularity: "word" });
const words: string[] = [];
for (const { segment, isWordLike } of seg.segment(text)) {
if (isWordLike || words.length === 0) {
words.push(segment);
} else {
words[words.length - 1] += segment;
}
}
return words;
}
return text.split(/(?<=\s)/);
}
/** Unicode grapheme clusters (emoji/combining-mark safe) with a spread-operator fallback. */
function splitGraphemes(text: string): string[] {
if (typeof Intl !== "undefined" && typeof Intl.Segmenter === "function") {
const seg = new Intl.Segmenter(undefined, { granularity: "grapheme" });
return Array.from(seg.segment(text), (s) => s.segment);
}
return [...text];
}
function resolveScroller(scroller: CleaveProps["scroller"]): string | Element | undefined {
if (!scroller) return undefined;
if (typeof scroller === "string") return scroller;
if (scroller instanceof Element) return scroller;
return scroller.current ?? undefined;
}
/**
* Cleave — text splits into lines, words, or characters and slides clear of a
* mask as it scrolls into view, like steel drawn from the forge, each
* segment trailing a brief molten-amber edge glow along its leading
* baseline. Falls back to plain static text under reduced motion.
*/
export function Cleave({
children,
by = "line",
// span default: Cleave usually lives inside an <h*> or <p>, where a block
// element would be invalid HTML (and a guaranteed hydration error).
as = "span",
delay = 0,
stagger = 0.06,
duration = 0.7,
scrub = false,
start = "top 85%",
end = "bottom 60%",
repeat = false,
scroller,
glowColor = "#ff6b35",
className,
...props
}: CleaveProps) {
const reducedMotion = useReducedMotion();
// SSR renders plain text (identical markup server/client — no hydration
// mismatch, and crawlers see real text); segmentation begins after mount.
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
const containerRef = React.useRef<HTMLElement | null>(null);
const measureRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
const innerRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
const glowRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
const words = React.useMemo(() => splitWords(children), [children]);
const graphemes = React.useMemo(() => (by === "char" ? splitGraphemes(children) : null), [children, by]);
const [lineGroups, setLineGroups] = React.useState<string[][] | null>(null);
// "line" mode needs a client-side measuring pass: render flat words first,
// read their offsetTop once laid out, then group same-top words into lines.
React.useLayoutEffect(() => {
if (by !== "line" || reducedMotion) return;
const container = containerRef.current;
if (!container) return;
const measure = () => {
const refs = measureRefs.current;
const groups: string[][] = [];
let lastTop: number | null = null;
words.forEach((word, i) => {
const el = refs[i];
if (!el) return;
const top = el.offsetTop;
if (lastTop === null || Math.abs(top - lastTop) > 1) {
groups.push([word]);
lastTop = top;
} else {
groups[groups.length - 1].push(word);
}
});
setLineGroups(groups.length ? groups : null);
};
const raf = requestAnimationFrame(measure);
// Re-measure only on real width changes (wrapping depends on width alone).
// Observe the parent, not the container: our own flat<->grouped markup
// swap changes the container's size, so observing it re-triggers the
// callback and bounces the component between renders forever. Skip the
// observer's initial fire-on-observe callback via the null sentinel.
const observed = container.parentElement ?? container;
let lastWidth: number | null = null;
const ro = new ResizeObserver((entries) => {
const width = entries[0]?.contentRect.width ?? observed.getBoundingClientRect().width;
if (lastWidth !== null && Math.abs(width - lastWidth) > 1) {
setLineGroups(null);
requestAnimationFrame(() => requestAnimationFrame(measure));
}
lastWidth = width;
});
ro.observe(observed);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
};
}, [by, words, reducedMotion]);
const segments = by === "char" ? (graphemes ?? []) : words;
const isLineMode = by === "line";
const lineReady = isLineMode && !!lineGroups;
useGSAP(
() => {
if (reducedMotion) return;
const targets = innerRefs.current.filter((el): el is HTMLSpanElement => el !== null);
if (!targets.length) return;
gsap.set(targets, { yPercent: 110, opacity: 0 });
const glows = glowRefs.current.filter((el): el is HTMLSpanElement => el !== null);
if (glows.length) gsap.set(glows, { opacity: 0 });
const tl = gsap.timeline({
scrollTrigger: {
trigger: containerRef.current,
scroller: resolveScroller(scroller),
start,
end: scrub ? end : undefined,
scrub,
toggleActions: scrub ? undefined : repeat ? "play reverse play reverse" : "play none none none",
},
delay: scrub ? 0 : delay,
});
targets.forEach((el, i) => {
tl.to(el, { yPercent: 0, opacity: 1, duration, ease: "power3.out" }, i * stagger);
const glow = glowRefs.current[i];
if (glow) {
tl.fromTo(glow, { opacity: 1 }, { opacity: 0, duration: duration * 0.7, ease: "power2.out" }, i * stagger);
}
});
return () => {
tl.scrollTrigger?.kill();
tl.kill();
};
},
{
scope: containerRef,
dependencies: [
mounted,
by,
lineGroups,
segments.length,
reducedMotion,
delay,
stagger,
duration,
scrub,
start,
end,
repeat,
scroller,
glowColor,
],
revertOnUpdate: true,
}
);
// Reset ref arrays before this render's ref callbacks repopulate them.
innerRefs.current = [];
glowRefs.current = [];
const Tag = as;
if (reducedMotion || !mounted) {
return (
<Tag ref={containerRef as React.Ref<any>} aria-label={children} className={cn("inline-block", className)} {...props}>
<span aria-hidden>{children}</span>
</Tag>
);
}
if (isLineMode && !lineReady) {
// Measuring pass: plain flat words, fully visible, refs read once laid out.
return (
<Tag ref={containerRef as React.Ref<any>} aria-label={children} className={cn("inline-block", className)} {...props}>
{words.map((word, i) => (
<span
key={i}
ref={(el) => {
measureRefs.current[i] = el;
}}
aria-hidden
className="inline"
>
{word}
</span>
))}
</Tag>
);
}
if (isLineMode) {
return (
<Tag ref={containerRef as React.Ref<any>} aria-label={children} className={cn(className)} {...props}>
{(lineGroups ?? []).map((line, i) => (
<span key={i} className="relative block overflow-hidden">
<span
ref={(el) => {
innerRefs.current[i] = el;
}}
// No inline transform here: GSAP parses `translateY(110%)` as a
// residual pixel `y` that yPercent tweens never clear, leaving the
// text permanently offset. gsap.set({ yPercent: 110 }) owns the
// offset; the opacity pre-hide (overridden by the synchronous
// useGSAP set) just guards against a pre-reveal flash.
className="block will-change-transform"
style={{ opacity: 0 }}
>
{line.join("")}
</span>
<span
ref={(el) => {
glowRefs.current[i] = el;
}}
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-[2px] opacity-0"
style={{
background: `linear-gradient(90deg, transparent, ${glowColor}, transparent)`,
boxShadow: `0 0 10px ${glowColor}`,
}}
/>
</span>
))}
</Tag>
);
}
return (
<Tag ref={containerRef as React.Ref<any>} aria-label={children} className={cn(className)} {...props}>
{segments.map((segment, i) => (
<span key={i} className="relative inline-block overflow-hidden align-bottom">
<span
ref={(el) => {
innerRefs.current[i] = el;
}}
aria-hidden
// See line mode above: initial offset must come from gsap.set
// (yPercent), never an inline translateY(%), which GSAP reads as
// a residual pixel `y`.
className="inline-block whitespace-pre will-change-transform"
style={{ opacity: 0 }}
>
{segment}
</span>
<span
ref={(el) => {
glowRefs.current[i] = el;
}}
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-[2px] opacity-0"
style={{
background: `linear-gradient(90deg, transparent, ${glowColor}, transparent)`,
boxShadow: `0 0 10px ${glowColor}`,
}}
/>
</span>
))}
</Tag>
);
}
Installation
CLI
npx shadcn@latest add @crucible/cleaveManual — install dependencies, then copy the source
npm install gsap @gsap/reactHonors prefers-reduced-motion with a designed static fallback, and passes className through.