A scroll-driven text fill for React that follows real line breaks, using clip-path. Words fill in as the user scrolls.
"use client";
import ClipText from "@/components/clip-text";
function Demo() {
return (
<ClipText
ariaLabel="We build AI tools and agents."
as="p"
className="font-serif text-2xl"
text="We build AI tools and agents. But, most importantly, we help you rediscover what care is when technology works with you, not against you."
/>
);
}Install the following dependencies:
npm install motionbun add motiondeno add motionpnpm add motionyarn add motionCopy and paste the following code into your project.
"use client";
import {
type MotionValue,
motion,
type UseScrollOptions,
useReducedMotion,
useScroll,
useSpring,
useTransform,
} from "motion/react";
import {
type ComponentPropsWithoutRef,
type CSSProperties,
type ElementType,
Fragment,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
const HIDDEN_CLIP = "inset(0 100% 0 0)";
const VISIBLE_CLIP = "inset(0 0% 0 0)";
const SCROLL_OFFSET = ["start end", "end center"] as const;
const LINE_TOP_TOLERANCE_PX = 1;
const WORD_CHUNK_RE = /(\S+\s*)/g;
type ClipTextProps<T extends ElementType = "p"> = {
ariaLabel: string;
as?: T;
className?: string;
text: string;
options?: UseScrollOptions;
} & Omit<ComponentPropsWithoutRef<T>, "as" | "children">;
interface WordToken {
index: number;
key: string;
space: string;
type: "word";
word: string;
}
type Token = WordToken | { key: string; type: "break" };
function tokenizeText(text: string): Token[] {
const tokens: Token[] = [];
const lines = text.split("\n");
let wordIndex = 0;
for (const [lineIndex, line] of lines.entries()) {
if (lineIndex > 0) {
tokens.push({ key: `nl-${lineIndex}`, type: "break" });
}
const chunks = line.match(WORD_CHUNK_RE);
if (!chunks) {
continue;
}
for (const [chunkIndex, chunk] of chunks.entries()) {
const word = chunk.trimEnd();
tokens.push({
index: wordIndex,
key: `w-${lineIndex}-${chunkIndex}`,
space: chunk.slice(word.length),
type: "word",
word,
});
wordIndex += 1;
}
}
return tokens;
}
function groupWordsByLine(
measureElement: HTMLElement,
tokens: readonly Token[]
): WordToken[][] {
const wordNodes = [
...measureElement.querySelectorAll<HTMLElement>("[data-clip-word]"),
];
const lines: WordToken[][] = [];
let currentLine: WordToken[] = [];
let currentTop: number | null = null;
let wordNodeIndex = 0;
for (const token of tokens) {
if (token.type === "break") {
if (currentLine.length > 0) {
lines.push(currentLine);
currentLine = [];
}
currentTop = null;
continue;
}
const node = wordNodes[wordNodeIndex];
wordNodeIndex += 1;
if (!node) {
continue;
}
const top = node.offsetTop;
const isNewLine =
currentTop === null || Math.abs(top - currentTop) > LINE_TOP_TOLERANCE_PX;
if (isNewLine) {
if (currentLine.length > 0) {
lines.push(currentLine);
}
currentLine = [token];
currentTop = top;
continue;
}
currentLine.push(token);
}
if (currentLine.length > 0) {
lines.push(currentLine);
}
return lines;
}
function WordFill({
children,
count,
index,
progress,
shouldReduceMotion,
}: {
children: string;
count: number;
index: number;
progress: MotionValue<number>;
shouldReduceMotion: boolean;
}) {
const clipPath = useTransform(
progress,
[index / count, (index + 1) / count],
[HIDDEN_CLIP, VISIBLE_CLIP]
);
return (
<span className="inline-grid align-baseline">
<span className="col-start-1 row-start-1 opacity-30">{children}</span>
<motion.span
className="col-start-1 row-start-1"
style={{ clipPath: shouldReduceMotion ? VISIBLE_CLIP : clipPath }}
>
{children}
</motion.span>
</span>
);
}
function MeasureWords({ tokens }: { tokens: readonly Token[] }) {
return tokens.map((token) => {
if (token.type === "break") {
return <br key={token.key} />;
}
return (
<Fragment key={token.key}>
<span className="inline-grid align-baseline" data-clip-word="">
{token.word}
</span>
{token.space}
</Fragment>
);
});
}
function ClipText<T extends ElementType = "p">({
ariaLabel,
as,
className,
text,
options,
...props
}: ClipTextProps<T>) {
const shouldReduceMotion = useReducedMotion() ?? false;
const containerRef = useRef<HTMLElement | null>(null);
const [lines, setLines] = useState<WordToken[][] | null>(null);
const MotionTag = useMemo(() => motion.create(as ?? "p"), [as]);
const tokens = useMemo(() => tokenizeText(text), [text]);
const wordCount = tokens.reduce(
(count, token) => (token.type === "word" ? count + 1 : count),
0
);
const { scrollYProgress } = useScroll({
offset: [...SCROLL_OFFSET],
target: containerRef,
...options,
});
const progress = useSpring(scrollYProgress, {
damping: 10,
mass: 0.1,
restDelta: 0.001,
skipInitialAnimation: true,
stiffness: 100,
});
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
const measure = container.querySelector<HTMLElement>("[data-clip-measure]");
if (!measure) {
return;
}
let cancelled = false;
const compute = () => {
if (cancelled) {
return;
}
if (tokens.length === 0) {
setLines([]);
return;
}
setLines(groupWordsByLine(measure, tokens));
};
compute();
const observer = new ResizeObserver(compute);
observer.observe(container);
const { fonts } = document;
fonts.ready.then(compute).catch(() => undefined);
fonts.addEventListener("loadingdone", compute);
return () => {
cancelled = true;
observer.disconnect();
fonts.removeEventListener("loadingdone", compute);
};
}, [tokens]);
const hasSplit = lines !== null;
const measureStyle: CSSProperties | undefined = hasSplit
? {
insetInline: 0,
pointerEvents: "none",
position: "absolute",
top: 0,
visibility: "hidden",
}
: undefined;
return (
<MotionTag
{...props}
aria-label={ariaLabel}
className={cn("relative", className)}
ref={containerRef}
>
<span aria-hidden="true" data-clip-measure="" style={measureStyle}>
<MeasureWords tokens={tokens} />
</span>
{hasSplit ? (
<span aria-hidden="true">
{lines.map((line) => (
<span
className="block"
key={line.map((word) => word.key).join(":")}
>
{line.map((word) => (
<Fragment key={word.key}>
<WordFill
count={wordCount}
index={word.index}
progress={progress}
shouldReduceMotion={shouldReduceMotion}
>
{word.word}
</WordFill>
{word.space}
</Fragment>
))}
</span>
))}
</span>
) : null}
</MotionTag>
);
}
ClipText.displayName = "ClipText";
export { ClipText };