# Streaming text

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

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.

A React component by Bruno Delorence: https://brunodelorence.com/components/streaming-text. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: AI.

## Anatomy

1. **Answer**: Each word is its own span with its trailing space, so a new word never reflows the ones before it. The paragraph wraps pretty as it grows.
2. **Citation**: The source’s tile and domain on an inset chip, a link that pops in where the claim it backs ends.
3. **Message actions**: Copy, Retry and the two thumbs, 24 px targets. They fade out while the answer streams and back with its last word.
4. **Sources button**: The first three tiles stacked and the count of sources read. It folds the list below open and shut.
5. **Sources list**: A grid row that unfolds from 0fr to 1fr in 300 ms: one link per source, its name and domain. It folds away while a stream plays.
6. **Follow-up**: A question to ask next, on a row with a reply arrow. The rows rise in one by one, 90 ms apart, when the answer is done.

## 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.

## Installation

Install it with the shadcn CLI, into components/streaming-text of any React project:

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

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: 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

```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}
    />
  );
}
```

## States

- **Answer ready**: The whole answer with its citation, the actions, the sources button and the follow-ups. The status line says `Answer ready.`
- **Streaming**: Held half-way through: a 2 × 12 px caret after the last word, the actions and follow-ups faded out and out of reach.
- **Sources open**: The sources button pressed: the list unfolds under the actions, one link per source.
- **Rated**: A pressed thumb takes the full ink colour and stays; pressing it again clears it, pressing the other moves it.
- **No sources or follow-ups**: With empty `sources` and `followUps`, only the answer and the four actions remain.

## How it works

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

## API reference

- **answer** (`AnswerPart[]`): The answer: strings of words, and { cite: n } to cite sources[n] with a chip. Defaults to a sample answer.
- **sources** (`StreamingSource[]`): What was read: { name, domain, href, icon? }. Without an icon a source gets a tile with its first letter.
- **sourceCount** (`number`): How many sources the button counts; 10 for the sample answer, otherwise the number of sources.
- **followUps** (`string[]`): Questions to ask next, under the answer.
- **followUpsLabel** (`string`, default `'Follow-ups'`): The heading over the follow-ups.
- **speed** (`number`, default `58`): Ms between two words of the stream.
- **defaultProgress** (`number`): 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.
- **defaultOpen** (`boolean`, default `false`): Opens with the sources list unfolded. Setting it turns the demo off.
- **defaultVote** (`'like' | 'dislike' | null`, default `null`): Opens 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.
- **autoplay** (`boolean`, default `true`): While 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.
- **className** (`string`): Extra class for the root.

## Accessibility

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

## Source

### streaming-text/streaming-text.tsx

```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.
 * - While on screen and untouched, the demo replays the answer every five seconds. Any press or
 *   key in it hands it to the reader until they leave it alone for six seconds.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

export type StreamingSource = {
  /** The source's name, in the sources list. */
  name: string;
  /** Its domain, on the citation chip and in the list. */
  domain: string;
  /** Where it opens. */
  href: string;
  /** A square tile drawn at 12 to 16 px; without one, a tile with the name's first letter. */
  icon?: ReactNode;
};

/** Words, or a citation of `sources[cite]`. */
export type AnswerPart = string | { cite: number };

export type MessageAction = 'copy' | 'retry' | 'like' | 'dislike';

/** One token every SPEED ms while streaming. */
const SPEED = 58;
/** The demo replays the answer every CYCLE ms, the first time START ms after it comes on screen. */
const CYCLE = 5000;
const START = 150;

/* The sample answer's tiles, drawn on a 64-unit square. */
function ScoopTile() {
  return (
    <svg viewBox="0 0 64 64" aria-hidden="true">
      <rect width="64" height="64" rx="16" fill="#1f7a5f" />
      <circle cx="32" cy="26" r="11.5" fill="#bff3dd" />
      <path d="M18.5 35h27a13.5 13 0 0 1-27 0Z" fill="#fff" />
      <path
        d="M25 25.5a8 8 0 0 1 14 0"
        fill="none"
        stroke="#1f7a5f"
        strokeWidth="4"
        strokeLinecap="round"
      />
    </svg>
  );
}
function TrendTile() {
  return (
    <svg viewBox="0 0 64 64" aria-hidden="true">
      <rect width="64" height="64" rx="16" fill="#2f6fec" />
      <path
        d="m14 44 12-12 8 7 15-17"
        fill="none"
        stroke="#fff"
        strokeWidth="7"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
      <circle cx="49.5" cy="20" r="5.5" fill="#bfe0ff" />
    </svg>
  );
}
function BarsTile() {
  return (
    <svg viewBox="0 0 64 64" aria-hidden="true">
      <rect width="64" height="64" rx="16" fill="#e56d24" />
      <path d="M16 45V27h8v18Zm12 0V17h8v28Zm12 0V31h8v14Z" fill="#fff" />
      <path d="M15 50h34" stroke="#ffd6b8" strokeWidth="4" strokeLinecap="round" />
    </svg>
  );
}

const SAMPLE_SOURCES: StreamingSource[] = [
  {
    name: 'Scoop Data',
    domain: 'scoopdata.test',
    href: 'https://scoopdata.test/',
    icon: <ScoopTile />,
  },
  {
    name: 'Trends Index',
    domain: 'trendsindex.test',
    href: 'https://trendsindex.test/',
    icon: <TrendTile />,
  },
  {
    name: 'Market Basket',
    domain: 'marketbasket.test',
    href: 'https://marketbasket.test/',
    icon: <BarsTile />,
  },
];

const SAMPLE_ANSWER: AnswerPart[] = [
  'Pistachio is your fastest-growing flavor — sales are up 23% this month and margins beat vanilla by 8 points.',
  { cite: 0 },
  'Stone-fruit flavors are trending in the same range.',
];

const SAMPLE_FOLLOW_UPS = [
  'Which flavors sell best in winter',
  'Compare gelato and soft serve margins',
];

type Token = { word: string } | { cite: number };

function tokenize(answer: AnswerPart[]): Token[] {
  return answer.flatMap((part): Token[] =>
    typeof part === 'string'
      ? part
          .split(/\s+/)
          .filter(Boolean)
          .map((word) => ({ word }))
      : [part],
  );
}

/** The source's own tile, or one with its first letter. */
function Tile({ source, className }: { source: StreamingSource; className: string }) {
  return (
    <span className={className} aria-hidden="true">
      {source.icon ?? <span className={styles.letter}>{source.name.charAt(0)}</span>}
    </span>
  );
}

/* Action icons, drawn on a 24-unit square with a 1.8 stroke. */
const ICONS: Record<MessageAction | 'done', ReactNode> = {
  copy: (
    <>
      <rect x="8.75" y="8.75" width="12.25" height="12.25" rx="2.5" />
      <path d="M15.25 5.5V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v8.25a2 2 0 0 0 2 2h.5" />
    </>
  ),
  retry: <path d="M20.6 12.4a8.6 8.6 0 1 1-2.4-6.3M20.6 3.4v5.4h-5.4" />,
  like: (
    <path d="M7.2 10.4V21.6M2.6 11.6a1.2 1.2 0 0 1 1.2-1.2h3.4l3.6-7.1a.9.9 0 0 1 .8-.5 2.8 2.8 0 0 1 2.7 3.5l-.9 3.6h5.6a2 2 0 0 1 1.9 2.6l-2.2 7.6a2 2 0 0 1-1.9 1.5H3.8a1.2 1.2 0 0 1-1.2-1.2Z" />
  ),
  dislike: (
    <path
      transform="rotate(180 12 12)"
      d="M7.2 10.4V21.6M2.6 11.6a1.2 1.2 0 0 1 1.2-1.2h3.4l3.6-7.1a.9.9 0 0 1 .8-.5 2.8 2.8 0 0 1 2.7 3.5l-.9 3.6h5.6a2 2 0 0 1 1.9 2.6l-2.2 7.6a2 2 0 0 1-1.9 1.5H3.8a1.2 1.2 0 0 1-1.2-1.2Z"
    />
  ),
  done: <path d="m4.5 12.5 5 5 10-11" />,
};

const ACTION_LABELS: Record<MessageAction, string> = {
  copy: 'Copy',
  retry: 'Retry',
  like: 'Good answer',
  dislike: 'Bad answer',
};

export function StreamingText({
  answer = SAMPLE_ANSWER,
  sources = SAMPLE_SOURCES,
  sourceCount,
  followUpsLabel = 'Follow-ups',
  followUps = SAMPLE_FOLLOW_UPS,
  speed = SPEED,
  onFollowUp,
  onAction,
  autoplay = true,
  defaultProgress,
  defaultOpen = false,
  defaultVote = null,
  colorScheme,
  className = '',
}: {
  /** The answer: runs of words, and citations of a source by its index in `sources`. */
  answer?: AnswerPart[];
  /** The sources read for it; the first three show their tiles on the sources button. */
  sources?: StreamingSource[];
  /** How many sources the button counts; 10 for the sample answer, else the length of `sources`. */
  sourceCount?: number;
  /** The heading over the follow-ups. */
  followUpsLabel?: string;
  /** Questions to ask next. */
  followUps?: string[];
  /** Ms between two words of the stream. */
  speed?: number;
  /** Called with a follow-up's words when it is pressed. */
  onFollowUp?: (question: string) => void;
  /** Called when a message action is pressed (retry also replays the stream). */
  onAction?: (action: MessageAction) => void;
  /** While on screen and untouched, the answer replays itself every five seconds. */
  autoplay?: boolean;
  /**
   * Opens part-way through a stream (0 to 1 of the tokens), held there with its caret until Retry
   * plays it again. Any default* prop turns the demo off.
   */
  defaultProgress?: number;
  /** Opens with the sources list unfolded. */
  defaultOpen?: boolean;
  /** Opens with a thumb pressed. */
  defaultVote?: 'like' | 'dislike' | null;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const tokens = useMemo(() => tokenize(answer), [answer]);
  const total = tokens.length;
  /** How many tokens are on screen: all of them, except while a stream plays. */
  const [shown, setShown] = useState(() =>
    defaultProgress === undefined
      ? total
      : Math.round(Math.min(1, Math.max(0, defaultProgress)) * total),
  );
  /** A stream opened part-way by defaultProgress waits there until it is replayed. */
  const [held, setHeld] = useState(defaultProgress !== undefined);
  const streaming = shown < total;
  const [open, setOpen] = useState(defaultOpen);
  const [vote, setVote] = useState<'like' | 'dislike' | null>(defaultVote);
  const demo = autoplay && defaultProgress === undefined && !defaultOpen && !defaultVote;
  const [copied, setCopied] = useState(false);
  const [message, setMessage] = useState('');
  const copiedTimer = useRef(0);
  const count = sourceCount ?? (sources === SAMPLE_SOURCES ? 10 : sources.length);
  const plain = tokens
    .map((token) => ('word' in token ? token.word : `[${sources[token.cite]?.domain ?? ''}]`))
    .join(' ');

  const replay = () => {
    if (!total) return;
    setHeld(false);
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    if (reduced) {
      setShown(total);
      setMessage('Answer ready.');
      return;
    }
    setShown(0);
    setMessage('Writing the answer…');
  };

  // The stream: one more token every `speed` ms until all are shown.
  useEffect(() => {
    if (!streaming || held) return;
    const timer = window.setInterval(() => setShown((n) => Math.min(n + 1, total)), speed);
    return () => window.clearInterval(timer);
  }, [streaming, held, speed, total]);

  useEffect(() => {
    if (!streaming && message === 'Writing the answer…') setMessage('Answer ready.');
  }, [streaming, message]);

  // The demo: replay the answer soon after it comes on screen, then every CYCLE ms.
  const replayRef = useRef(replay);
  useEffect(() => {
    replayRef.current = replay;
  });
  useEffect(() => {
    if (!demo || !playing) return;
    let timer = window.setTimeout(function loop() {
      replayRef.current();
      timer = window.setTimeout(loop, CYCLE);
    }, START);
    return () => window.clearTimeout(timer);
  }, [demo, playing]);

  useEffect(() => () => window.clearTimeout(copiedTimer.current), []);

  const act = (action: MessageAction) => {
    onAction?.(action);
    if (action === 'retry') replay();
    else if (action === 'copy') {
      navigator.clipboard?.writeText(plain).then(
        () => {
          setCopied(true);
          setMessage('Copied.');
          window.clearTimeout(copiedTimer.current);
          copiedTimer.current = window.setTimeout(() => setCopied(false), 1500);
        },
        () => setMessage('Could not copy.'),
      );
    } else setVote((current) => (current === action ? null : action));
  };

  const listId = useId();

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDown={take}
      onKeyDown={take}
    >
      <div className={styles.frame}>
        <div className={styles.message} data-streaming={streaming || undefined}>
          <p className={styles.answer} data-part="answer">
            {tokens.slice(0, shown).map((token, index) => {
              if ('word' in token)
                return (
                  <span key={index} className={styles.word} data-part="word">
                    {token.word}{' '}
                  </span>
                );
              const source = sources[token.cite];
              if (!source) return null;
              return (
                <a
                  key={index}
                  className={styles.chip}
                  data-part="citation"
                  href={source.href}
                  target="_blank"
                  rel="noreferrer"
                  aria-label={`Source: ${source.name}`}
                >
                  <Tile source={source} className={styles.chipTile} />
                  <span>{source.domain}</span>
                </a>
              );
            })}
            {streaming && <span className={styles.caret} data-part="caret" aria-hidden="true" />}
          </p>

          <div className={styles.actions} data-part="actions">
            {(['copy', 'retry', 'like', 'dislike'] as const).map((action) => {
              const pressed =
                action === 'like' || action === 'dislike' ? vote === action : undefined;
              return (
                <button
                  key={action}
                  type="button"
                  className={styles.action}
                  data-part="action"
                  data-action={action}
                  aria-label={ACTION_LABELS[action]}
                  aria-pressed={pressed}
                  onClick={() => act(action)}
                >
                  <svg
                    width="15"
                    height="15"
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth="1.8"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    aria-hidden="true"
                  >
                    {action === 'copy' && copied ? ICONS.done : ICONS[action]}
                  </svg>
                </button>
              );
            })}
            {sources.length > 0 && (
              <button
                type="button"
                className={styles.sourcesButton}
                data-part="sources-button"
                aria-expanded={open}
                aria-controls={listId}
                onClick={() => setOpen((value) => !value)}
              >
                <span className={styles.stack}>
                  {sources.slice(0, 3).map((source) => (
                    <Tile key={source.href} source={source} className={styles.avatar} />
                  ))}
                </span>
                <span className={styles.count}>
                  {count} {count === 1 ? 'source' : 'sources'}
                </span>
              </button>
            )}
          </div>

          <div
            className={styles.fold}
            data-open={(open && !streaming) || undefined}
            inert={!(open && !streaming)}
          >
            <div className={styles.foldInner}>
              <ul className={styles.list} id={listId} aria-label="Sources" data-part="sources">
                {sources.map((source) => (
                  <li key={source.href}>
                    <a
                      className={styles.row}
                      data-part="source"
                      href={source.href}
                      target="_blank"
                      rel="noreferrer"
                    >
                      <Tile source={source} className={styles.rowTile} />
                      <span className={styles.rowName}>{source.name}</span>
                      <span className={styles.rowDomain}>{source.domain}</span>
                    </a>
                  </li>
                ))}
              </ul>
            </div>
          </div>

          {followUps.length > 0 && (
            <div className={styles.followUps} data-part="follow-ups">
              <p className={styles.followUpsLabel}>{followUpsLabel}</p>
              <ul className={styles.followUpList}>
                {followUps.map((question, index) => (
                  <li key={question}>
                    <button
                      type="button"
                      className={styles.followUp}
                      data-part="follow-up"
                      style={{ animationDelay: `${index * 90}ms` }}
                      onClick={() => onFollowUp?.(question)}
                    >
                      <svg
                        width="11"
                        height="11"
                        viewBox="0 0 24 24"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth="2"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                        aria-hidden="true"
                      >
                        <path d="M8.6 9.6 4.2 14l4.4 4.4M19.8 4.2v6.6a3.2 3.2 0 0 1-3.2 3.2H4.2" />
                      </svg>
                      {question}
                    </button>
                  </li>
                ))}
              </ul>
            </div>
          )}
        </div>
      </div>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### streaming-text/streaming-text.module.css

```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. */
  --ring: oklch(21% 0.034 263.436 / 0.1);
  --focus: #387bff;
  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
  --ease: cubic-bezier(0.4, 0, 0.2, 1);
  position: relative;
  container-type: inline-size;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  min-height: 272px;
  padding: 12px;
  overflow: hidden;
  overflow: clip;
  border-radius: 14px;
  background: var(--canvas);
  box-shadow: 0 0 0 1px var(--line);
  color: var(--ink);
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  font-size: 14px;
  line-height: 1.5;
  letter-spacing: -0.14px;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --canvas: light-dark(oklch(96.1% 0.002 247.84), oklch(23.1% 0.004 264.487));
    --surface: light-dark(oklch(100% 0 0), oklch(26% 0.006 271.191));
    --inset: light-dark(oklch(97.9% 0.002 247.839), oklch(24.3% 0.004 264.492));
    --ink: light-dark(oklch(24.7% 0.006 258.361), oklch(96.4% 0.002 247.839));
    --ink-2: light-dark(oklch(50.6% 0.01 264.477), oklch(73.1% 0.008 260.731));
    --ink-3: light-dark(oklch(69.5% 0.009 264.505), oklch(54.1% 0.01 264.484));
    --hover: light-dark(oklch(97% 0.002 247.839), oklch(28.9% 0.006 271.22));
    --hover-2: light-dark(oklch(93.3% 0.003 247.86), oklch(31.8% 0.007 274.747));
    --line: light-dark(oklch(94.6% 0.003 264.542), oklch(30.8% 0.006 258.354));
    --ring: light-dark(oklch(21% 0.034 263.436 / 0.1), oklch(100% 0 0 / 0.08));
  }
}
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}
:where(.root) *,
:where(.root) *::before,
:where(.root) *::after {
  box-sizing: border-box;
}
:where(.root) :where(p, ul) {
  margin: 0;
  padding: 0;
}
:where(.root) :where(ul) {
  list-style: none;
}
:where(.root) :where(button, a) {
  font: inherit;
  letter-spacing: inherit;
  color: inherit;
  text-decoration: none;
  -webkit-tap-highlight-color: transparent;
}
:where(.root) :where(button) {
  margin: 0;
  border: 0;
  padding: 0;
  background: none;
  cursor: pointer;
}
.root :where(button, a):focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}

.frame {
  width: 100%;
  max-width: 480px;
}
.message {
  width: 100%;
  max-width: 380px;
  min-height: 248px;
  margin: 0 auto;
}

/* The answer. */
.answer {
  font-size: 13px;
  line-height: 1.625;
  color: var(--ink);
  text-wrap: pretty;
  overflow-wrap: break-word;
}
.word {
  display: inline;
}
.caret {
  display: inline-block;
  width: 2px;
  height: 12px;
  margin-left: 2px;
  border-radius: 999px;
  background: var(--ink);
  transform: translateY(2px);
  animation: fade-in 150ms ease-out both;
}

/* A citation: the source's tile and domain, on an inset chip. */
.chip {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  height: 18px;
  margin-right: 4px;
  padding: 0 3px;
  border-radius: 5px;
  vertical-align: middle;
  background: var(--inset);
  box-shadow: 0 0 0 1px var(--line);
  color: var(--ink-2);
  font-family: JetBrains, 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
  font-size: 10.5px;
  line-height: 1.625;
  white-space: nowrap;
  translate: 0 -1px;
  transition:
    color 150ms var(--ease),
    background-color 150ms var(--ease);
  animation: pop-in 250ms var(--ease-out) both;
}

/* Tiles: square art clipped to a round square or a disc. */
.chipTile,
.avatar,
.rowTile {
  display: block;
  flex: none;
  overflow: hidden;
}
.chipTile svg,
.avatar svg,
.rowTile svg {
  display: block;
  width: 100%;
  height: 100%;
}
.chipTile {
  width: 12px;
  height: 12px;
  border-radius: 3px;
}
.letter {
  display: grid;
  place-items: center;
  width: 100%;
  height: 100%;
  background: var(--ink-3);
  color: var(--canvas);
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  font-size: 0.6em;
  font-weight: 600;
  line-height: 1;
}

/* Message actions and the sources button. */
.actions,
.followUps {
  transition: opacity 400ms var(--ease);
}
.message[data-streaming] .actions,
.message[data-streaming] .followUps {
  opacity: 0;
  pointer-events: none;
}
.message[data-streaming] .actions:has(:focus-visible),
.message[data-streaming] .followUps:has(:focus-visible) {
  opacity: 1;
}
.actions {
  display: flex;
  align-items: center;
  gap: 2px;
  margin-top: 8px;
}
.action {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  border-radius: 6px;
  color: var(--ink-3);
  transition:
    color 100ms var(--ease),
    background-color 100ms var(--ease);
}
.action svg {
  display: block;
  width: 15px;
  height: 15px;
}
.action[aria-pressed='true'] {
  color: var(--ink);
}
.sourcesButton {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-left: 6px;
  padding: 2px 4px;
  border-radius: 6px;
  text-align: left;
  transition: background-color 150ms var(--ease);
}
.stack {
  display: flex;
}
.avatar {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--surface);
  box-shadow: 0 0 0 1px var(--ring);
}
.avatar + .avatar {
  margin-left: -4px;
}
.count {
  font-size: 12px;
  line-height: 1.5;
  color: var(--ink-2);
}

/* The sources list, folded into a grid row of 0fr until it opens. */
.fold {
  display: grid;
  grid-template-rows: 0fr;
  opacity: 0;
  transition:
    grid-template-rows 300ms var(--ease-out),
    opacity 300ms var(--ease-out);
}
.fold[data-open] {
  grid-template-rows: 1fr;
  opacity: 1;
}
.foldInner {
  min-height: 0;
  overflow: hidden;
}
.list {
  display: flex;
  flex-direction: column;
  margin-top: 6px;
  padding: 4px;
  border-radius: 10px;
  background: var(--inset);
  box-shadow: 0 0 0 1px var(--line);
}
.row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 4px 6px;
  border-radius: 6px;
  color: var(--ink-2);
  font-size: 12px;
  line-height: 1.5;
  transition:
    color 150ms var(--ease),
    background-color 150ms var(--ease);
}
.rowTile {
  width: 16px;
  height: 16px;
  border-radius: 4px;
}
.rowName {
  position: relative;
  display: inline-block;
  min-width: 0;
}
/* The name's underline draws in from the left on hover or focus. */
.rowName::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: -1px;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: 0 50%;
  transition: transform 280ms cubic-bezier(0.16, 1, 0.3, 1);
}
.row:focus-visible .rowName::after {
  transform: scaleX(1);
}
.rowDomain {
  flex: none;
  margin-left: auto;
  color: var(--ink-3);
  font-family: JetBrains, 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
  font-size: 10.5px;
  line-height: 1.5;
}

/* Follow-ups. */
.followUps {
  margin-top: 10px;
}
.followUpsLabel {
  color: var(--ink-2);
  font-size: 12px;
  font-weight: 500;
  line-height: 1.5;
}
.followUpList {
  display: flex;
  flex-direction: column;
  margin-top: 2px;
}
.followUp {
  display: flex;
  align-items: center;
  gap: 8px;
  width: calc(100% + 12px);
  margin: 0 -6px;
  padding: 6px;
  border-bottom: 1px solid var(--line);
  border-radius: 7px;
  color: var(--ink);
  font-size: 12.5px;
  line-height: 1.5;
  text-align: left;
  transition: background-color 100ms var(--ease);
  animation: fade-up 350ms var(--ease-out) both;
}
.followUp svg {
  flex: none;
  width: 11px;
  height: 11px;
  color: var(--ink-3);
}
.message[data-streaming] .followUp {
  opacity: 0;
  animation: none;
}
.message[data-streaming] .followUp:focus-visible {
  opacity: 1;
}

@media (hover: hover) {
  .chip:hover {
    background: var(--hover);
    color: var(--ink);
  }
  .action:hover {
    background: var(--hover-2);
    color: var(--ink-2);
  }
  .action[aria-pressed='true']:hover {
    color: var(--ink);
  }
  .sourcesButton:hover {
    background: var(--hover);
  }
  .row:hover {
    background: var(--hover);
    color: var(--ink);
  }
  .row:hover .rowName::after {
    transform: scaleX(1);
  }
  .followUp:hover {
    background: var(--hover-2);
  }
}

@keyframes fade-in {
  from {
    opacity: 0;
  }
}
@keyframes pop-in {
  from {
    opacity: 0;
    transform: scale(0.95);
  }
}
@keyframes fade-up {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
}

.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .caret,
  .chip,
  .followUp {
    animation: none;
  }
  .actions,
  .followUps,
  .fold,
  .rowName::after {
    transition: none;
  }
}
```

### streaming-text/use-autoplay.ts

```tsx
'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;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```