Content·10.1
Text Reveal
Words arrive in reading order.
Install
One dependency. The component is copied into your project, so the file is yours after that.
bun add motionUsage
"use client";
import { useState } from "react";
import { TextReveal } from "@/components/interior/text-reveal";
export function AnswerCard({ answer }: { answer: string }) {
const [asked, setAsked] = useState(false);
return (
<section className="rounded-[14px] border border-stone-200 p-4 dark:border-white/[0.16]">
<TextReveal
text={answer}
play={asked}
startOnView={false}
stagger={0.05}
maxDuration={1.6}
className="block text-[13.5px] leading-relaxed"
/>
<button
type="button"
onClick={() => setAsked(true)}
disabled={asked}
className="mt-3 h-9 rounded-[9px] border border-stone-200 px-3 text-[13px] font-medium text-stone-700 disabled:opacity-50 dark:border-white/[0.16] dark:text-stone-200"
>
Ask
</button>
</section>
);
}Source
"use client";
import { Fragment, useMemo, useRef } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
const EASE = [0.23, 1, 0.32, 1] as const;
const DURATION = 0.6;
const HIDDEN = { opacity: 0, y: 10, filter: "blur(8px)" } as const;
const SHOWN = { opacity: 1, y: 0, filter: "blur(0px)" } as const;
export type TextRevealSplit = "word" | "character";
export type TextRevealUnit = {
key: string;
text: string;
index: number;
};
export type TextRevealGroup = {
key: string;
units: TextRevealUnit[];
};
export type UseTextRevealOptions = {
text: string;
by?: TextRevealSplit;
stagger?: number;
maxDuration?: number;
startOnView?: boolean;
play?: boolean;
once?: boolean;
amount?: number;
};
export function useTextReveal<T extends HTMLElement = HTMLSpanElement>({
text,
by = "word",
stagger = 0.055,
maxDuration = 1.6,
startOnView = true,
play = true,
once = true,
amount = 0.35,
}: UseTextRevealOptions) {
const ref = useRef<T>(null);
const inView = useInView(ref, { once, amount });
const reduced = useReducedMotion();
const { groups, step, count } = useMemo(() => {
const words = text.trim().length ? text.trim().split(/\s+/) : [];
let index = 0;
const built: TextRevealGroup[] = words.map((word, w) => {
if (by === "character") {
return {
key: `w${w}`,
units: Array.from(word).map((char, c) => ({
key: `w${w}c${c}`,
text: char,
index: index++,
})),
};
}
return {
key: `w${w}`,
units: [{ key: `w${w}`, text: word, index: index++ }],
};
});
const total = index;
const span = Math.max(0, maxDuration - DURATION);
return {
groups: built,
count: total,
step: total > 1 ? Math.min(stagger, span / (total - 1)) : 0,
};
}, [text, by, stagger, maxDuration]);
const started = play && (!startOnView || inView);
return {
ref,
groups,
step,
count,
started,
reduced: Boolean(reduced),
duration: count > 1 ? (count - 1) * step + DURATION : DURATION,
};
}
export type TextRevealProps = UseTextRevealOptions & {
className?: string;
};
export function TextReveal({
text,
by = "word",
stagger = 0.055,
maxDuration = 1.6,
startOnView = true,
play = true,
once = true,
amount = 0.35,
className = "",
}: TextRevealProps) {
const { ref, groups, step, started, reduced } = useTextReveal<HTMLSpanElement>(
{ text, by, stagger, maxDuration, startOnView, play, once, amount },
);
return (
<span ref={ref} className={`text-stone-700 dark:text-stone-200 ${className}`}>
<span className="sr-only">{text}</span>
<span aria-hidden="true">
{groups.map((group, g) => (
<Fragment key={group.key}>
{g > 0 ? " " : null}
<span className="inline-block whitespace-nowrap align-baseline">
{group.units.map((unit) => (
<motion.span
key={unit.key}
className="inline-block align-baseline"
initial={reduced ? false : HIDDEN}
animate={started ? SHOWN : HIDDEN}
transition={
reduced
? { duration: 0 }
: {
duration: DURATION,
ease: EASE,
delay: started ? unit.index * step : 0,
}
}
>
{unit.text}
</motion.span>
))}
</span>
</Fragment>
))}
</span>
</span>
);
}Props
textstringThe sentence to reveal. It is rendered in full for assistive technology and split for display, so it is never truncated or reordered.
by"word""word" | "character"Unit of the stagger. Characters stay grouped inside their word, which cannot break across a line.
stagger0.055numberSeconds between two units, before clamping. This is the reading cadence, not a decoration.
maxDuration1.6numberCeiling in seconds for the whole reveal. Long text tightens its stagger to fit instead of running for a minute.
startOnViewtruebooleanWait until the element is on screen. Set false when a click or a response, not scrolling, is the trigger.
playtruebooleanExternal gate. The reveal runs once this and the viewport condition are both satisfied.
oncetruebooleanKeep the text revealed after the first pass instead of re-hiding it when it scrolls away.
amount0.35numberFraction of the element that must be visible before the viewport trigger fires.
className""stringAppended last. Set display, size and color here; the root is an inline span until you say otherwise.