Cleave
Masked line, word, or character reveal that draws text clear of hiding as it scrolls into view, each segment trailing a soft white 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. @default soft white */
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;
}
/** True when two line-groupings are word-for-word identical, so a re-measure that
* produces the same wrapping can be dropped instead of re-rendering (and replaying). */
function groupsEqual(a: string[][] | null, b: string[][] | null): boolean {
if (a === b) return true;
if (!a || !b || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const x = a[i];
const y = b[i];
if (x.length !== y.length) return false;
for (let j = 0; j < x.length; j++) {
if (x[j] !== y[j]) return false;
}
}
return true;
}
/**
* Cleave — text splits into lines, words, or characters and slides clear of a
* mask as it scrolls into view, each segment trailing a brief soft-white 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 = "#ffffff",
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);
// Last committed grouping, so a re-measure that yields identical wrapping is
// dropped without a re-render (which is what used to replay the reveal).
const groupsRef = React.useRef<string[][] | null>(null);
// Whether the one-shot reveal has already run to completion. A responsive
// re-wrap after that must land on the finished state, never re-hide-and-play.
const hasPlayedRef = React.useRef(false);
// "line" mode needs a client-side measuring pass. A permanently-mounted,
// visibility-hidden copy of the flat words (see the render below) wraps
// exactly like the visible lines, so we can read each word's offsetTop at
// any time — including on resize — WITHOUT tearing the visible lines back
// down to flat text. That teardown was the source of the flashing: it reset
// the timeline and replayed the reveal on every width change.
React.useLayoutEffect(() => {
// Gate on `mounted`: the flat measure words only exist once we're past the
// SSR plain-text render, and `mounted` flipping true is what re-runs this so
// it measures against those words (otherwise it fires once on the ref-less
// first render and never again — leaving line mode stuck as static text).
if (by !== "line" || reducedMotion || !mounted) 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);
}
});
const next = groups.length ? groups : null;
// Drop no-op re-measures (e.g. a scrollbar or URL-bar showing/hiding
// nudges the width without changing where lines wrap): committing them
// would re-render and, pre-fix, replay the reveal — the "flashing".
if (groupsEqual(groupsRef.current, next)) return;
groupsRef.current = next;
setLineGroups(next);
};
const raf = requestAnimationFrame(measure);
// Re-measure only on real width changes (wrapping depends on width alone).
// Observe the parent, not the container, so our own render output can never
// feed back into the observer. The measurement copy is always mounted, so
// there is no flat<->grouped swap to re-measure against.
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) {
requestAnimationFrame(measure);
}
lastWidth = width;
});
ro.observe(observed);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
};
}, [by, words, reducedMotion, mounted]);
// Reset the one-shot latch when the reveal's own definition changes (text,
// granularity, timing, colors) so those genuinely replay — but NOT when only
// the line grouping changes on resize. Declared before useGSAP so it commits
// first: on a re-measure-only render its deps are unchanged, it no-ops, and
// useGSAP below sees hasPlayedRef still latched and skips the replay.
React.useLayoutEffect(() => {
hasPlayedRef.current = false;
}, [children, by, delay, stagger, duration, scrub, start, end, repeat, scroller, glowColor, 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;
const glows = glowRefs.current.filter((el): el is HTMLSpanElement => el !== null);
// A responsive re-wrap after the reveal already finished: land on the
// final visible state and return. Rebuilding the timeline here (re-hiding
// to yPercent 110 and playing again) is exactly the flashing bug.
if (hasPlayedRef.current && !scrub) {
gsap.set(targets, { yPercent: 0, opacity: 1 });
if (glows.length) gsap.set(glows, { opacity: 0 });
return;
}
gsap.set(targets, { yPercent: 110, opacity: 0 });
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,
onComplete: scrub
? undefined
: () => {
// Latch once the reveal has fully played, so later re-measures
// (resize) skip the replay above instead of restarting it.
hasPlayedRef.current = true;
},
});
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: the flat words are the visible content (a graceful
// fallback if measurement never resolves) and double as the measure source.
return (
<Tag ref={containerRef as React.Ref<any>} aria-label={children} className={cn("relative 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("relative block", className)} {...props}>
{/* Flat, visibility-hidden copy of the words, used only to measure where
lines wrap. As the `relative block` container's absolute child it
wraps at the exact same width as the visible lines, and stays mounted
so resize re-measures never tear down (or replay) the visible reveal. */}
<span aria-hidden className="pointer-events-none invisible absolute inset-x-0 top-0 -z-10 select-none">
{words.map((word, i) => (
<span
key={i}
ref={(el) => {
measureRefs.current[i] = el;
}}
className="inline"
>
{word}
</span>
))}
</span>
{(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/reactProps
| Prop | Type | Default | Description |
|---|---|---|---|
| children | string | The text to reveal. | |
| by | "line" | "word" | "char" | "line" | Split granularity. "line" groups words by measured wrapped line. |
| as | "div" | "p" | "h1" | "h2" | "h3" | "span" | "div" | Element tag rendered for the wrapper. |
| delay | number | Seconds before the reveal starts. | |
| stagger | number | Seconds between each segment's reveal, tail to head. | |
| duration | number | Seconds each segment takes to slide clear of its mask. | |
| scrub | boolean | false | Tie progress directly to scroll position instead of a one-shot trigger. |
| start | string | ScrollTrigger start, relative to the wrapper entering the scroller's viewport. | |
| end | string | ScrollTrigger end (only used when scrub is true). | |
| repeat | boolean | Re-run every time the element re-enters the viewport (non-scrub only). | |
| scroller | string | Element | React.RefObject<HTMLElement | null> | the window. Pass a ref or element to drive the reveal from an internal overflow-y-auto container instead | The element ScrollTrigger measures scroll progress against. |
| glowColor | string | soft white | Color of the edge-glow that rides each segment's leading baseline. |
Also accepts all props of Omit<React.ComponentPropsWithoutRef<"div">, "children"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.