All patterns

Streaming Text Reveal

Words arrive one at a time behind a soft caret — the answer is being written, not loaded.

aicalmfuturisticautomatic · finite · starter · ~1.6s
Variant

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.

166 lines · react + motion only
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.

Chat responseGenerated summaryInline rewriteAnswer streaming

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • 10:15
    Summarise the supplier contract and flag anything unusual.
    The renewal runs another twelve months at the same rate, with one clause worth a second look.
    Ask a follow-up
    AI assistant

    Responses render progressively as the model streams them.

Related patterns