# Stream Text

> Text that streams in like model output, with a blurred edge and caret.

- **Slug:** `stream-text`
- **Kind:** atom · **Category:** Feedback
- **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/atoms/StreamText.tsx) (MIT)
- **Docs page:** https://viewership.ai/ui/components/stream-text
- **All files in one bundle:** https://viewership.ai/ui/components/stream-text.bundle.txt
- **Structured JSON:** https://viewership.ai/ui/components/stream-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 these files into `src/components/viewership/stream-text/`:

   - `StreamText.astro` (raw: https://viewership.ai/ui/raw/stream-text/StreamText.astro.txt)
   - `stream-text.ts` (raw: https://viewership.ai/ui/raw/stream-text/stream-text.ts.txt)

npm packages: none.

## Usage

```astro
---
import StreamText from "../components/viewership/stream-text/StreamText.astro";
---

<StreamText />
```

## Example

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

```astro
---
import StreamText from "./StreamText.astro";
import Button from "../button/Button.astro";

interface Props {
  variant?: string;
}
const { variant = "Report summary" } = Astro.props;

const notes =
  "Organic sessions rose 12% week over week, led by the trail pack guide. Paid search held a 4.1x ROAS on flat spend. Perplexity now cites the guide on 3 of 5 tracked prompts. Move $800 from display to search next week.";
---

{variant === "Report summary" && (
  <div class="card" data-stream-demo>
    <div class="head">
      <div>
        <p class="title">Weekly report</p>
        <p class="sub">Northwind Outdoor · Week 41 · GA4</p>
      </div>
      <Button variant="secondary" size="xs" data-replay>Replay</Button>
    </div>
    <p class="body"><StreamText text={notes} reserveSpace /></p>
  </div>
)}

{variant === "Inline rewrite" && (
  <div class="card">
    <p class="body">
      Our trail packs are tested on real routes every season.{" "}
      <span class="selection"><StreamText text="Each pack carries 30 liters, weighs under a kilo and sheds rain on long climbs." charsPerTick={1} tickMs={18} loop loopDelay={2600} /></span>{" "}
      Spring colors ship to retailers Friday.
    </p>
  </div>
)}

<script>
  const boot = () =>
    document.querySelectorAll<HTMLElement>("[data-stream-demo]:not([data-vs-ready])").forEach((demo) => {
      demo.dataset.vsReady = "";
      const stream = demo.querySelector(".vs-stream-text");
      demo.querySelector("[data-replay]")?.addEventListener("click", () => {
        stream?.dispatchEvent(new CustomEvent("vs-stream-play"));
      });
    });
  boot();
  document.addEventListener("astro:page-load", boot);
</script>

<style>
  .card {
    max-width: 440px;
    margin: 0 auto;
    padding: 16px 18px;
    border-radius: var(--vs-radius-card);
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-card);
  }
  .head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 12px;
    margin-bottom: 10px;
  }
  .title {
    margin: 0;
    font-size: 13px;
    font-weight: 600;
    color: var(--vs-ink);
  }
  .sub {
    margin: 2px 0 0;
    font-size: 12px;
    color: var(--vs-ink-3);
  }
  .body {
    margin: 0;
    font-size: 13px;
    line-height: 1.65;
    color: var(--vs-ink);
  }
  .selection {
    border-radius: 3px;
    background: color-mix(in srgb, var(--vs-accent) 14%, var(--vs-surface));
    box-decoration-break: clone;
    -webkit-box-decoration-break: clone;
  }
</style>
```

## Variants

Demo variants: `Report summary`, `Inline rewrite`.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` | `string` |  | The text to stream. |
| `charsPerTick` | `number` | `2` | Characters per tick. Higher is faster. |
| `tickMs` | `number` | `9` | Tick interval, ms. |
| `blurTail` | `number` | `6` | Trailing characters that are blurred. |
| `caret` | `boolean` | `true` | Show the caret. Blinks once idle. |
| `autoplay` | `boolean` | `true` | Stream when in view. If false, wait for vs-stream-play. |
| `loop` | `boolean` | `false` | Replay after loopDelay. |
| `loopDelay` | `number` | `2400` | Pause before replay, ms. |
| `reserveSpace` | `boolean` | `false` | Reserve final size to prevent layout shift. Block contexts only. |
| `events` | `vs-stream-play · vs-stream-progress · vs-stream-done` |  | Dispatch vs-stream-play ({ text? }) to restart. Listen for progress ({ count }) and done. |
| `streamText(el, text, opts)` | `(el: HTMLElement, text: string, opts?: StreamTextOptions) => StreamTextHandle` |  | Stream into any element from your own script. Handle: pause, resume, finish, stop. |

## Used by

[Selection Actions](https://viewership.ai/ui/components/selection-actions.md)

## Source

### `src/components/viewership/stream-text/StreamText.astro`

```astro
---
/**
 * StreamText: text that streams in like model output, with a soft blurred
 * edge and a caret that blinks once it settles.
 *
 * Adapted from Beautiful UI StreamText (MIT, © 2026 Shane Levine).
 *
 * - Without JS, and under reduced motion, the full text renders at once.
 * - Streams in view; pauses offscreen.
 * - Screen readers get the full text once from a hidden copy. The animated
 *   copy is aria-hidden.
 * - Script API on the root:
 *     el.dispatchEvent(new CustomEvent("vs-stream-play", { detail: { text } }))
 *     el.addEventListener("vs-stream-progress", (e) => e.detail.count)
 *     el.addEventListener("vs-stream-done", …)
 * - For your own DOM, import `streamText` from `./stream-text`.
 *
 * @example
 * <p><StreamText text="Organic sessions rose 12% week over week." /></p>
 */
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"span"> & {
  /** The text to stream. */
  text: string;
  /** Characters revealed per tick; higher is faster. */
  charsPerTick?: number;
  /** Interval between reveals, in ms. */
  tickMs?: number;
  /** How many trailing characters carry the soft blur edge. */
  blurTail?: number;
  /** Render the caret (solid while streaming, blinks once idle). */
  caret?: boolean;
  /** Start streaming when scrolled into view. When false, wait for `vs-stream-play`. */
  autoplay?: boolean;
  /** Replay after a pause once the text settles. */
  loop?: boolean;
  /** Pause before a loop restarts, in ms. */
  loopDelay?: number;
  /** Reserve the final text's space up front so nothing below shifts (block contexts only). */
  reserveSpace?: boolean;
};

const {
  text,
  charsPerTick = 2,
  tickMs = 9,
  blurTail = 6,
  caret = true,
  autoplay = true,
  loop = false,
  loopDelay = 2400,
  reserveSpace = false,
  class: className,
  ...rest
} = Astro.props;
---

<span
  class:list={["vs-stream-text", { "vs-stream-text--reserve": reserveSpace }, className]}
  data-chars-per-tick={charsPerTick}
  data-tick-ms={tickMs}
  data-blur-tail={blurTail}
  data-caret={caret ? "" : undefined}
  data-autoplay={autoplay ? "" : undefined}
  data-loop={loop ? "" : undefined}
  data-loop-delay={loopDelay}
  {...rest}
>
  <span class="vs-stream-text__sr">{text}</span>
  {reserveSpace && <span class="vs-stream-text__ghost" aria-hidden="true">{text}{caret && <span class="vs-stream-text__caret" />}</span>}
  <span class="vs-stream-text__visual" aria-hidden="true">{text}{caret && <span class="vs-stream-text__caret" />}</span>
</span>

<script>
  import { streamText, prefersReducedMotion, type StreamTextHandle } from "./stream-text";

  const cleanups = new Set<() => void>();

  function init(root: HTMLElement) {
    const visual = root.querySelector<HTMLElement>(".vs-stream-text__visual");
    const sr = root.querySelector<HTMLElement>(".vs-stream-text__sr");
    const ghost = root.querySelector<HTMLElement>(".vs-stream-text__ghost");
    if (!visual || !sr) return;

    const d = root.dataset;
    let text = sr.textContent ?? "";
    const opts = {
      charsPerTick: Number(d.charsPerTick) || 2,
      tickMs: Number(d.tickMs) || 9,
      blurTail: Number(d.blurTail ?? 6),
      caret: d.caret !== undefined,
    };
    const loop = d.loop !== undefined && !prefersReducedMotion();
    const loopDelay = Number(d.loopDelay) || 2400;

    let handle: StreamTextHandle | null = null;
    let loopTimer: ReturnType<typeof setTimeout> | undefined;
    let inView = false;

    const play = (next?: string) => {
      if (typeof next === "string" && next !== text) {
        text = next;
        sr.textContent = text;
        if (ghost?.firstChild) ghost.firstChild.textContent = text;
      }
      clearTimeout(loopTimer);
      handle?.stop();
      handle = streamText(visual, text, {
        ...opts,
        paused: !inView,
        onProgress: (count) => root.dispatchEvent(new CustomEvent("vs-stream-progress", { detail: { count } })),
        onDone: () => {
          root.dispatchEvent(new CustomEvent("vs-stream-done"));
          if (loop) loopTimer = setTimeout(() => play(), loopDelay);
        },
      });
    };

    const io = new IntersectionObserver(([entry]) => {
      inView = entry.isIntersecting;
      if (inView) handle?.resume();
      else handle?.pause();
    });
    io.observe(root);

    const onPlay = (event: Event) => play((event as CustomEvent<{ text?: string } | null>).detail?.text);
    root.addEventListener("vs-stream-play", onPlay);

    // Autoplay: clear to an empty line now (paused), then stream once visible.
    if (d.autoplay !== undefined && !prefersReducedMotion()) play();

    cleanups.add(() => {
      clearTimeout(loopTimer);
      handle?.stop();
      io.disconnect();
      root.removeEventListener("vs-stream-play", onPlay);
    });
  }

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

<style>
  .vs-stream-text__sr {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }

  /* Reserve mode: the hidden ghost holds the final size; the stream overlays it. */
  .vs-stream-text--reserve {
    display: grid;
  }
  .vs-stream-text--reserve > .vs-stream-text__ghost,
  .vs-stream-text--reserve > .vs-stream-text__visual {
    grid-area: 1 / 1;
  }
  .vs-stream-text__ghost {
    visibility: hidden;
  }

  /* Settled caret for the server render (the script styles its own inline). */
  .vs-stream-text__caret {
    display: inline-block;
    width: 2px;
    height: 1.05em;
    margin-left: 1.5px;
    translate: 0 -0.5px;
    border-radius: 1px;
    background: currentColor;
    vertical-align: text-bottom;
    animation: vs-caret-blink 1s step-end infinite;
  }
  @media (prefers-reduced-motion: reduce) {
    .vs-stream-text__caret {
      animation: none;
    }
  }
</style>
```

### `src/components/viewership/stream-text/stream-text.ts`

```ts
/**
 * stream-text: the streaming engine behind <StreamText>.
 *
 * Adapted from Beautiful UI StreamText (MIT, © 2026 Shane Levine).
 * The newest characters resolve out of a soft blur; the caret stays solid
 * while streaming, then blinks. Styles are inline, so it works on any element:
 *
 *   const stream = streamText(span, "Organic sessions rose…", {
 *     onProgress: () => reanchorToolbar(),
 *     onDone: () => setMode("result"),
 *   });
 *   stream.stop();
 *
 * The target's children are replaced. Per-character updates are noisy for
 * screen readers: mark the target `aria-hidden` and expose the full text
 * elsewhere (StreamText.astro does), or set `aria-busy` on a live region
 * until `onDone`. Under reduced motion the full text appears at once.
 */

export interface StreamTextOptions {
  /** Characters revealed per tick; higher is faster. Default 2. */
  charsPerTick?: number;
  /** Interval between reveals in ms. Default 9. */
  tickMs?: number;
  /** How many trailing characters carry the soft blur edge. Default 6. */
  blurTail?: number;
  /** Render the caret (solid while streaming, blinks once idle). Default true. */
  caret?: boolean;
  /** Start paused (call resume()). Default false. */
  paused?: boolean;
  /** Fires each tick with the number of characters shown. */
  onProgress?: (count: number) => void;
  /** Fires once the full string is shown. */
  onDone?: () => void;
}

export interface StreamTextHandle {
  /** Stop for good and show the full text. */
  finish(): void;
  /** Stop for good where it is (no onDone). */
  stop(): void;
  pause(): void;
  resume(): void;
}

const TAIL_MASK = "linear-gradient(to right, oklch(0 0 0) 20%, oklch(0 0 0 / 0.2))";

function makeCaret(): HTMLSpanElement {
  const caret = document.createElement("span");
  caret.className = "vs-stream-text__caret";
  caret.setAttribute("aria-hidden", "true");
  Object.assign(caret.style, {
    display: "inline-block",
    width: "2px",
    height: "1.05em",
    marginLeft: "1.5px",
    translate: "0 -0.5px",
    borderRadius: "1px",
    background: "currentColor",
    verticalAlign: "text-bottom",
  } satisfies Partial<CSSStyleDeclaration>);
  return caret;
}

function makeTail(blur: boolean): HTMLSpanElement {
  const tail = document.createElement("span");
  tail.className = "vs-stream-text__tail";
  if (blur) {
    tail.style.filter = "blur(1.6px)";
    tail.style.setProperty("-webkit-mask-image", TAIL_MASK);
    tail.style.setProperty("mask-image", TAIL_MASK);
  }
  return tail;
}

export function prefersReducedMotion(): boolean {
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

/** Stream `text` into `target`. Returns a handle to pause, resume, finish or stop. */
export function streamText(target: HTMLElement, text: string, options: StreamTextOptions = {}): StreamTextHandle {
  const { charsPerTick = 2, tickMs = 9, blurTail = 6, caret: withCaret = true, onProgress, onDone } = options;
  const reduce = prefersReducedMotion();

  const head = document.createTextNode("");
  const tail = makeTail(!reduce);
  const caret = withCaret ? makeCaret() : null;
  target.replaceChildren(head, tail, ...(caret ? [caret] : []));

  let count = 0;
  let timer: ReturnType<typeof setInterval> | undefined;
  let done = false;
  let stopped = false;

  const render = () => {
    const shown = text.slice(0, count);
    const streaming = count < text.length;
    const split = streaming ? Math.max(0, shown.length - blurTail) : shown.length;
    head.data = shown.slice(0, split);
    tail.textContent = shown.slice(split);
    if (caret) caret.style.animation = streaming || reduce ? "none" : "vs-caret-blink 1s step-end infinite";
  };

  const clear = () => {
    if (timer !== undefined) clearInterval(timer);
    timer = undefined;
  };

  const complete = () => {
    clear();
    count = text.length;
    render();
    if (!done) {
      done = true;
      onDone?.();
    }
  };

  const tick = () => {
    count = Math.min(count + charsPerTick, text.length);
    render();
    onProgress?.(count);
    if (count >= text.length) complete();
  };

  const start = () => {
    if (done || stopped || timer !== undefined) return;
    timer = setInterval(tick, tickMs);
  };

  render();
  if (reduce || text.length === 0) complete();
  else if (!options.paused) start();

  return {
    finish() {
      if (stopped) return;
      stopped = true;
      complete();
    },
    stop() {
      stopped = true;
      clear();
    },
    pause: clear,
    resume: start,
  };
}
```
