Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Streaming text

New

An answer that streams in word by word behind a caret, with a cited source, actions, sources and follow-ups.

streaming-text/streaming-text.tsx
Live · interactive

Pistachio is your fastest-growing flavor — sales are up 23% this month and margins beat vanilla by 8 points. scoopdata.testStone-fruit flavors are trending in the same range.

Follow-ups

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • An assistant’s answer that is generated while the person waits: words appear as they arrive instead of after a spinner.
  • Answers built from sources, where each claim can carry its citation inline and the full list sits one press away.
  • A reply the person may want to copy, regenerate or rate, with those actions held back until the last word lands.
  • Chats and search answers that suggest what to ask next: the follow-ups rise in once the answer is complete.

When not to use

  • Text that is already known when the screen draws. Show it at once; a fake stream only slows reading.
  • Long documents or code. Stream into an editor or a code block that keeps its formatting and can scroll.
  • Answers that change their mind mid-way, where words already shown would be rewritten. Wait for the final text.
  • Status messages and errors. Use a toast or an inline message that says it in one go.

You can add this component with the shadcn CLI or by hand:

Into components/streaming-text of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/streaming-text.json

Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

reply.tsx
import { StreamingText } from './streaming-text';const sources = [  { name: 'Field Notes', domain: 'fieldnotes.test', href: 'https://fieldnotes.test/' },  { name: 'Weather Desk', domain: 'weatherdesk.test', href: 'https://weatherdesk.test/' },];export function Reply() {  return (    <StreamingText      answer={['Rain clears by noon, so the hike is on.', { cite: 1 }, 'Pack a layer for the ridge.']}      sources={sources}      followUps={['How long is the ridge loop', 'Where can we park']}      onFollowUp={(question) => console.log(question)}      autoplay={false}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

An assistant’s reply as it arrives: words appear one at a time behind a thin caret, a source chip pops in where it is cited, and when the last word lands the message actions, the sources and the follow-up questions fade back in. “10 sources” unfolds the list of what was read; Retry plays the answer again.

  • The answer is a list of parts, runs of words and citations, cut into tokens: one per word or citation. The stream shows one more token every 58 ms, so the sample’s 28 tokens take about 1.6 s.
  • Each word is a span of its own with its trailing space, so a new word never reflows the ones before it; the paragraph wraps with text-wrap: pretty, so its last lines settle as it grows.
  • The caret is a 2 × 12 px bar that fades in over 150 ms when a stream starts and leaves with the last word. A citation is an inset chip with the source’s tile and domain; it pops in from 95% scale in 250 ms.
  • While the answer streams, the actions and the follow-ups fade out over 400 ms and stop taking the pointer, and an open sources list folds away. With the last word they fade back, the list unfolds again (a 0fr to 1fr grid row, 300 ms), and the follow-ups rise 8 px into place in 350 ms, 90 ms apart.
  • The sources list is a disclosure: its button shows the first three sources’ tiles overlapping and the count; each row is a link whose name underlines from the left on hover or focus.
  • While on screen and untouched, the demo replays the answer every five seconds. A press or key in it hands it to the reader, until they leave it alone for six seconds.
  • Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop: a charcoal stage at night, a soft grey one by day.
PropTypeDefaultDescription
answerAnswerPart[]—The answer: strings of words, and { cite: n } to cite sources[n] with a chip. Defaults to a sample answer.
sourcesStreamingSource[]—What was read: { name, domain, href, icon? }. Without an icon a source gets a tile with its first letter.
sourceCountnumber—How many sources the button counts; 10 for the sample answer, otherwise the number of sources.
followUpsstring[]—Questions to ask next, under the answer.
followUpsLabelstring'Follow-ups'The heading over the follow-ups.
speednumber58Ms between two words of the stream.
defaultProgressnumber—Opens part-way through the stream (0 to 1 of the words), held there with its caret until Retry plays it again. Turns the demo off.
defaultOpenbooleanfalseOpens with the sources list unfolded. Setting it turns the demo off.
defaultVote'like' | 'dislike' | nullnullOpens with a thumb pressed. A vote turns the demo off.
onFollowUp(question: string) => void—Called with a follow-up’s words when it is pressed.
onAction(action: 'copy' | 'retry' | 'like' | 'dislike') => void—Called when a message action is pressed. Retry also replays the stream, copy puts the answer on the clipboard, and the thumbs toggle.
autoplaybooleantrueWhile on screen and untouched, the answer replays itself every five seconds.
colorScheme'light' | 'dark' | 'system'—Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
classNamestring—Extra class for the root.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

streaming-text.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react';import { useAutoplay } from './use-autoplay';import styles from './streaming-text.module.css';/* * Streaming text: an assistant's answer that arrives word by word behind a thin caret, with a * source cited inline, a row of message actions, the sources it read, and follow-up questions. * * - The answer is a list of parts: runs of words and citations. They are cut into tokens, one per *   word or citation, and the stream reveals one more token every `speed` ms (58 by default). Each *   word is a span of its own with its trailing space, so a revealed word never reflows the ones *   before it; the paragraph wraps "pretty", so the last lines settle as it grows. * - The caret is a 2 by 12 px bar that fades in when a stream starts and goes with the last word. *   A citation is a small chip (the source's tile and domain) that pops in as it arrives. * - While the answer streams, the actions and the follow-ups fade out (400 ms) and stop taking the *   pointer, and the sources list folds away. With the last word they fade back, the open sources *   list unfolds again (a 0fr to 1fr grid row, 300 ms), and the follow-ups rise in one by one, *   90 ms apart. * - Retry plays the stream again; copy puts the answer's text on the clipboard; the thumbs are *   toggles. "10 sources" opens the list of sources, each a link with its tile, name and domain.
streaming-text.module.css
/* Streaming text (streaming-text.tsx): a soft grey stage, or at night a charcoal one, with the   answer in a 380 px column in its middle. Sizes are the component's own (13 px answer, 12 px   details); the column narrows with the stage on a phone, and the stage grows taller when the   sources list opens.   Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it   inherits from the page (light unless the page says otherwise), or the one its colorScheme prop   sets (data-color-scheme). */.root {  /* The stage, raised and inset surfaces on it. */  --canvas: oklch(96.1% 0.002 247.84);  --surface: oklch(100% 0 0);  --inset: oklch(97.9% 0.002 247.839);  /* The words: the answer, the details, the quiet icons. */  --ink: oklch(24.7% 0.006 258.361);  --ink-2: oklch(50.6% 0.01 264.477);  --ink-3: oklch(69.5% 0.009 264.505);  /* Hovered rows and buttons. */  --hover: oklch(97% 0.002 247.839);  --hover-2: oklch(93.3% 0.003 247.86);  /* Hairlines: the stage's edge, the chips', the rule under each follow-up. */  --line: oklch(94.6% 0.003 264.542);  /* The ring around each tile on the sources button. */
use-autoplay.ts
'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(      ([entry]) => {        onScreen = entry.isIntersecting;
  • The answer is a plain paragraph; a polite status line says “Writing the answer…” and “Answer ready.”, so a screen reader is not read every word. The caret and the tiles are decorative (aria-hidden).
  • The actions are buttons named Copy, Retry, Good answer and Bad answer; the thumbs are toggles with aria-pressed, and copying is announced.
  • “10 sources” is a disclosure button (aria-expanded, aria-controls) for a labelled list of links; while folded the list is inert, so it is never tabbed into.
  • The citation chip is a link named “Source: …”. The follow-ups are buttons. Faded actions come back to full strength when focused, and every control has a visible focus ring.
  • Under reduced motion the demo never plays, Retry shows the answer at once, and nothing fades, pops or rises.