# Streaming Text

> Streamed answer with inline citations, sources, actions and follow-ups.

- **Slug:** `streaming-text`
- **Kind:** component · **Category:** Agent
- **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/primitives/StreamingText.tsx) (MIT)
- **Docs page:** https://viewership.ai/ui/components/streaming-text
- **All files in one bundle:** https://viewership.ai/ui/components/streaming-text.bundle.txt
- **Structured JSON:** https://viewership.ai/ui/components/streaming-text.json

## Install

1. Import the foundation tokens once (download: https://viewership.ai/ui/raw/foundation.css.txt):

```astro
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
---
```

2. Copy this file into `src/components/viewership/streaming-text/`:

   - `StreamingText.astro` (raw: https://viewership.ai/ui/raw/streaming-text/StreamingText.astro.txt)

npm packages: none.

## Usage

```astro
---
import StreamingText from "../components/viewership/streaming-text/StreamingText.astro";
---

<StreamingText />
```

## Example

The gallery demo, verbatim. Use the import path from Usage instead of `./`.

```astro
---
import StreamingText from "./StreamingText.astro";
import type { StreamingSource } from "./StreamingText.astro";

/* small inline favicons so the demo needs no network */
const icon = (svg: string) => `data:image/svg+xml,${encodeURIComponent(svg)}`;
const ICONS = {
  orders: icon(
    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="16" fill="#7a4a2a"/><path d="M17 45V27h8v18zm11 0V18h8v27zm11 0V31h8v14z" fill="#fff"/><path d="M16 49h32" stroke="#e9cdb4" stroke-width="4" stroke-linecap="round"/></svg>`,
  ),
  geo: icon(
    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="16" fill="#d9692b"/><path d="M32 13c3 8 12 12 12 23a12 12 0 0 1-24 0c0-6 3-9 6-12 0 5 2 8 5 8-2-7 0-13 1-19z" fill="#fff"/></svg>`,
  ),
  survey: icon(
    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="16" fill="#2f7a5b"/><path d="M18 26h24v9a12 12 0 0 1-24 0z" fill="#fff"/><path d="M42 29h3a5 5 0 0 1 0 10h-4" fill="none" stroke="#fff" stroke-width="4"/><path d="M16 50h30" stroke="#bfe6d3" stroke-width="4" stroke-linecap="round"/></svg>`,
  ),
};

const sources: StreamingSource[] = [
  { name: "GA4 sessions, September", domain: "ga4-sep.csv", image: ICONS.orders },
  { name: "AI citations, weeks 36–41", domain: "geo-log.md", image: ICONS.geo },
  { name: "Customer survey", domain: "nw-survey.pdf", image: ICONS.survey },
];

const answer =
  "The trail pack guide is your fastest-growing page. Organic sessions are up 23% this month, and it converts 9 points better than the category page. [1] Gemini and Perplexity now cite it too.";
---

<StreamingText
  content={answer}
  sources={sources}
  followUps={["Which pages get cited most", "Compare paid and organic conversion"]}
  labels={{ sources: "8 sources" }}
/>
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `content` | `string \| ({ text: string } \| { cite: number })[]` |  | The answer. In a string, [n] cites sources[n - 1]; in tokens, cite is 0-based. |
| `sources` | `{ name: string; domain: string; href?: string; image?: string }[]` | `[]` | Cited sources. Without image, shows the first letter. |
| `followUps` | `string[]` | `[]` | Shown when done. Click fires vs-streaming-text:follow-up. |
| `labels` | `Partial<{ sources; followUps; copy; regenerate; good; bad }>` |  | Sources toggle, follow-ups heading and action button names. |
| `autoplay` | `boolean` | `true` | Stream when in view. If false or reduced motion, show the full answer. |
| `loop` | `boolean` | `true` | Restart after 3.4s (not while hovered or focused). Turn off in a real thread. |
| `fill` | `boolean` | `false` | Fill the parent instead of 380px. |

## Source

### `src/components/viewership/streaming-text/StreamingText.astro`

```astro
---
/**
 * StreamingText: a streamed answer with inline sources.
 *
 * Words resolve out of blur, citations pop in where cited, then the action
 * row, sources toggle and follow-ups become usable. `content` takes tokens,
 * or a string where a standalone `[n]` cites `sources[n - 1]`.
 *
 * Events (bubbling, on the root):
 *   vs-streaming-text:done       the stream finished
 *   vs-streaming-text:action     detail { action: "copy" | "regenerate" | "good" | "bad" }
 *   vs-streaming-text:follow-up  detail { text, index }
 *
 * Adapted from Beautiful UI StreamingText (MIT, © 2026 Shane Levine).
 *
 * @example
 * <StreamingText content="Organic traffic is up 12%. [1]" sources={sources} loop={false} />
 */
import type { HTMLAttributes } from "astro/types";

/** One streamed word. */
export interface StreamingWord {
  text: string;
  cite?: undefined;
}
/** A citation of `sources[cite]` (0-based), rendered as an inline chip. */
export interface StreamingCite {
  cite: number;
  text?: undefined;
}
export type StreamingToken = StreamingWord | StreamingCite;

export interface StreamingSource {
  name: string;
  /** Short mono label: a domain or a file name. */
  domain: string;
  href?: string;
  /** Avatar image URL (a favicon or data URI). Falls back to the first letter of `name`. */
  image?: string;
}

export interface StreamingLabels {
  /** Label on the sources toggle. Defaults to "N sources". */
  sources: string;
  /** Heading above the follow-up prompts. */
  followUps: string;
  copy: string;
  regenerate: string;
  good: string;
  bad: string;
}

type Props = HTMLAttributes<"div"> & {
  content: string | StreamingToken[];
  sources?: StreamingSource[];
  followUps?: string[];
  labels?: Partial<StreamingLabels>;
  /** Stream when scrolled into view. */
  autoplay?: boolean;
  /** Restart after a hold. Turn off when embedding in a real thread. */
  loop?: boolean;
  /** Fill the parent instead of the 380px reading measure. */
  fill?: boolean;
};

const {
  content,
  sources = [],
  followUps = [],
  labels,
  autoplay = true,
  loop = true,
  fill = false,
  class: className,
  ...rest
} = Astro.props;

const l: StreamingLabels = {
  sources: `${sources.length} source${sources.length === 1 ? "" : "s"}`,
  followUps: "Follow-ups",
  copy: "Copy",
  regenerate: "Regenerate",
  good: "Good response",
  bad: "Bad response",
  ...labels,
};

const tokens: StreamingToken[] =
  typeof content === "string"
    ? content
        .split(/\s+/)
        .filter(Boolean)
        .map((word) => {
          const cite = /^\[(\d+)\]$/.exec(word);
          return cite ? { cite: Number(cite[1]) - 1 } : { text: word };
        })
    : content;

const plainText = tokens
  .filter((t) => t.text !== undefined)
  .map((t) => t.text)
  .join(" ");

const ACTIONS = [
  { action: "copy", label: l.copy, icon: '<rect x="9" y="9" width="12" height="12" rx="2.5"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>' },
  { action: "regenerate", label: l.regenerate, icon: '<path d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6"/>' },
  { action: "good", label: l.good, toggle: true, icon: '<path d="M7 10v12M15 5.88L14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88z"/>' },
  { action: "bad", label: l.bad, toggle: true, icon: '<path d="M17 14V2M9 18.12L10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88z"/>' },
];

/* JSON for the script; escape "<" so the text can never close the <script> element */
const config = JSON.stringify({ text: plainText }).replaceAll("<", "\\u003c");
const panelId = `vs-streaming-${Math.random().toString(36).slice(2, 8)}`;
const initial = (name: string) => name.trim().charAt(0).toUpperCase();
---

<div
  class:list={["vs-streaming-text", fill && "is-fill", className]}
  data-autoplay={autoplay ? "" : undefined}
  data-loop={loop ? "" : undefined}
  data-done=""
  {...rest}
>
  <p class="answer" data-answer tabindex="-1">
    {tokens.map((token) => {
      if (token.cite === undefined) return <span class="token word" data-token>{token.text} </span>;
      const source = sources[token.cite];
      if (!source) return null;
      const Chip = source.href ? "a" : "span";
      return (
        <Chip
          class="token chip"
          data-token
          href={source.href}
          target={source.href ? "_blank" : undefined}
          rel={source.href ? "noreferrer" : undefined}
        >
          {source.image ? (
            <img class="avatar chip-avatar" src={source.image} alt="" width="12" height="12" />
          ) : (
            <span class="avatar chip-avatar is-letter" aria-hidden="true">{initial(source.name)}</span>
          )}
          <span>{source.domain}</span>
        </Chip>
      );
    })}
    <span class="caret" aria-hidden="true"></span>
  </p>
  <span class="visually-hidden" role="status" data-live></span>

  <div class="actions" data-after>
    {ACTIONS.map((a) => (
      <button
        type="button"
        class="icon-button"
        aria-label={a.label}
        title={a.label}
        data-action={a.action}
        aria-pressed={a.toggle ? "false" : undefined}
      >
        <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" set:html={a.icon} />
      </button>
    ))}
    {sources.length > 0 && (
      <button type="button" class="sources-toggle" aria-expanded="false" aria-controls={panelId} data-sources-toggle>
        <span class="stack" aria-hidden="true">
          {sources.map((source) =>
            source.image ? (
              <img class="avatar stack-avatar" src={source.image} alt="" width="14" height="14" />
            ) : (
              <span class="avatar stack-avatar is-letter">{initial(source.name)}</span>
            ),
          )}
        </span>
        <span class="sources-label">{l.sources}</span>
      </button>
    )}
  </div>

  {sources.length > 0 && (
    <div class="sources-panel" id={panelId} data-sources-panel>
      <div class="sources-clip" data-sources-clip>
        <div class="sources-list">
          {sources.map((source) => {
            const Row = source.href ? "a" : "div";
            return (
              <Row
                class="source"
                href={source.href}
                target={source.href ? "_blank" : undefined}
                rel={source.href ? "noreferrer" : undefined}
              >
                {source.image ? (
                  <img class="avatar source-avatar" src={source.image} alt="" width="16" height="16" />
                ) : (
                  <span class="avatar source-avatar is-letter" aria-hidden="true">{initial(source.name)}</span>
                )}
                <span class="source-name">{source.name}</span>
                <span class="source-domain">{source.domain}</span>
              </Row>
            );
          })}
        </div>
      </div>
    </div>
  )}

  {followUps.length > 0 && (
    <div class="follow-ups" data-after>
      <p class="follow-ups-heading">{l.followUps}</p>
      <div class="follow-ups-list">
        {followUps.map((text, i) => (
          <button type="button" class="follow-up" style={`--vs-st-i: ${i}`} data-follow-up={i}>
            <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
              <path d="M9 10l-5 5 5 5"></path>
              <path d="M20 4v7a4 4 0 0 1-4 4H4"></path>
            </svg>
            {text}
          </button>
        ))}
      </div>
    </div>
  )}

  <script is:inline type="application/json" data-vs-config set:html={config} />
</div>

<style>
  .vs-streaming-text {
    width: 100%;
    max-width: 380px;
    min-height: 15.5rem;
  }
  .vs-streaming-text.is-fill {
    max-width: none;
    min-height: 0;
  }

  /* ── answer ── */
  .answer {
    margin: 0;
    outline: none;
    font-size: 13px;
    line-height: 1.625;
    color: var(--vs-ink);
  }
  .token[hidden] {
    display: none;
  }
  /* each word resolves out of a soft blur as it arrives */
  [data-streaming] .word {
    animation: vs-streaming-text-blur-in 260ms var(--vs-ease-out) both;
  }
  @keyframes vs-streaming-text-blur-in {
    from {
      opacity: 0;
      filter: blur(4px);
    }
    to {
      opacity: 1;
      filter: blur(0);
    }
  }

  .chip {
    display: inline-flex;
    height: 18px;
    box-sizing: border-box;
    align-items: center;
    gap: 4px;
    margin: 0 4px 0 0;
    padding: 0 3px;
    border-radius: 5px;
    background: var(--vs-inset);
    box-shadow: var(--vs-shadow-hairline);
    color: var(--vs-ink-2);
    font-family: var(--vs-font-sans);
    font-variant-numeric: tabular-nums;
    font-size: 10.5px;
    line-height: 1;
    text-decoration: none;
    vertical-align: middle;
    translate: 0 -1px;
    transition:
      background-color 150ms ease-out,
      color 150ms ease-out;
  }
  [data-streaming] .chip {
    animation: vs-pop-in 250ms var(--vs-ease-out) both;
  }
  a.chip:hover {
    background: var(--vs-hover);
    color: var(--vs-ink);
  }

  .caret {
    display: none;
    width: 2px;
    height: 12px;
    margin-left: 2px;
    border-radius: var(--vs-radius-pill);
    background: var(--vs-ink);
    translate: 0 2px;
    animation: vs-fade-in 150ms ease-out both;
  }
  [data-streaming] .caret {
    display: inline-block;
  }

  /* favicon avatars */
  .avatar {
    flex-shrink: 0;
    object-fit: cover;
    box-shadow: 0 0 0 1px color-mix(in oklab, var(--vs-ink) 9%, transparent);
  }
  .avatar.is-letter {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    background: var(--vs-accent-tint);
    color: var(--vs-accent-ink);
    font-family: var(--vs-font-sans);
    font-weight: 600;
    line-height: 1;
  }
  .chip-avatar {
    width: 12px;
    height: 12px;
    border-radius: 3px;
    font-size: 7px;
  }
  .stack-avatar {
    width: 14px;
    height: 14px;
    border-radius: 50%;
    background: var(--vs-surface);
    font-size: 8px;
  }
  .source-avatar {
    width: 16px;
    height: 16px;
    border-radius: 4px;
    font-size: 9px;
  }

  /* ── revealed once the stream completes ── */
  .actions,
  .follow-ups {
    opacity: 0;
    pointer-events: none;
    transition: opacity 400ms ease-out;
  }
  [data-done] .actions,
  [data-done] .follow-ups {
    opacity: 1;
    pointer-events: auto;
  }

  .actions {
    display: flex;
    align-items: center;
    gap: 2px;
    margin-top: 8px;
  }
  .icon-button {
    display: flex;
    width: 24px;
    height: 24px;
    align-items: center;
    justify-content: center;
    padding: 0;
    border: none;
    border-radius: var(--vs-radius-chip);
    background: transparent;
    color: var(--vs-ink-3);
    cursor: pointer;
    transition:
      background-color 100ms ease-out,
      color 100ms ease-out;
  }
  .icon-button:hover {
    background: var(--vs-hover-2);
    color: var(--vs-ink-2);
  }
  .icon-button[aria-pressed="true"] {
    color: var(--vs-ink);
  }
  .icon-button[aria-pressed="true"] svg {
    fill: color-mix(in oklab, currentColor 14%, transparent);
  }
  .icon-button.is-copied {
    color: var(--vs-green);
  }

  .sources-toggle {
    display: flex;
    align-items: center;
    gap: 6px;
    margin-left: 6px;
    padding: 2px 4px;
    border: none;
    border-radius: var(--vs-radius-chip);
    background: transparent;
    font: inherit;
    text-align: left;
    cursor: pointer;
    transition: background-color 150ms ease-out;
  }
  .sources-toggle:hover {
    background: var(--vs-hover);
  }
  .stack {
    display: flex;
  }
  .stack > * + * {
    margin-left: -4px;
  }
  .sources-label {
    font-size: 12px;
    color: var(--vs-ink-2);
  }

  .sources-panel {
    display: grid;
    grid-template-rows: 0fr;
    opacity: 0;
    transition:
      grid-template-rows 300ms var(--vs-ease-out),
      opacity 300ms var(--vs-ease-out);
  }
  [data-done][data-sources-open] .sources-panel {
    grid-template-rows: 1fr;
    opacity: 1;
  }
  .sources-clip {
    min-height: 0;
    overflow: hidden;
  }
  .sources-list {
    display: flex;
    flex-direction: column;
    margin-top: 6px;
    padding: 4px;
    border-radius: var(--vs-radius-card);
    background: var(--vs-inset);
    box-shadow: var(--vs-shadow-hairline);
  }
  .source {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 4px 6px;
    border-radius: var(--vs-radius-chip);
    color: var(--vs-ink-2);
    font-size: 12px;
    text-decoration: none;
    transition:
      background-color 150ms ease-out,
      color 150ms ease-out;
  }
  a.source:hover {
    background: var(--vs-hover);
    color: var(--vs-ink);
  }
  .source-name {
    position: relative;
    display: inline-block;
  }
  .source-name::after {
    position: absolute;
    right: 0;
    bottom: -1px;
    left: 0;
    height: 1px;
    content: "";
    background: currentColor;
    transform: scaleX(0);
    transform-origin: left center;
    transition: transform 280ms var(--vs-ease-link);
  }
  a.source:hover .source-name::after,
  a.source:focus-visible .source-name::after {
    transform: scaleX(1);
  }
  .source-domain {
    margin-left: auto;
    font-family: var(--vs-font-sans);
    font-variant-numeric: tabular-nums;
    font-size: 10.5px;
    color: var(--vs-ink-3);
  }

  .follow-ups {
    margin-top: 10px;
  }
  .follow-ups-heading {
    margin: 0;
    font-size: 12px;
    font-weight: 500;
    color: var(--vs-ink-2);
  }
  .follow-ups-list {
    display: flex;
    flex-direction: column;
    margin-top: 2px;
  }
  .follow-up {
    display: flex;
    align-items: center;
    gap: 8px;
    margin: 0 -6px;
    padding: 6px;
    border: none;
    border-bottom: 1px solid var(--vs-line);
    border-radius: 7px;
    background: transparent;
    color: var(--vs-ink);
    font: inherit;
    font-size: 12.5px;
    text-align: left;
    cursor: pointer;
    opacity: 0;
    transition: background-color 100ms ease-out;
  }
  .follow-up svg {
    flex-shrink: 0;
    color: var(--vs-ink-3);
  }
  .follow-up:hover {
    background: var(--vs-hover-2);
  }
  [data-done] .follow-up {
    animation: vs-fade-up 350ms var(--vs-ease-out) calc(var(--vs-st-i) * 90ms) both;
  }

  .icon-button:focus-visible,
  .sources-toggle:focus-visible,
  .follow-up:focus-visible,
  .source:focus-visible,
  .chip:focus-visible {
    outline: none;
    box-shadow: var(--vs-focus-ring);
  }

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

  /* hide the finished answer for the instant before the script rewinds it */
  @media (scripting: enabled) and (prefers-reduced-motion: no-preference) {
    .vs-streaming-text[data-autoplay]:not([data-vs-ready]) {
      visibility: hidden;
    }
  }
  /* without script the toggle can't open the list, so show it */
  @media (scripting: none) {
    .sources-panel {
      grid-template-rows: 1fr;
      opacity: 1;
    }
  }

  @media (prefers-reduced-motion: reduce) {
    [data-streaming] .word,
    [data-streaming] .chip,
    [data-done] .follow-up {
      animation: none;
    }
    .follow-up {
      opacity: 1;
    }
  }
</style>

<script>
  const WORD_MS = 55;
  const HOLD_MS = 3400;

  /** One pending timeout that can pause and resume where it left off. */
  function createTimer() {
    let id: number | undefined;
    let fn: (() => void) | undefined;
    let left = 0;
    let due = 0;
    let paused = true;
    const arm = () => {
      due = performance.now() + left;
      id = window.setTimeout(() => {
        const run = fn;
        id = fn = undefined;
        run?.();
      }, left);
    };
    return {
      set(ms: number, next: () => void) {
        window.clearTimeout(id);
        id = undefined;
        fn = next;
        left = ms;
        if (!paused) arm();
      },
      pause() {
        if (paused) return;
        paused = true;
        if (id === undefined) return;
        window.clearTimeout(id);
        id = undefined;
        left = Math.max(0, due - performance.now());
      },
      resume() {
        if (!paused) return;
        paused = false;
        if (fn) arm();
      },
      clear() {
        window.clearTimeout(id);
        id = fn = undefined;
      },
    };
  }

  function init(root: HTMLElement) {
    const answer = root.querySelector<HTMLElement>("[data-answer]")!;
    const live = root.querySelector<HTMLElement>("[data-live]")!;
    const tokens = [...root.querySelectorAll<HTMLElement>("[data-token]")];
    const after = [...root.querySelectorAll<HTMLElement>("[data-after]")];
    const toggle = root.querySelector<HTMLButtonElement>("[data-sources-toggle]");
    const clip = root.querySelector<HTMLElement>("[data-sources-clip]");
    const config = JSON.parse(root.querySelector("[data-vs-config]")?.textContent || "{}") as { text?: string };
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const loop = root.hasAttribute("data-loop");

    const timer = createTimer();
    let count = tokens.length;
    let announced = false;
    let sourcesOpen = false;

    const emit = (name: string, detail?: unknown) =>
      root.dispatchEvent(new CustomEvent(`vs-streaming-text:${name}`, { bubbles: true, detail }));

    const renderSources = () => {
      const open = sourcesOpen && root.hasAttribute("data-done");
      root.toggleAttribute("data-sources-open", sourcesOpen);
      toggle?.setAttribute("aria-expanded", String(open));
      if (clip) clip.inert = !open;
    };

    const setDone = (done: boolean) => {
      root.toggleAttribute("data-done", done);
      root.toggleAttribute("data-streaming", !done);
      answer.setAttribute("aria-busy", String(!done));
      after.forEach((el) => (el.inert = !done));
      renderSources();
    };

    const finish = () => {
      setDone(true);
      if (!announced && config.text) {
        announced = true;
        live.textContent = config.text;
      }
      emit("done");
      if (loop) timer.set(HOLD_MS, restart);
    };

    const tick = () => {
      const token = tokens[count];
      if (token) token.hidden = false;
      count += 1;
      if (count >= tokens.length) finish();
      else timer.set(WORD_MS, tick);
    };

    const start = () => {
      count = 0;
      tokens.forEach((token) => (token.hidden = true));
      setDone(false);
      timer.set(WORD_MS, tick);
    };

    function restart() {
      /* don't rewind under someone's pointer, focus, or an open sources list */
      if (root.matches(":hover") || root.contains(document.activeElement) || sourcesOpen) {
        timer.set(HOLD_MS, restart);
        return;
      }
      start();
    }

    /* ── interactions ── */
    toggle?.addEventListener("click", () => {
      sourcesOpen = !sourcesOpen;
      renderSources();
    });

    root.querySelectorAll<HTMLButtonElement>("[data-action]").forEach((button) => {
      button.addEventListener("click", () => {
        const action = button.dataset.action!;
        if (action === "copy" && config.text) {
          navigator.clipboard?.writeText(config.text).then(() => {
            button.classList.add("is-copied");
            window.setTimeout(() => button.classList.remove("is-copied"), 1600);
          }, () => {});
        }
        if (action === "good" || action === "bad") {
          const pressed = button.getAttribute("aria-pressed") === "true";
          root.querySelectorAll("[data-action='good'], [data-action='bad']").forEach((b) => b.setAttribute("aria-pressed", "false"));
          button.setAttribute("aria-pressed", String(!pressed));
        }
        if (action === "regenerate" && !reduced && root.hasAttribute("data-autoplay")) {
          sourcesOpen = false;
          start();
          answer.focus();
        }
        emit("action", { action });
      });
    });

    root.querySelectorAll<HTMLButtonElement>("[data-follow-up]").forEach((button) => {
      button.addEventListener("click", () =>
        emit("follow-up", { text: button.textContent?.trim() ?? "", index: Number(button.dataset.followUp) }),
      );
    });

    if (reduced || !root.hasAttribute("data-autoplay")) {
      setDone(true);
      return;
    }

    start();

    const io = new IntersectionObserver(([entry]) => {
      if (!root.isConnected) return teardown();
      if (entry?.isIntersecting) timer.resume();
      else timer.pause();
    }, { threshold: 0.2 });
    io.observe(root);

    function teardown() {
      timer.clear();
      io.disconnect();
    }
    document.addEventListener("astro:before-swap", teardown, { once: true });
  }

  const boot = () =>
    document.querySelectorAll<HTMLElement>(".vs-streaming-text:not([data-vs-ready])").forEach((root) => {
      root.dataset.vsReady = "";
      init(root);
    });
  boot();
  document.addEventListener("astro:page-load", boot);
</script>
```
