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 motionOr let the shadcn CLI do the copying: same file, landing in components/yugo.
bunx shadcn@latest add https://yugo.click/r/text-reveal.jsonUsage
"use client";
import { useState } from "react";
import { TextReveal } from "@/components/yugo/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.