Streaming Text Reveal
Words arrive one at a time behind a soft caret — the answer is being written, not loaded.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { Fragment, useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Streaming Text Reveal
*
* Words arriving one at a time, the way a model streams them, with a
* soft caret parked at the write head.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Works with zero props; tune via `variant`, `text`, `caretColor`.
* The stream starts on mount, so give it a `key` that changes with the
* message to stream a new answer.
* Requires the automatic JSX runtime (default since React 17).
*/
export type StreamingTextRevealProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** The answer to stream. Split on whitespace — a word is the reveal unit. */
text?: string;
/** Caret color. */
caretColor?: string;
/** Fires once the last word has landed. */
onComplete?: () => void;
};
type VariantConfig = {
/** ms between words — this is the perceived generation speed. */
cadenceMs: number;
/** Beat before the first word, so the caret is seen waiting first. */
leadInMs: number;
/** Fade-in of a single word. */
fadeSeconds: number;
/** px a word travels up while it fades in. */
riseY: number;
caretCycleSeconds: number;
};
// Variants differ in cadence and travel only. A word fades — it never
// scales and never springs: deformed glyphs are the fastest way to make
// text look cheap, and a whole paragraph of them is unreadable.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Pure crossfade, zero travel: a long answer of rising words turns
// into visual noise, so the calmest setting drops the movement.
subtle: {
cadenceMs: 62,
leadInMs: 220,
fadeSeconds: 0.3,
riseY: 0,
caretCycleSeconds: 1.3,
},
// 2px is enough to read as "arriving" without pulling the eye off the
// sentence being read. The all-purpose setting.
default: {
cadenceMs: 52,
leadInMs: 260,
fadeSeconds: 0.24,
riseY: 2,
caretCycleSeconds: 1.15,
},
// Quicker cadence, slightly more travel — energy from speed, not size.
playful: {
cadenceMs: 38,
leadInMs: 200,
fadeSeconds: 0.2,
riseY: 4,
caretCycleSeconds: 0.95,
},
};
const SAMPLE_TEXT =
"Revenue grew 12% last quarter, driven mostly by returning customers. Support volume dropped by a third after the checkout fix shipped.";
export default function StreamingTextReveal({
variant = "default",
text = SAMPLE_TEXT,
caretColor = "#7C7CF0",
onComplete,
}: StreamingTextRevealProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const words = text.trim().split(/\s+/);
const wordCount = words.length;
const [revealed, setRevealed] = useState(0);
const done = Boolean(reduceMotion) || revealed >= wordCount;
// Kept in a ref so an inline arrow from the parent can't re-fire the
// callback every render.
const onCompleteRef = useRef(onComplete);
useEffect(() => {
onCompleteRef.current = onComplete;
}, [onComplete]);
// One timer per word, scheduled from the current count: nothing to
// drift out of sync, and unmounting mid-stream cleans itself up.
useEffect(() => {
if (reduceMotion || revealed >= wordCount) return;
const id = setTimeout(
() => setRevealed((n) => n + 1),
revealed === 0 ? cfg.leadInMs : cfg.cadenceMs
);
return () => clearTimeout(id);
}, [revealed, wordCount, reduceMotion, cfg.cadenceMs, cfg.leadInMs]);
useEffect(() => {
if (done) onCompleteRef.current?.();
}, [done]);
// Line height is set because streamed text is read while it moves and
// needs the leading; size and color are left to inherit so the answer
// looks like the rest of your copy.
const paragraphStyle = { margin: 0, lineHeight: 1.6 };
// Reduced motion: the whole answer is there on the first frame. The
// information is the text — the trickle is decoration.
if (reduceMotion) {
return <p style={paragraphStyle}>{text}</p>;
}
return (
<p style={paragraphStyle}>
{words.slice(0, revealed).map((word, index) => (
<Fragment key={index}>
<motion.span
initial={{ opacity: 0, y: cfg.riseY }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: cfg.fadeSeconds, ease: "easeOut" }}
// inline-block because transforms have no effect on inline
// boxes; the space between words stays a real text node so
// the paragraph still wraps normally.
style={{ display: "inline-block" }}
>
{word}
</motion.span>{" "}
</Fragment>
))}
<motion.span
aria-hidden
initial={{ opacity: 0.85 }}
// A slow opacity swell rather than a hard blink: the caret should
// read as a live write head, not as a system cursor.
animate={done ? { opacity: 0 } : { opacity: [0.85, 0.2, 0.85] }}
transition={
done
? { duration: 0.28, delay: 0.3, ease: "easeOut" }
: {
duration: cfg.caretCycleSeconds,
repeat: Infinity,
ease: "easeInOut",
}
}
style={{
display: "inline-block",
width: 2,
// em units so the caret matches whatever type size it inherits.
height: "1.05em",
borderRadius: 1,
background: caretColor,
verticalAlign: "-0.18em",
}}
/>
</p>
);
}About this pattern
The default shape of a generated answer: text lands word by word while a caret marks the write head. Streaming converts dead waiting time into readable progress — the first sentence can be read while the rest is still arriving, which makes a four-second response feel shorter than a one-second spinner. Words fade into place and never scale, because deformed glyphs are the fastest way to make text look cheap.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- AI assistant
Responses render progressively as the model streams them.
Related patterns
- AI Result RevealThe result card rises into place while its confidence value counts up to the final number.
- Streaming Code BlockGenerated code arrives line by line while the panel grows downward to fit it.
- Stop Generation CutHalting a stream folds the caret away, closes the partial answer with a hairline, and states that it stopped.