---
/**
* 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>