AI output
Streaming answer
An AI answer that streams in through the shared streaming text with inline citation chips, then offers copy, regenerate, feedback, a sources count and follow-up questions, where the actions rise in once the text has settled and small swaps and pops confirm each action.
Live preview
Loading preview
An answer about a new app build (the Figma copy) streams in uneven bursts over two paragraphs with two citation chips while the sources count rolls up to 3; when it is done the actions rise in and two follow-up questions show. A second answer stops with an error, keeps its actions and offers Try again. Replay streams both again.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add streaming-answerUsage
The demo above, as code.
'use client';
import * as React from 'react';
import type { StreamingContent, StreamingTextStatus } from '../streaming-text/streaming-text';
import { StreamingAnswer, type StreamingAnswerSource } from './streaming-answer';
type Step =
| { at: number; add: StreamingContent[number] }
| { at: number; status: StreamingTextStatus }
| { at: number; source: StreamingAnswerSource };
const SOURCES: StreamingAnswerSource[] = [
{ name: 'Build log', href: 'https://example.com/builds/218' },
{ name: 'Bundle report', href: 'https://example.com/bundle' },
{ name: 'Release notes', href: 'https://example.com/release' },
];
const BUILD: Step[] = [
{ at: 300, add: 'The new build is ready to test. ' },
{ at: 560, add: { type: 'citation', label: 'build-log', href: 'https://example.com/builds/218' } },
{ at: 600, source: SOURCES[0] as StreamingAnswerSource },
{ at: 700, add: ' The first screen now loads in 1.2 seconds, about 30 percent faster' },
{ at: 1700, add: ' than last week, and the main bundle is 18 KB smaller.' },
{ at: 1750, source: SOURCES[1] as StreamingAnswerSource },
{ at: 2100, add: '\n\nTo try it, open the preview link on your phone ' },
{ at: 2350, add: { type: 'citation', label: 'release-notes', href: 'https://example.com/release' } },
{ at: 2400, source: SOURCES[2] as StreamingAnswerSource },
{ at: 2500, add: ' and sign in with your test account.' },
{ at: 2900, status: 'done' },
];
const CUT: Step[] = [
{ at: 400, add: 'The new build is ready to test. ' },
{ at: 700, add: { type: 'citation', label: 'build-log' } },
{ at: 720, source: SOURCES[0] as StreamingAnswerSource },
{ at: 900, add: ' The first screen now loads in 1.2 seconds, about 30 percent faster' },
{ at: 1700, status: 'error' },
];
const FOLLOW_UPS = ['How do I test it on my phone?', 'What made the bundle smaller?'];
function finalState(steps: Step[]) {
const state = {
content: [] as StreamingContent,
status: 'streaming' as StreamingTextStatus,
sources: [] as StreamingAnswerSource[],
};
for (const step of steps) apply(state, step);
return state;
}
function apply(state: ReturnType<typeof finalState>, step: Step) {
if ('add' in step) state.content = [...state.content, step.add];
else if ('status' in step) state.status = step.status;
else state.sources = [...state.sources, step.source];
}
function useAnswer(steps: Step[]) {
const [state, setState] = React.useState(() => finalState(steps));
const timers = React.useRef<number[]>([]);
const play = React.useCallback(() => {
for (const id of timers.current) window.clearTimeout(id);
setState({ content: [], status: 'streaming', sources: [] });
timers.current = steps.map((step) =>
window.setTimeout(
() =>
setState((s) => {
const next = { ...s };
apply(next, step);
return next;
}),
step.at,
),
);
}, [steps]);
React.useEffect(
() => () => {
for (const id of timers.current) window.clearTimeout(id);
},
[],
);
return { state, play };
}
const plainText = (content: StreamingContent) =>
content.map((item) => (typeof item === 'string' ? item : '')).join('');
export default function StreamingAnswerDemo({ replay = 0 }: { replay?: number }) {
const build = useAnswer(BUILD);
const cut = useAnswer(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-12">
<StreamingAnswer
content={build.state.content}
status={build.state.status}
sources={build.state.sources}
copyText={plainText(build.state.content)}
followUps={FOLLOW_UPS}
actionsDisabled={build.state.status === 'streaming'}
onRegenerate={build.play}
/>
<StreamingAnswer
content={cut.state.content}
status={cut.state.status}
sources={cut.state.sources}
copyText={plainText(cut.state.content)}
actionsDisabled={cut.state.status === 'streaming'}
onRetry={cut.play}
onRegenerate={cut.play}
/>
</div>
);
}
API
Parts: root (three parts: text, actions, follow-ups), text (streaming-text component: paragraphs, citation chips, caret, stopped and error lines), actions row, copy button (copy icon and drawn check, same slot), regenerate button, feedback buttons (thumb up, thumb down), sources button (up to 3 dots or icons, count number roll, word), status region (visually hidden), follow-ups (divider, heading, list of buttons).
| Prop | Type | Default | Description |
|---|---|---|---|
| content | Array<string | { type: "citation"; label: string; href?: string }> | [] | The answer so far, with inline citations. Append to it as tokens arrive; a blank line starts a new paragraph. |
| status | "streaming" | "done" | "stopped" | "error" | "done" | Where the stream is. The caret shows only while streaming. |
| copyText | string | undefined | Text the copy button puts on the clipboard; the button hides when not set. |
| feedback | "up" | "down" | null | undefined | Controlled feedback, for example loaded from your database. |
| defaultFeedback | "up" | "down" | null | null | Feedback at first when not controlled. |
| sources | Array<{ name: string; href?: string; icon?: Node; color?: string }> | undefined | Sources used; the first 3 show as dots or icons next to the count. |
| actionsDisabled | boolean | false | Turns off the action buttons, for example while the answer still streams. |
| followUps | string[] | [] | Suggested next questions. |
| followUpsLabel | string | "Follow-up questions" | Heading above the follow-ups. |
| Event | Description |
|---|---|
| retry | The person pressed Try again after an error. The Try again button shows only when this has a listener. |
| regenerate | The person asked for a new answer. The button shows only when this has a listener. |
| feedbackChange | The person rated the answer, or pressed the same thumb again to clear it. Payload: "up", "down" or null. |
| copy | The answer text was put on the clipboard. Payload: the text. |
| sourcesClick | The person pressed the sources count. Without a listener the count is plain text. |
| followUpSelect | The person chose a follow-up question. Payload: the question. |
| Slot | Description |
|---|---|
| default | Your own renderer (for example Markdown) for the answer text instead of content. |
| sourceIcon | Scoped slot for a source icon, for example a site icon. |
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 |
|---|---|---|---|---|---|---|
| SAN1 | Text arrives, pauses, stops or ends | text (streaming-text component) | reveal, citation chip, caret, stopped and error lines: streaming-text rows STX1 to STX8 | see streaming-text → see streaming-text | duration-md (260)ease-out | Shows at once |
| SAN14 | streaming-text settle event | actions row, follow-ups | opacity, translate y | 0, distance-rise → 1, 0 | duration-lg (320)ease-settledelay duration-2xs (120) | Shows at once |
| SAN15 | streaming-text settle event | follow-up button | opacity, translate y | 0, distance-rise → 1, 0 | duration-lg (320)ease-settledelay duration-2xs + stagger-step x row | Shows at once |
| SAN5 | Copy pressed, and back after the wait | copy icon and check | opacity, blur | 0, blur-swap → 1, 0 (the leaving icon runs back) | duration-md (260)ease-out | Swaps at once |
| SAN6 | Copy pressed, and back after the wait | copy icon and check | scale | scale-swap → 1 (the leaving icon runs back) | duration-xl (380)ease-out | Swaps at once |
| SAN7 | Copy pressed | check | stroke drawn | 0% → 100% | duration-xl (380)ease-outdelay 100ms | Shows at once |
| SAN8 | Copied state ends | copy button | swap back to the copy icon (SAN5, SAN6) | check → copy icon | duration-md (260)ease-outdelay duration-loop (1600) | Swaps at once |
| SAN9 | A thumb is chosen | thumb icon | scale, rotation | 0.8, -8deg → 1, 0deg | duration-2xl (420)ease-throw | Stays still |
| SAN10 | A thumb is chosen or cleared | thumb icon | fill | transparent → currentColor | duration-sm (200)ease-out | Swaps at once |
| SAN11 | Hover, or pressed state change | action button | background color, icon color | transparent, fg-quaternary → bg-primary_hover, fg-secondary | duration-xs (160)ease-out | Swaps at once |
| SAN12 | Number of sources changes | sources count | number roll | old count → new count | duration-5xl (640)ease-out | Swaps at once |
| SAN13 | Hover | sources button, follow-up button | background color, text color | transparent, text-tertiary → bg-primary_hover, text-primary | duration-xs (160)ease-out | Swaps at once |
Accessibility
- The text follows streaming-text: aria-busy="true" while streaming, not a live region, and one polite line when the answer starts and one when it ends.
- The source dots and the follow-up arrow are aria-hidden.
- Action buttons are icon buttons with names ("Copy answer", "Regenerate", "Helpful", "Not helpful"); the thumbs use aria-pressed; disabled buttons are disabled, not only dimmed.
- A visually hidden role="status" region outside the buttons says "Copied", so the button keeps its own name.
- Citation chips, the caret and the stopped and error lines follow streaming-text.
- The actions row stays out of the tab order and inert until the settle event, so keyboard users do not reach buttons that are still invisible.
- Buttons are 28px; every control has a visible focus ring.
- Under reduced motion the text follows streaming-text, the actions and follow-ups appear at once, the thumb fills at once without the pop and the copy check shows at once.