Skip to content

Streaming Text

Words resolve through a soft cross-blur, like an AI stream.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/streaming-text.json

Manual

  1. Install the dependencies

    npm install clsx motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Copy the source

    components/ui/streaming-text/streaming-text.tsx
    "use client";
    
    import { useEffect, useRef, useState, type ComponentProps } from "react";
    import { motion, useReducedMotion } from "motion/react";
    import { cn } from "@/lib/utils";
    import { ease } from "@/lib/motion";
    
    export interface StreamingTextProps
      extends Omit<ComponentProps<"div">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** Text received so far. Append to it as chunks arrive; only the new part animates. */
      text: string;
      /** More text is on the way: shows the caret and sets aria-busy. @default false */
      streaming?: boolean;
      /** Animate each new word or each new character. @default "word" */
      granularity?: "word" | "character";
      /** Seconds each new token takes to resolve. @default 0.5 */
      duration?: number;
      /** Starting blur in px for new tokens. @default 6 */
      blur?: number;
      /** Show a pulsing caret after the text while streaming. @default true */
      caret?: boolean;
    }
    
    function graphemes(value: string) {
      if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
        return Array.from(new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(value), (s) => s.segment);
      }
      return Array.from(value);
    }
    
    function tokenize(text: string, granularity: "word" | "character") {
      return granularity === "word" ? (text.match(/\S+\s*|\s+/g) ?? []) : graphemes(text);
    }
    
    export function StreamingText({
      text,
      streaming = false,
      granularity = "word",
      duration = 0.5,
      blur = 6,
      caret = true,
      className,
      ...props
    }: StreamingTextProps) {
      const reduceMotion = useReducedMotion();
      const tokens = tokenize(text, granularity);
      // Tokens that finished animating collapse into one plain text node, so long answers stay light.
      const [settled, setSettled] = useState(0);
      const [previous, setPrevious] = useState(text);
      if (previous !== text) {
        setPrevious(text);
        if (!text.startsWith(previous)) setSettled(0);
      }
    
      const arrivals = useRef<number[]>([]);
      const live = useRef({ streaming, lastText: text });
      useEffect(() => {
        const times = arrivals.current;
        if (!text.startsWith(live.current.lastText)) times.length = 0;
        live.current = { streaming, lastText: text };
        const now = performance.now();
        while (times.length > tokens.length) times.pop();
        while (times.length < tokens.length) times.push(now);
      });
    
      useEffect(() => {
        const id = window.setInterval(() => {
          const times = arrivals.current;
          const now = performance.now();
          let done = 0;
          while (done < times.length && now - times[done] > duration * 1000 + 60) done++;
          // While streaming, the last token may still grow ("hel" → "hello"), so it never settles early.
          if (live.current.streaming) done = Math.min(done, times.length - 1);
          setSettled((s) => (done > s ? done : s));
        }, 250);
        return () => window.clearInterval(id);
      }, [duration]);
    
      const cut = Math.min(settled, tokens.length);
      const initial = reduceMotion ? { opacity: 0 } : { opacity: 0, filter: `blur(${blur}px)` };
    
      return (
        <div className={cn("whitespace-pre-wrap", className)} aria-busy={streaming || undefined} {...props}>
          {tokens.slice(0, cut).join("")}
          {tokens.slice(cut).map((token, j) => (
            <motion.span
              key={cut + j}
              initial={initial}
              animate={{ opacity: 1, filter: "blur(0px)", transitionEnd: { filter: "none" } }}
              transition={{ duration: reduceMotion ? duration * 0.6 : duration, ease: ease.out }}
            >
              {token}
            </motion.span>
          ))}
          {streaming && caret ? (
            <motion.span
              aria-hidden="true"
              className="ml-0.5 inline-block size-[0.55em] rounded-full bg-current align-[0.05em]"
              animate={reduceMotion ? { opacity: [0.35, 1, 0.35] } : { opacity: [0.35, 1, 0.35], scale: [0.8, 1, 0.8] }}
              transition={{ duration: 1.1, repeat: Infinity, ease: "easeInOut" }}
            />
          ) : null}
        </div>
      );
    }
    
    export interface TextStreamOptions {
      /** Average words per second. @default 30 */
      wordsPerSecond?: number;
      /** Start streaming; flip to true later to begin. @default true */
      enabled?: boolean;
    }
    
    /** Replays `source` as if it were arriving from a model, in uneven chunks. Handy for prototypes and demos. */
    export function useTextStream(source: string, { wordsPerSecond = 30, enabled = true }: TextStreamOptions = {}) {
      const [text, setText] = useState("");
      const [run, setRun] = useState(0);
      const [current, setCurrent] = useState(source);
      if (current !== source) {
        setCurrent(source);
        setText("");
      }
    
      useEffect(() => {
        if (!enabled) return;
        const words = source.match(/\S+\s*|\s+/g) ?? [];
        let shown = 0;
        let timer = 0;
        const push = () => {
          // Real streams arrive in uneven chunks of one to three words.
          const take = 1 + Math.floor(Math.random() * 3);
          shown = Math.min(words.length, shown + take);
          setText(words.slice(0, shown).join(""));
          if (shown < words.length) timer = window.setTimeout(push, (take / wordsPerSecond) * 1000 * (0.6 + Math.random() * 0.8));
        };
        timer = window.setTimeout(push, 250);
        return () => window.clearTimeout(timer);
      }, [source, wordsPerSecond, enabled, run]);
    
      return {
        text,
        streaming: enabled && text.length < source.length,
        /** Clear the text and stream it again. */
        restart: () => {
          setText("");
          setRun((n) => n + 1);
        },
      };
    }

Usage

import { StreamingText } from "@/components/ui/streaming-text/streaming-text";

export function Answer({ text, done }: { text: string; done: boolean }) {
  // Append chunks to `text` as they arrive from your model.
  return <StreamingText text={text} streaming={!done} aria-live="polite" />;
}

// Prototyping without a model? Replay a string as a stream:
import { useTextStream } from "@/components/ui/streaming-text/streaming-text";
const { text, streaming, restart } = useTextStream("Hello there…");

API reference

PropTypeDefaultDescription
text*string–Text received so far. Append to it as chunks arrive; only the new part animates.
streamingbooleanfalseMore text is on the way: shows the caret and sets aria-busy.
granularity"word" | "character""word"Animate each new word or each new character.
durationnumber0.5Seconds each new token takes to resolve.
blurnumber6Starting blur in px for new tokens.
caretbooleantrueShow a pulsing caret after the text while streaming.

Also accepts every native <div> attribute, including ref and className.

  • Only text appended since the last render animates. If `text` is replaced by something that doesn't start with the old text (regenerate), it starts over.
  • Tokens that finished animating collapse into one plain text node, so long answers don't keep hundreds of animated spans around.
  • Whitespace and newlines are preserved (white-space: pre-wrap). For Markdown, stream into your renderer and animate the last block instead.

Accessibility

  • Sets aria-busy while streaming. Add aria-live="polite" if the answer should be announced; with aria-busy set, screen readers wait for the full answer instead of reading fragments.
  • The caret is decorative and aria-hidden.
  • With reduced motion enabled, new tokens fade in without blur.