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
npm i lucide-react
2. Files
"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. 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;
}
/*
* 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
import { StreamingText } from "./StreamingText";
import "./StreamingText.css";
export default function Demo() {
return <StreamingText />;
}