Teseria
All elements

Streaming text

Tokens arrive out of a 5px blur, a word at a time. A block cursor rides the edge of the text and breathes while the stream is live.

Code

1. Install

Terminal
npm i lucide-react

2. Files

StreamingText.tsx135 lines
"use client";
// StreamingText: tokens arrive out of a 5px blur, a word at a time. A block cursor rides the edge of the text and
// breathes while the stream is live. Stop keeps the partial: the cursor thins to a hairline and stays. An invisible
// copy of the full text reserves the finished height, so nothing below it moves while it types.
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { Play, Square } from "lucide-react";

export type StreamingTextCue = "done" | "push" | "disable";

export type StreamingTextProps = {
  /** The text to stream. */
  text?: string;
  /** 0 to 100: how fast tokens land. */
  speed?: number;
  /** Start streaming on mount. */
  autoplay?: boolean;
  /** Show the status tag and the Start / Stop / Continue button. */
  controls?: boolean;
  /** Block width in px. */
  width?: number;
  /** Called once when the last token has landed. */
  onDone?: () => void;
  /** Sound hook: "push" on start / continue, "disable" on stop, "done" when a stream the person started finishes. */
  onSound?: (cue: StreamingTextCue) => void;
  className?: string;
  style?: CSSProperties;
};

const SAMPLE = "The trick with streaming is that nothing should ever cut. Each word lands out of a slight blur, the cursor sits at the edge of what exists, and when you stop, what you have is still a sentence.";

/** Split prose into word tokens that keep their trailing whitespace. */
const tokenize = (text: string) => text.match(/\S+\s*/g) ?? [];
/** 0..100 speed knob -> duration multiplier (1.6x slow .. 0.4x fast). */
const speedMult = (speed: number) => 1.6 - (speed / 100) * 1.2;

/** Ref callback: sets `data-off` on the element while it is off screen, so endless CSS animations can pause. */
function useOffscreenFlag() {
  const io = useRef<IntersectionObserver | null>(null);
  const ref = useCallback((el: HTMLElement | null) => {
    io.current?.disconnect();
    io.current = null;
    if (!el || typeof IntersectionObserver === "undefined") return;
    io.current = new IntersectionObserver(([e]) => (e.isIntersecting ? el.removeAttribute("data-off") : el.setAttribute("data-off", "")), { rootMargin: "160px" });
    io.current.observe(el);
  }, []);
  useEffect(() => () => io.current?.disconnect(), []);
  return ref;
}

export function StreamingText({ text = SAMPLE, speed = 50, autoplay = true, controls = true, width = 300, onDone, onSound, className, style }: StreamingTextProps) {
  const offRef = useOffscreenFlag();
  const tokens = useMemo(() => tokenize(text), [text]);
  const [running, setRunning] = useState(autoplay);
  const [n, setN] = useState(0);
  const at = useRef(0);
  const pressed = useRef(false);
  const cb = useRef({ onDone, onSound });
  cb.current = { onDone, onSound };
  const done = n >= tokens.length;

  // Fake token stream: ~70ms x speed per token, jittered, longer after punctuation.
  useEffect(() => {
    if (!running || done) return;
    const base = 70 * speedMult(speed);
    let tmr = 0;
    const step = () => {
      const k = at.current;
      const tok = tokens[k] ?? "";
      at.current = k + 1;
      setN(k + 1);
      if (k + 1 < tokens.length) {
        const pause = /[.!?]\s*$/.test(tok) ? 3.2 : /[,;:]\s*$/.test(tok) ? 1.8 : 1;
        tmr = window.setTimeout(step, base * pause * (0.5 + Math.random()));
      } else {
        if (pressed.current) cb.current.onSound?.("done");
        cb.current.onDone?.();
      }
    };
    tmr = window.setTimeout(step, base);
    return () => clearTimeout(tmr);
  }, [running, done, speed, tokens]);

  // new text starts over
  useEffect(() => {
    at.current = 0;
    setN(0);
  }, [tokens]);

  const state = done ? "done" : running ? "live" : n > 0 ? "stopped" : "idle";
  const word = running ? "Stop" : n > 0 ? "Continue" : "Start";
  const go = () => {
    pressed.current = true;
    if (running) {
      setRunning(false);
      onSound?.("disable");
    } else {
      setRunning(true);
      onSound?.("push");
    }
  };

  return (
    <div ref={offRef} className={className ? `stx-root ${className}` : "stx-root"} data-state={state} style={{ width, ...style }}>
      <div className="stx-stage">
        <p className="stx-body" aria-live="polite" aria-busy={running && !done}>
          {tokens.slice(0, n).map((t, i) => (
            <span key={i} className="stx-tok">
              {t}
            </span>
          ))}
          <span className="stx-cur" aria-hidden />
        </p>
        {/* Invisible full text in the same cell: the body is as tall as the finished answer from the first token. */}
        <p className="stx-body stx-ghost" aria-hidden>
          {text}
          <span className="stx-cur" />
        </p>
      </div>
      {controls && (
        <div className="stx-foot">
          <span className="stx-tag">{state === "live" ? "Streaming" : state === "stopped" ? "Stopped" : state === "done" ? "Done" : "Ready"}</span>
          {!done && (
            <button type="button" className="stx-btn" onClick={go} aria-label={word}>
              <span className="stx-glyph" data-k={running ? "stop" : "play"}>
                <Play size={13} strokeWidth={2.2} />
                <Square size={11} strokeWidth={2.4} />
              </span>
              {word}
            </button>
          )}
        </div>
      )}
    </div>
  );
}
StreamingText.css152 lines
/* StreamingText. Prefix stx-. */
.stx-root,
.stx-root * {
  box-sizing: border-box;
}
:where(.stx-root) button {
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  padding: 0;
  margin: 0;
}
.stx-root {
  --stx-ease: cubic-bezier(0.22, 1, 0.36, 1);
  color: var(--ts-fg);
  font-family: var(--ts-font, system-ui, sans-serif);
  font-size: 14px;
  line-height: 1.55;
  letter-spacing: -0.01em;
}
.stx-stage {
  display: grid;
  min-height: calc(1.55em * 4);
}
.stx-body {
  grid-area: 1 / 1;
  margin: 0;
  font-weight: 400;
}
.stx-ghost {
  visibility: hidden;
}
.stx-ghost .stx-cur {
  animation: none !important;
}
.stx-tok {
  display: inline;
  animation: stx-in 0.34s var(--stx-ease) backwards;
}
@keyframes stx-in {
  0% {
    opacity: 0;
    filter: blur(5px);
  }
  to {
    opacity: 1;
    filter: none;
  }
}
.stx-cur {
  display: inline-block;
  width: 7px;
  height: 1em;
  margin-left: 1px;
  vertical-align: -0.14em;
  border-radius: 1.5px;
  background: var(--ts-fg);
  transform-origin: 0 50%;
  transition: scale 0.42s var(--stx-ease), opacity 0.22s var(--stx-ease);
}
.stx-root[data-state="live"] .stx-cur {
  animation: stx-breathe 0.9s var(--stx-ease) infinite alternate;
}
@keyframes stx-breathe {
  to {
    opacity: 0.35;
  }
}
.stx-root[data-state="stopped"] .stx-cur {
  scale: 0.22 1;
  opacity: 0.5;
}
.stx-root[data-state="done"] .stx-cur {
  opacity: 0;
  scale: 0.22 1;
}
.stx-foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 30px;
  margin-top: 12px;
}
.stx-tag {
  font-size: 12px;
  font-weight: 500;
  color: rgba(var(--ts-fg-rgb), 0.5);
  transition: color 0.22s var(--stx-ease);
}
.stx-root[data-state="live"] .stx-tag {
  color: rgba(var(--ts-fg-rgb), 0.78);
}
.stx-btn {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  height: 30px;
  padding: 0 12px 0 10px;
  border-radius: 999px;
  background: var(--ts-raised);
  box-shadow: var(--ts-edge);
  color: var(--ts-fg);
  font-size: 13px;
  font-weight: 500;
  cursor: pointer;
  transition: scale 0.22s var(--stx-ease), background 0.16s var(--stx-ease);
}
.stx-btn:hover {
  background: color-mix(in srgb, var(--ts-fg) 5%, var(--ts-raised));
}
.stx-btn:active {
  scale: 0.96;
}
.stx-btn:focus-visible {
  outline: 2px solid var(--ts-focus, #3446eb);
  outline-offset: 2px;
}
.stx-glyph {
  position: relative;
  display: grid;
  width: 13px;
  height: 13px;
}
.stx-glyph svg {
  grid-area: 1 / 1;
  place-self: center;
  opacity: 0;
  scale: 0.6;
  filter: blur(2px);
  transition: opacity 0.16s var(--stx-ease), scale 0.22s var(--stx-ease), filter 0.22s var(--stx-ease);
}
.stx-glyph[data-k="play"] svg:nth-child(1),
.stx-glyph[data-k="stop"] svg:nth-child(2) {
  opacity: 1;
  scale: 1;
  filter: none;
}
@media (prefers-reduced-motion: reduce) {
  .stx-tok,
  .stx-cur {
    animation: none;
  }
}

/* off screen: endless animations hold still (the root gets data-off) */
.stx-root[data-off],
.stx-root[data-off] *,
.stx-root[data-off] ::before,
.stx-root[data-off] ::after {
  animation-play-state: paused !important;
}
tokens.cssshared · add once
/*
 * Teseria tokens — the only colours, fonts and corner radii a block reads.
 *
 * Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
 * the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
 * any ancestor, or the OS setting unless [data-theme="light"] is set.
 *
 *   Surfaces   page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
 *   Text       fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
 *   Lines      border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
 *              dark; composes with other shadows)
 *   Signals    accent · focus · success · warning · danger
 *   Corners    controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
 *              (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
 */
:root {
  --ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  --ts-r-panel: 24px;
  --ts-r-pop: 18px;
  --ts-r-inner: 12px;

  --ts-page: #ffffff;
  --ts-raised: #ffffff;
  --ts-sunken: #f3f2f0;
  --ts-tint-1: #f5f4f2;
  --ts-tint-2: #ecebe8;
  --ts-tint-3: #e3e1dd;

  --ts-fg: #161514;
  --ts-fg-rgb: 22, 21, 20;
  --ts-fg-secondary: #3a3836;
  --ts-fg-tertiary: #585552;
  --ts-fg-muted: #6d6a66;
  --ts-fg-faint: #89857f;
  --ts-fg-inverse: #ffffff;

  --ts-border: rgba(22, 21, 20, 0.1);
  --ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);

  --ts-accent: #3446eb;
  --ts-focus: #3446eb;
  --ts-success: #13804a;
  --ts-warning: #a9650b;
  --ts-danger: #df474e;
}

:root[data-theme="dark"] {
  --ts-page: #0b0a09;
  --ts-raised: #24221f;
  --ts-sunken: #0d0c0b;
  --ts-tint-1: #131211;
  --ts-tint-2: #1b1a18;
  --ts-tint-3: #2b2926;

  --ts-fg: #f6f5f3;
  --ts-fg-rgb: 246, 245, 243;
  --ts-fg-secondary: #bab8b4;
  --ts-fg-tertiary: #9e9b97;
  --ts-fg-muted: #8e8b86;
  --ts-fg-faint: #72706c;
  --ts-fg-inverse: #0b0a09;

  --ts-border: rgba(246, 245, 243, 0.1);
  --ts-edge: 0 0 #0000;

  --ts-accent: #3446eb;
  --ts-focus: #7f8cff;
  --ts-success: #3ccf80;
  --ts-warning: #efa130;
  --ts-danger: #df474e;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --ts-page: #0b0a09;
    --ts-raised: #24221f;
    --ts-sunken: #0d0c0b;
    --ts-tint-1: #131211;
    --ts-tint-2: #1b1a18;
    --ts-tint-3: #2b2926;

    --ts-fg: #f6f5f3;
    --ts-fg-rgb: 246, 245, 243;
    --ts-fg-secondary: #bab8b4;
    --ts-fg-tertiary: #9e9b97;
    --ts-fg-muted: #8e8b86;
    --ts-fg-faint: #72706c;
    --ts-fg-inverse: #0b0a09;

    --ts-border: rgba(246, 245, 243, 0.1);
    --ts-edge: 0 0 #0000;

    --ts-accent: #3446eb;
    --ts-focus: #7f8cff;
    --ts-success: #3ccf80;
    --ts-warning: #efa130;
    --ts-danger: #df474e;
  }
}

3. Usage

Demo.tsx
import { StreamingText } from "./StreamingText";
import "./StreamingText.css";

export default function Demo() {
  return <StreamingText />;
}