AI
Streaming text
Shows text that arrives over time: new characters fade in and screen readers hear whole sentences.
- Built on
aria-live@starting-style- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 2.9 kB
- Dependencies
- React
The rollback is safe. Build 411 is still warm on eu-west-1.
Code
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/equip/button";
import { StreamingText } from "@/components/equip/streaming-text";
const answer = "The rollback is safe. Build 411 is still warm on eu-west-1.";
export default function Demo() {
const [shown, setShown] = useState(answer.length);
const streaming = shown < answer.length;
useEffect(() => {
if (!streaming) return;
const timer = setTimeout(() => setShown((n) => Math.min(n + 3, answer.length)), 45);
return () => clearTimeout(timer);
}, [streaming, shown]);
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 8, width: 224 }}>
<StreamingText text={answer.slice(0, shown)} streaming={streaming} style={{ minHeight: "3lh", fontSize: "var(--eq-text-m)", lineHeight: 1.5 }} />
<Button variant="outline" size="s" onClick={() => setShown(0)}>
Replay
</Button>
</div>
);
}Examples
Uneven chunks
Real streams arrive in bursts of different sizes. Only the newest burst fades in; earlier text never re-renders or flickers.
Error rate on api-gateway rose to 4.2% at 14:02, right after build 412 went out. The new connection pool caps at 20, and peak traffic needs about 35. Raise pool.max or roll back to 411.
Code
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/equip/button";
import { StreamingText } from "@/components/equip/streaming-text";
const chunks = [
"Error rate on api-gateway ",
"rose to 4.2% ",
"at 14:02, ",
"right after build 412 went out. ",
"The new connection pool ",
"caps at 20, ",
"and peak traffic needs about 35. ",
"Raise pool.max ",
"or roll back to 411.",
];
export default function Example() {
const [count, setCount] = useState(chunks.length);
const streaming = count < chunks.length;
useEffect(() => {
if (!streaming) return;
const timer = setTimeout(() => setCount((n) => n + 1), 120 + (count % 3) * 90);
return () => clearTimeout(timer);
}, [streaming, count]);
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 12, maxWidth: 480 }}>
<StreamingText text={chunks.slice(0, count).join("")} streaming={streaming} style={{ minHeight: "4lh", lineHeight: 1.55 }} />
<Button variant="outline" size="s" onClick={() => setCount(0)}>
Stream again
</Button>
</div>
);
}Inline, without a cursor
As a span it can stream into a sentence that is already on the page, here a generated commit title.
Commit title: Raise health check timeout to 60s for api-gateway
Code
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/equip/button";
import { StreamingText } from "@/components/equip/streaming-text";
const generated = "Raise health check timeout to 60s for api-gateway";
export default function Example() {
const [shown, setShown] = useState(generated.length);
const streaming = shown < generated.length;
useEffect(() => {
if (!streaming) return;
const timer = setTimeout(() => setShown((n) => Math.min(n + 2, generated.length)), 40);
return () => clearTimeout(timer);
}, [streaming, shown]);
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 12 }}>
<p style={{ margin: 0 }}>
<strong>Commit title: </strong>
<StreamingText as="span" cursor={false} text={generated.slice(0, shown)} streaming={streaming} />
</p>
<Button variant="outline" size="s" onClick={() => setShown(0)}>
Generate again
</Button>
</div>
);
}Rich content
segments takes prose with inline elements between it, so code and source links land inside the reply as it streams.
Build 412 lowered pool.max from 50 to 20 (PR 2291). Peak traffic needs about 35 connections, so set it back to 40 in gateway.yaml or roll back to 411.
Code
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { Button } from "@/components/equip/button";
import { StreamingText } from "@/components/equip/streaming-text";
const mono = { fontFamily: "var(--eq-font-mono)", fontVariationSettings: "var(--eq-font-mono-settings)", fontSize: "0.92em" };
// Each entry is one step of the stream: a run of words, or a whole inline element.
const steps: ReactNode[] = [
"Build 412 lowered ",
<code key="pool" style={mono}>
pool.max
</code>,
" from 50 to 20 ",
<a key="pr" href="#pr-2291">
(PR 2291)
</a>,
". Peak traffic needs about 35 connections, ",
"so set it back to 40 ",
"in ",
<code key="file" style={mono}>
gateway.yaml
</code>,
" or roll back to 411.",
];
export default function Example() {
const [count, setCount] = useState(steps.length);
const streaming = count < steps.length;
useEffect(() => {
if (!streaming) return;
const timer = setTimeout(() => setCount((n) => n + 1), 140 + (count % 3) * 80);
return () => clearTimeout(timer);
}, [streaming, count]);
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 12, maxWidth: 480 }}>
<StreamingText segments={steps.slice(0, count)} streaming={streaming} style={{ minHeight: "3lh", lineHeight: 1.55 }} />
<Button variant="outline" size="s" onClick={() => setCount(0)}>
Stream again
</Button>
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
text | string | none | All the text so far. Append to it as chunks arrive; a string that does not extend the last one starts over. Pass this or segments. |
segments | ReactNode[] | none | Rich content in place of text: strings of prose with inline elements, such as citations or code, between them. Append entries or extend the last string as the stream arrives; each element counts as one step and fades in when it lands. |
streaming | boolean | none | True while more text is on its way. |
cursor | boolean | true | Shows a block cursor at the end while streaming. |
as | "p" | "div" | "span" | "p" | Element to render. Use span inside other prose. |
...rest | HTMLAttributes<HTMLElement> | none | Every global attribute, passed to the rendered element. |
Anatomy
| root | .eq-streaming-text | |
|---|---|---|
| visible text | .eq-streaming-text-visible | Hidden from assistive tech while streaming. |
| new chunk | .eq-streaming-text-new | One span per recent chunk; only these fade in. |
| inline element | .eq-streaming-text-node | With segments: wraps each element so it keeps its place, and its state, as later text arrives. |
| cursor | .eq-streaming-text-cursor | Blinks only while streaming. |
| live region | .eq-streaming-text-live | Visually hidden; receives finished sentences. |
Notes
- While streaming, the visible text is hidden from assistive tech and a polite live region receives each sentence once it is finished, so nothing is read in fragments.
- A sentence counts as finished when its full stop, question mark or exclamation mark is followed by a space, so a decimal like 3.5 is never split.
- When streaming ends the remaining words are announced, the full text is exposed normally, and the live copy is cleared a few seconds later so it is not read twice.
- Earlier text is never re-rendered or re-animated; only the newest chunks fade, and a fresh mount shows its text without any animation.
- With reduced motion the fade is instant and the cursor stays lit instead of blinking.
- With segments, the live region reads the prose only; inline elements are skipped there and can be reached in the full text once the stream ends.
- With segments, the visible content is inert while streaming, so links in it cannot be focused while assistive tech cannot see them.
- Keep segments inline, such as links, code or citation markers; an element is only ever whole, never cut mid-way.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/streaming-text.tsx190 lines
"use client";
import { useEffect, useState, type HTMLAttributes, type ReactNode } from "react";
import "./streaming-text.css";
type StreamingTextBase = HTMLAttributes<HTMLElement> & {
/** True while more text is on its way. */
streaming: boolean;
/** Shows a block cursor at the end while streaming. */
cursor?: boolean;
/** Element to render. Use span when the text sits inside other prose. */
as?: "p" | "div" | "span";
};
export type StreamingTextProps = StreamingTextBase &
(
| {
/** All the text so far. Append to it as chunks arrive; a string that does not extend the last one starts over. */
text: string;
segments?: undefined;
}
| {
text?: undefined;
/**
* Rich content so far: strings of prose with inline elements, such as citations, between them.
* Append entries or extend the last string as the stream arrives. Each element counts as one
* step of the stream and fades in when it arrives.
*/
segments: ReactNode[];
}
);
/** Stands in for one inline element when rich content is measured as text. */
const NODE = "\uFFFC";
/** The segments as one string: prose as is, each element as a single placeholder. */
function project(segments: ReactNode[]): string {
let out = "";
for (const seg of segments) {
if (seg == null || typeof seg === "boolean") continue;
out += typeof seg === "string" || typeof seg === "number" ? String(seg) : NODE;
}
return out;
}
/** The segments cut at the settled point and each fading chunk. Elements keep one stable wrapper. */
function renderSegments(segments: ReactNode[], chunks: number[], settledEnd: number): ReactNode[] {
const out: ReactNode[] = [];
let pos = 0;
segments.forEach((seg, i) => {
if (seg == null || typeof seg === "boolean") return;
if (typeof seg === "string" || typeof seg === "number") {
const str = String(seg);
const start = pos;
const end = pos + str.length;
pos = end;
const cuts = [settledEnd, ...chunks].filter((c) => c > start && c < end).sort((a, b) => a - b);
const points = [start, ...cuts, end];
for (let k = 0; k < points.length - 1; k++) {
const from = points[k];
const to = points[k + 1];
if (from === to) continue;
const piece = str.slice(from - start, to - start);
if (from < settledEnd) {
out.push(piece);
} else {
const chunk = chunks.filter((c) => c <= from).pop() ?? settledEnd;
out.push(
<span key={`${chunk}:${i}`} className="eq-streaming-text-new">
{piece}
</span>,
);
}
}
return;
}
const at = pos;
pos += 1;
out.push(
<span
key={`node:${i}`}
className={at >= settledEnd ? "eq-streaming-text-node eq-streaming-text-new" : "eq-streaming-text-node"}
>
{seg}
</span>,
);
});
return out;
}
/** Recently arrived chunks keep their own span so their fade can finish; older ones merge into plain text. */
const FADING_CHUNKS = 8;
/** End of the last finished sentence: a terminator followed by whitespace, so "3." waits for "3.5". */
function lastSentenceEnd(text: string): number {
let end = 0;
// A placeholder for an inline element (a citation after the full stop) may sit before the space.
for (const match of text.matchAll(/[.!?…]+["'”’)\]]*\uFFFC*(?=\s)/g)) end = (match.index ?? 0) + match[0].length;
return end;
}
type Track = {
text: string;
/** Offsets where each still-fading chunk starts. */
chunks: number[];
/** How much of the text has been handed to the live region. */
spoken: number;
/** Sentences in the live region, keyed by where they start. */
lines: { at: number; text: string }[];
};
export function StreamingText({
text: plain,
segments,
streaming,
cursor = true,
as: Tag = "p",
className,
...rest
}: StreamingTextProps) {
const rich = segments !== undefined;
// Rich content is tracked through its text projection, so a new array every render is fine.
const text = rich ? project(segments) : (plain ?? "");
const [track, setTrack] = useState<Track>(() => ({
text,
chunks: [],
spoken: streaming ? 0 : text.length,
lines: [],
}));
// Adjust during render so the new tail and the announcement land in the same frame as the text.
let next = track;
if (text !== next.text) {
next = text.startsWith(next.text)
? { ...next, text, chunks: [...next.chunks, next.text.length].slice(-FADING_CHUNKS) }
: { text, chunks: [], spoken: 0, lines: [] };
}
const target = streaming ? lastSentenceEnd(text) : text.length;
if (target > next.spoken) {
const said = text.slice(next.spoken, target);
const sentence = (rich ? said.replaceAll(NODE, "") : said).trim();
next = {
...next,
spoken: target,
lines: sentence ? [...next.lines, { at: next.spoken, text: sentence }] : next.lines,
};
}
if (next !== track) setTrack(next);
// Once the stream ends the visible text is exposed, so the live copy is cleared to avoid reading it twice.
const finished = !streaming && next.lines.length > 0;
useEffect(() => {
if (!finished) return;
const timer = setTimeout(() => setTrack((t) => ({ ...t, lines: [] })), 4000);
return () => clearTimeout(timer);
}, [finished]);
const settledEnd = next.chunks[0] ?? text.length;
return (
<Tag
{...rest}
className={["eq-streaming-text", className].filter(Boolean).join(" ")}
data-streaming={streaming || undefined}
>
{rich ? (
// Links inside are hidden from assistive tech while streaming, so they are inert until it ends.
<span className="eq-streaming-text-visible" aria-hidden={streaming || undefined} inert={streaming || undefined}>
{renderSegments(segments, next.chunks, settledEnd)}
{streaming && cursor && <span className="eq-streaming-text-cursor" />}
</span>
) : (
<span className="eq-streaming-text-visible" aria-hidden={streaming || undefined}>
{text.slice(0, settledEnd)}
{next.chunks.map((start, i) => (
<span key={start} className="eq-streaming-text-new">
{text.slice(start, next.chunks[i + 1] ?? text.length)}
</span>
))}
{streaming && cursor && <span className="eq-streaming-text-cursor" />}
</span>
)}
<span className="eq-streaming-text-live" aria-live="polite">
{next.lines.map((line) => (
<span key={line.at}>{line.text} </span>
))}
</span>
</Tag>
);
}components/equip/streaming-text.css49 lines
@layer equip {
.eq-streaming-text {
margin: 0;
color: inherit;
overflow-wrap: break-word;
}
.eq-streaming-text-new {
transition: opacity var(--eq-dur) var(--eq-ease);
}
@starting-style {
.eq-streaming-text-new {
opacity: 0;
}
}
/* A block the height of a capital, sitting on the baseline after the last character. */
.eq-streaming-text-cursor {
display: inline-block;
width: 0.5em;
height: 1.05em;
margin-inline-start: 1px;
vertical-align: -0.15em;
border-radius: 1px;
background: currentColor;
opacity: 0.7;
animation: eq-streaming-text-blink calc(var(--eq-dur-move) * 2.5) steps(1) infinite;
}
/* With motion off the duration is zero and the cursor simply stays lit. */
@keyframes eq-streaming-text-blink {
50% {
opacity: 0;
}
}
.eq-streaming-text-live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
}