## Text Reveal — Content
Words arrive in reading order.
Docs: https://www.interior.dev/docs/text-reveal
Reference: https://www.interior.dev/reference/text-reveal
License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT)
### Install
Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project.
`bunx shadcn@latest add https://www.interior.dev/r/text-reveal.json`
Or run `bun add motion` and copy the source below.
### Usage
```tsx
"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 (
);
}
```
### Props
- `text` (`string`)
The sentence to reveal. It is rendered in full for assistive technology and split for display, so it is never truncated or reordered.
- `by` (`"word" | "character"`) — default: `"word"`
Unit of the stagger. Characters stay grouped inside their word, which cannot break across a line.
- `stagger` (`number`) — default: `0.055`
Seconds between two units, before clamping. This is the reading cadence, not a decoration.
- `maxDuration` (`number`) — default: `1.6`
Ceiling in seconds for the whole reveal. Long text tightens its stagger to fit instead of running for a minute.
- `startOnView` (`boolean`) — default: `true`
Wait until the element is on screen. Set false when a click or a response, not scrolling, is the trigger.
- `play` (`boolean`) — default: `true`
External gate. The reveal runs once this and the viewport condition are both satisfied.
- `once` (`boolean`) — default: `true`
Keep the text revealed after the first pass instead of re-hiding it when it scrolls away.
- `amount` (`number`) — default: `0.35`
Fraction of the element that must be visible before the viewport trigger fires.
- `className` (`string`) — default: `""`
Appended last. Set display, size and color here; the root is an inline span until you say otherwise.
### Behavior notes
- Every word occupies its final wrap position before the first one lights up, so the paragraph never re-wraps mid-reveal and nothing below it moves.
- Only opacity and transform are animated; no width, height, blur or mask-image, so a long paragraph neither relayouts nor repaints a filter on any frame.
- The stagger is clamped by maxDuration, so a four-hundred-word block finishes in the same window as a twelve-word one instead of running for half a minute.
- Screen readers get one complete copy of the sentence and the animated spans are aria-hidden, so nobody hears the text delivered a word at a time.
- Under prefers-reduced-motion the delay and the blur are dropped and the text lands at its final state; the words still arrive, only the trip is skipped.
- Order comes from the unit index, never from a random offset or a timer, so server and client markup agree and there is nothing left running after unmount.
### Source (`components/interior/text-reveal.tsx`)
```tsx
"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({
text,
by = "word",
stagger = 0.055,
maxDuration = 1.6,
startOnView = true,
play = true,
once = true,
amount = 0.35,
}: UseTextRevealOptions) {
const ref = useRef(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(
{ text, by, stagger, maxDuration, startOnView, play, once, amount },
);
return (
{text}
{groups.map((group, g) => (
{g > 0 ? " " : null}
{group.units.map((unit) => (
{unit.text}
))}
))}
);
}
```