AI output
Streaming text
Text from a model that appears as the stream delivers it, where each new piece resolves out of a soft blur and a caret marks the end while more is coming.
Live preview
Loading preview
A short answer of two paragraphs with one inline citation arrives in uneven pieces, pauses once so the caret blinks, then settles; a second demo stops with an error and offers Try again. Replay restarts both.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add streaming-textUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { type StreamingContent, StreamingText, type StreamingTextStatus } from './streaming-text';
type Step = { at: number; add: StreamingContent[number] } | { at: number; status: StreamingTextStatus };
const BUILD: Step[] = [
{ at: 300, add: 'The new build is ' },
{ at: 520, add: 'ready to test. ' },
{ at: 760, add: { type: 'citation', label: 'build-log', href: 'https://example.com/builds/218' } },
{ at: 900, add: ' The first screen now loads in 1.2 seconds,' },
{ at: 1180, add: ' about 30 percent faster' },
{ at: 2500, add: ' than last week, and the main bundle is 18 KB smaller.' },
{ at: 2900, add: '\n\nTo try it, open the preview link on your phone' },
{ at: 3150, add: ' and sign in with your test account.' },
{ at: 3500, status: 'done' },
];
const CUT: Step[] = [
{ at: 400, add: 'The new build is ready to test. ' },
{ at: 700, add: { type: 'citation', label: 'build-log' } },
{ at: 860, add: ' The first screen now loads' },
{ at: 1100, add: ' in 1.2 seconds, about 30 percent faster' },
{ at: 1900, status: 'error' },
];
const finalContent = (steps: Step[]) => steps.flatMap((s) => ('add' in s ? [s.add] : []));
const finalStatus = (steps: Step[]) =>
steps.reduce<StreamingTextStatus>((last, s) => ('status' in s ? s.status : last), 'streaming');
function useStream(steps: Step[]) {
const [content, setContent] = React.useState<StreamingContent>(() => finalContent(steps));
const [status, setStatus] = React.useState<StreamingTextStatus>(() => finalStatus(steps));
const timers = React.useRef<number[]>([]);
const play = React.useCallback(() => {
for (const id of timers.current) window.clearTimeout(id);
setContent([]);
setStatus('streaming');
timers.current = steps.map((step) =>
window.setTimeout(() => {
if ('add' in step) setContent((list) => [...list, step.add]);
else setStatus(step.status);
}, step.at),
);
}, [steps]);
React.useEffect(
() => () => {
for (const id of timers.current) window.clearTimeout(id);
},
[],
);
return { content, status, play };
}
export default function StreamingTextDemo({ replay = 0 }: { replay?: number }) {
const build = useStream(BUILD);
const cut = useStream(CUT);
const plays = React.useRef({ build: build.play, cut: cut.play });
plays.current = { build: build.play, cut: cut.play };
React.useEffect(() => {
if (replay === 0) return;
plays.current.build();
plays.current.cut();
}, [replay]);
return (
<div className="flex w-full max-w-[480px] flex-col gap-10">
<StreamingText content={build.content} status={build.status} />
<StreamingText content={cut.content} status={cut.status} onRetry={cut.play} />
</div>
);
}
API
Parts: root (aria-busy while streaming), paragraphs, settled text, arriving piece (blur and fade), inline citation chip (link or text, with a dot), caret, status line (stopped or error, with Try again), live region (visually hidden).
| Prop | Type | Default | Description |
|---|---|---|---|
| content | Array<string | { type: "citation"; label: string; href?: string }> | [] | The text so far, with inline citations. Append to it as pieces arrive; a blank line starts a new paragraph. |
| status | "streaming" | "done" | "stopped" | "error" | "done" | Where the stream is. The caret shows only while streaming. |
| caret | boolean | true | Shows the caret while streaming. Turn it off where the parent shows progress in another way, as selection-actions does. |
| pauseAfter | number | 900 | Time in ms with no new text after which the caret starts to blink, to show the model is still working. |
| announce | "done" | "off" | "done" | What the live region says. "done" says one short line when the stream starts and one when it ends; "off" leaves announcements to the parent. |
| errorText | string | "The answer stopped before the end. Try again to get the full answer." | Line shown when status is error. |
| Event | Description |
|---|---|
| retry | The person pressed Try again after an error. The Try again button shows only when this has a listener. |
| settle | The last arriving piece finished its reveal after status became done, so parents can show actions and follow-ups. |
| Slot | Description |
|---|---|
| default | Your own renderer (for example Markdown) instead of content; the reveal and caret still apply to its last text node. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| STX1 | New text arrives | arriving piece | opacity | 0 → 1 | duration-md (260)ease-out | Shows at once |
| STX2 | New text arrives | arriving piece | blur | blur-text → 0 | duration-lg (320)ease-out | Shows at once |
| STX3 | Citation arrives | inline citation chip | opacity | 0 → 1 | duration-md (260)ease-out | Shows at once |
| STX4 | Citation arrives | inline citation chip | scale | scale-press → 1 | duration-xl (380)ease-settle | Shows at once |
| STX5 | Stream starts | caret | opacity | 0 → 1 | duration-xs (160)ease-out | Shows at once |
| STX6 | No new text for pauseAfter | caret | opacity blink (steps), repeats | 1 → 0 | duration-6xl (900)ease-linear | No loop |
| STX7 | Streaming to done, stopped or error | caret | opacity | 1 → 0 | duration-2xs (120)ease-exit | Hides at once |
| STX8 | Streaming to stopped or error | status line | opacity, translate y | 0, distance-rise → 1, 0 | duration-lg (320)ease-settledelay duration-2xs (120) | Shows at once |
Accessibility
- The root has aria-busy="true" while streaming and is not a live region, so screen readers do not read every piece as it arrives.
- A separate visually hidden role="status" region (polite) says "Writing an answer" once when the stream starts and "Answer ready" once when it ends; on stop it says "Stopped", on error it says the error line.
- The blur is visual only: the arriving piece is real text in the DOM, selectable and read in order, never a copy.
- The caret is aria-hidden.
- Citation chips that are links open in a new tab and carry a visually hidden "(opens in a new tab)".
- Error and stopped lines use an icon plus words, never color alone; Try again is a real button with a visible focus ring and a 24px minimum target.
- Text color is text-primary on bg-primary; the blur never changes color, so contrast stays at the full value once the piece settles.