## 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} ); } ```