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. ga4-sep.csv Gemini and Perplexity now cite it too.
Follow-ups
Streamed answer with inline citations, sources, actions and follow-ups.
AgentComponentNo dependencies Beautiful UI, MIT
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. ga4-sep.csv Gemini and Perplexity now cite it too.
Follow-ups
foundation.css holds the tokens every component reads.
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
--- ---
import StreamingText from "../components/viewership/streaming-text/StreamingText.astro";
---
<StreamingText /> | 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. |
---
/**
* 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> Ported from Beautiful UI, MIT. Notices.