interior[.]dev

Async·03.4

Streaming Text

Token by token with a caret.

streaming-text

Install

One dependency. The component is copied into your project, so the file is yours after that.

terminal
bun add motion

Usage

stats.tsx
"use client";

import { useState } from "react";
import { StreamingText, useStreamingText } from "@/components/interior/streaming-text";

export function AssistantReply({ reply }: { reply: string }) {
  const [settled, setSettled] = useState(false);

  return (
    <div className="rounded-[14px] border border-stone-200 p-4 dark:border-white/[0.16]">
      <StreamingText
        text={reply}
        tokensPerSecond={20}
        label="Assistant reply"
        onDone={() => setSettled(true)}
      />

      <div className="mt-3 flex gap-2">
        <button type="button" disabled={!settled} className="h-7 rounded-[6px] px-2.5 text-[11.5px]">
          Copy
        </button>
        <button type="button" disabled={!settled} className="h-7 rounded-[6px] px-2.5 text-[11.5px]">
          Regenerate
        </button>
      </div>
    </div>
  );
}

export function BareStream({ reply }: { reply: string }) {
  const { visible, status, skip } = useStreamingText({ text: reply, tokensPerSecond: 24 });

  return (
    <p onClick={skip} className="font-mono text-[12.5px]">
      {visible}
      {status === "done" ? null : ""}
    </p>
  );
}

Source

components/interior/streaming-text.tsx
"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

const CHARS_PER_TOKEN = 4;

const CROSSFADE = {
  type: "spring",
  stiffness: 260,
  damping: 34,
  mass: 0.8,
} as const;

const MAX_FRAME_DELTA = 64;

export type StreamingTextStatus = "idle" | "streaming" | "paused" | "done";

export type StreamingToken = { word: string; gap: string };

function tokenize(text: string): StreamingToken[] {
  const tokens: StreamingToken[] = [];
  for (const part of text.split(/(\s+)/)) {
    if (!part) continue;
    if (part.trim() === "") {
      const last = tokens[tokens.length - 1];
      if (last) last.gap += part;
      else tokens.push({ word: "", gap: part });
      continue;
    }
    tokens.push({ word: part, gap: "" });
  }
  return tokens;
}

export type UseStreamingTextOptions = {
  text: string;
  tokensPerSecond?: number;
  autoStart?: boolean;
  onDone?: () => void;
};

export function useStreamingText({
  text,
  tokensPerSecond = 18,
  autoStart = true,
  onDone,
}: UseStreamingTextOptions) {
  const reduced = useReducedMotion();
  const tokens = useMemo(() => tokenize(text), [text]);
  const total = text.length;

  const [index, setIndex] = useState(0);
  const [status, setStatus] = useState<StreamingTextStatus>(
    autoStart ? "streaming" : "idle",
  );

  const cursor = useRef(0);
  const finished = useRef(onDone);

  useEffect(() => {
    finished.current = onDone;
  }, [onDone]);

  const start = useCallback(() => {
    setStatus((s) => (s === "done" ? s : "streaming"));
  }, []);

  const pause = useCallback(() => {
    setStatus((s) => (s === "streaming" ? "paused" : s));
  }, []);

  const skip = useCallback(() => {
    cursor.current = total;
    setIndex(total);
    setStatus("done");
  }, [total]);

  const reset = useCallback(() => {
    cursor.current = 0;
    setIndex(0);
    setStatus(autoStart ? "streaming" : "idle");
  }, [autoStart]);

  useEffect(() => {
    cursor.current = 0;
    setIndex(0);
    setStatus(autoStart ? "streaming" : "idle");
  }, [text, autoStart]);

  useEffect(() => {
    if (status !== "streaming") return;
    if (reduced || cursor.current >= total) {
      cursor.current = total;
      setIndex(total);
      setStatus("done");
      return;
    }

    const interval = 1000 / Math.max(1, tokensPerSecond * CHARS_PER_TOKEN);
    let frame = 0;
    let last = performance.now();
    let carry = 0;

    const tick = (now: number) => {
      carry += Math.min(now - last, MAX_FRAME_DELTA);
      last = now;

      if (carry >= interval) {
        const advance = Math.floor(carry / interval);
        carry -= advance * interval;
        const next = Math.min(total, cursor.current + advance);
        cursor.current = next;
        setIndex(next);
        if (next >= total) {
          setStatus("done");
          return;
        }
      }

      frame = requestAnimationFrame(tick);
    };

    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [status, total, tokensPerSecond, reduced]);

  useEffect(() => {
    if (status === "done") finished.current?.();
  }, [status]);

  useEffect(() => {
    if (!reduced) return;
    cursor.current = total;
    setIndex(total);
    setStatus("done");
  }, [reduced, total]);

  const visible = useMemo(() => text.slice(0, index), [text, index]);

  return {
    tokens,
    index,
    total,
    status,
    visible,
    start,
    pause,
    skip,
    reset,
  };
}

export type StreamingTextProps = {
  text: string;
  tokensPerSecond?: number;
  autoStart?: boolean;
  showSkip?: boolean;
  label?: string;
  onDone?: () => void;
  className?: string;
};

export function StreamingText({
  text,
  tokensPerSecond = 18,
  autoStart = true,
  showSkip = true,
  label = "Streamed response",
  onDone,
  className = "",
}: StreamingTextProps) {
  const { visible, status, skip, start, reset } = useStreamingText({
    text,
    tokensPerSecond,
    autoStart,
    onDone,
  });
  const reduced = useReducedMotion();

  const done = status === "done";
  const blink = !reduced && (status === "idle" || status === "paused");


  const caret = (
    <span
      aria-hidden
      className="relative inline-block h-[1.1em] w-0 align-[-0.22em]"
    >
      <motion.span
        className="absolute inset-y-0 left-px block w-[2px] bg-stone-800 dark:bg-stone-100"
        initial={false}
        animate={blink ? { opacity: [1, 1, 0, 0] } : { opacity: done ? 0 : 1 }}
        transition={
          blink
            ? {
                duration: 1.06,
                times: [0, 0.45, 0.5, 0.95],
                repeat: Infinity,
                ease: "linear",
              }
            : CROSSFADE
        }
      />
    </span>
  );

  return (
    <div
      role="group"
      aria-label={label}
      aria-busy={status === "streaming"}
      className={`text-[13.5px] leading-relaxed text-stone-700 dark:text-stone-200 ${className}`}
    >
      <p aria-hidden className="relative whitespace-pre-line">
        <span className="invisible">{text}</span>

        <span className="absolute inset-0 whitespace-pre-line">
          {visible}
          {caret}
        </span>
      </p>

      <span role="status" aria-live="polite" className="sr-only">
        {done ? text : ""}
      </span>

      {showSkip ? (
        <div className="mt-2.5 flex justify-end">
          <button
            type="button"
            onClick={
              done
                ? () => {
                    reset();
                    start();
                  }
                : skip
            }
            aria-label={
              done
                ? `Replay ${label}`
                : "Skip to the end"
            }
            className="inline-grid h-7 place-items-center rounded-[6px] border border-stone-200 px-2.5 text-[11.5px] font-medium text-stone-500 transition-colors duration-150 hover:text-stone-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-400 dark:border-white/[0.16] dark:text-stone-400 dark:hover:text-stone-200 dark:focus-visible:ring-stone-500"
          >
            <motion.span
              aria-hidden
              className="col-start-1 row-start-1"
              initial={false}
              animate={{ opacity: done ? 0 : 1 }}
              transition={reduced ? { duration: 0 } : CROSSFADE}
            >
              Skip
            </motion.span>
            <motion.span
              aria-hidden
              className="col-start-1 row-start-1"
              initial={false}
              animate={{ opacity: done ? 1 : 0 }}
              transition={reduced ? { duration: 0 } : CROSSFADE}
            >
              Replay
            </motion.span>
          </button>
        </div>
      ) : null}
    </div>
  );
}

Props

text
string

The full string to stream. Changing it restarts the stream from the first token.

tokensPerSecond18
number

Reveal rate in words per second. Time is accumulated, not counted in frames, so the rate holds on a slow display.

autoStarttrue
boolean

When false the component sits at zero tokens with a blinking caret until start() is called on the hook.

showSkiptrue
boolean

Renders the keyboard-reachable skip control. Its row is present in every state, so hiding it is a layout decision, not a runtime one.

label"Streamed response"
string

Accessible name for the group and the basis of the skip button's label.

onDone
() => void

Fires once when the last token lands, including when reduced motion skips the stream entirely.

className""
string

Appended last on the root, which carries the type styles, so size, colour and measure are all overridable.