Lunia DS

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.

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-text

Usage

The demo above, as code.

demo.tsx
'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).

PropTypeDefaultDescription
contentArray<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.
caretbooleantrueShows the caret while streaming. Turn it off where the parent shows progress in another way, as selection-actions does.
pauseAfternumber900Time 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.
errorTextstring"The answer stopped before the end. Try again to get the full answer."Line shown when status is error.
EventDescription
retryThe person pressed Try again after an error. The Try again button shows only when this has a listener.
settleThe last arriving piece finished its reveal after status became done, so parents can show actions and follow-ups.
SlotDescription
defaultYour 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
STX1New text arrivesarriving pieceopacity0 → 1duration-md (260)ease-outShows at once
STX2New text arrivesarriving pieceblurblur-text → 0duration-lg (320)ease-outShows at once
STX3Citation arrivesinline citation chipopacity0 → 1duration-md (260)ease-outShows at once
STX4Citation arrivesinline citation chipscalescale-press → 1duration-xl (380)ease-settleShows at once
STX5Stream startscaretopacity0 → 1duration-xs (160)ease-outShows at once
STX6No new text for pauseAftercaretopacity blink (steps), repeats1 → 0duration-6xl (900)ease-linearNo loop
STX7Streaming to done, stopped or errorcaretopacity1 → 0duration-2xs (120)ease-exitHides at once
STX8Streaming to stopped or errorstatus lineopacity, translate y0, distance-rise → 1, 0duration-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.