Streaming Text
Streamed answer with inline sources, actions, and follow-ups.
"use client";
/*
* MIT License
*
* Copyright (c) 2026 Shane Levine
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import "./styles/foundation.css";
import { useEffect, useState } from "react";
/* ─────────────────────────────────────────────────────────
* STREAMING TEXT
* Words resolve out of blur, inline citations appear in
* context, then actions and follow-up prompts become usable.
* ───────────────────────────────────────────────────────── */
const WORD_MS = 55;
const HOLD_MS = 3400;
/* one streamed word, or a `cite` placeholder that renders an inline source chip */
export type StreamingToken = { text: string; cite?: boolean };
const TOKENS: StreamingToken[] = [
..."Pistachio is your fastest-growing project — sales are up 23% this month and margins beat vanilla by 8 points."
.split(" ")
.map((text) => ({ text })),
{ text: "", cite: true },
..."Stone-fruit projects are trending in the same range."
.split(" ")
.map((text) => ({ text })),
];
const FOLLOW_UPS = [
"Which projects sell best in winter",
"Compare brand and soft serve margins",
];
const SOURCE_IMAGES = {
work:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%231f7a5f'/%3E%3Cpath d='M20 36c0 7 5.4 12 12 12s12-5 12-12H20Z' fill='%23fff'/%3E%3Ccircle cx='32' cy='25' r='11' fill='%23bff3dd'/%3E%3Cpath d='M24 24c4-7 13-7 17 0' fill='none' stroke='%231f7a5f' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E",
trends:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%232f6fec'/%3E%3Cpath d='M15 43 27 31l8 7 14-18' fill='none' stroke='%23fff' stroke-width='7' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='49' cy='20' r='5' fill='%23bfe0ff'/%3E%3C/svg%3E",
market:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23e56d24'/%3E%3Cpath d='M17 45V25h8v20h-8Zm11 0V16h8v29h-8Zm11 0V30h8v15h-8Z' fill='%23fff'/%3E%3Cpath d='M16 49h32' stroke='%23ffd6b8' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E",
};
/* one cited source rendered as an inline chip and in the sources list */
export type StreamingSource = { name: string; domain: string; href: string; image: string };
const SOURCES: StreamingSource[] = [
{ name: "Work Data", domain: "example.com", href: "https://example.com/source-one", image: SOURCE_IMAGES.work },
{ name: "Trends Index", domain: "example.com", href: "https://example.com/source-two", image: SOURCE_IMAGES.trends },
{ name: "Market Basket", domain: "example.com", href: "https://example.com/source-three", image: SOURCE_IMAGES.market },
];
function sourceImage(source: StreamingSource) {
return source.image;
}
function SourceChip({ source }: { source?: StreamingSource }) {
if (!source) return null;
return (
<a
href={source.href}
target="_blank"
rel="noreferrer"
className="ml-0 mr-1 inline-flex h-4.5 translate-y-[-1px] items-center gap-1 rounded-[5px]
bg-inset pr-[3px] pl-[3px] align-middle font-mono text-[10.5px] text-ink-2 shadow-hairline
transition-colors duration-150 hover:bg-hover hover:text-ink"
style={{ animation: "pop-in 250ms cubic-bezier(0.23,1,0.32,1) both" }}
>
<img src={sourceImage(source)} alt="" className="source-avatar size-3 rounded-[3px]" />
<span>{source.domain}</span>
</a>
);
}
const ACTION_ICONS: React.ReactNode[] = [
<g key="copy"><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" /></g>,
<path key="retry" d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6" />,
<path key="up" 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" />,
<path key="down" 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" />,
];
export type StreamingLabels = {
/** label on the collapsed sources toggle */
sources: string;
/** heading above the follow-up prompts */
followUps: string;
};
const DEFAULT_LABELS: StreamingLabels = {
sources: "10 sources",
followUps: "Follow-ups",
};
export default function StreamingText({
content = TOKENS,
sources = SOURCES,
followUps = FOLLOW_UPS,
labels,
loop = true,
fill = false,
onDone,
onFollowUp,
}: {
variant?: string;
/** the streamed tokens; `cite` tokens render an inline source chip */
content?: StreamingToken[];
/** cited sources shown in the chip, avatar stack, and expanded list */
sources?: StreamingSource[];
/** follow-up prompt suggestions shown once the stream completes */
followUps?: string[];
/** prominent copy strings */
labels?: Partial<StreamingLabels>;
/** restart the stream after a hold; turn off when embedding in a real thread */
loop?: boolean;
/** fill the parent width instead of the gallery's fixed measure */
fill?: boolean;
onDone?: () => void;
/** fired when a follow-up prompt is chosen */
onFollowUp?: (text: string, index: number) => void;
} = {}) {
const l = { ...DEFAULT_LABELS, ...labels };
const [count, setCount] = useState(0);
const [sourcesOpen, setSourcesOpen] = useState(false);
const done = count >= content.length;
useEffect(() => {
if (done && !loop) {
onDone?.();
return;
}
const t = setTimeout(
() => setCount((c) => (c >= content.length ? 0 : c + 1)),
done ? HOLD_MS : WORD_MS,
);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [count, done, loop]);
return (
<div className={fill ? "w-full" : "min-h-[15.5rem] w-full max-w-95"}>
<p className="text-[13px] leading-relaxed text-ink">
{content.slice(0, count).map((token, i) =>
token.cite ? (
<SourceChip key={i} source={sources[0]} />
) : (
<span key={i} className="inline">
{token.text}{" "}
</span>
),
)}
{!done && (
<span
className="ml-0.5 inline-block h-3 w-0.5 translate-y-0.5 rounded-full bg-ink"
style={{ animation: "fade-in 150ms ease-out both" }}
/>
)}
</p>
{/* action icons row */}
<div
className="mt-2 flex items-center gap-0.5 transition-opacity duration-400"
style={{ opacity: done ? 1 : 0, pointerEvents: done ? "auto" : "none" }}
>
{ACTION_ICONS.map((icon, i) => (
<button
key={i}
type="button"
aria-label="Action"
className="flex size-6 items-center justify-center rounded-[6px] text-ink-3
transition-colors duration-100 hover:bg-hover-2 hover:text-ink-2"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
{icon}
</svg>
</button>
))}
<button
type="button"
aria-expanded={sourcesOpen}
onClick={() => setSourcesOpen((current) => !current)}
className="ml-1.5 flex items-center gap-1.5 rounded-[6px] px-1 py-0.5 text-left transition-colors duration-150 hover:bg-hover"
>
<span className="flex -space-x-1">
{sources.map((source) => (
<img
key={source.href}
src={sourceImage(source)}
alt=""
className="source-avatar size-3.5 rounded-full bg-card shadow-[0_0_0_1.5px_var(--canvas)]"
/>
))}
</span>
<span className="text-[12px] text-ink-2">{l.sources}</span>
</button>
</div>
<div
className="grid transition-[grid-template-rows,opacity] duration-300"
style={{
gridTemplateRows: done && sourcesOpen ? "1fr" : "0fr",
opacity: done && sourcesOpen ? 1 : 0,
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
}}
>
<div className="overflow-hidden">
<div className="mt-1.5 flex flex-col rounded-[10px] bg-inset p-1 shadow-hairline">
{sources.map((source) => (
<a
key={source.href}
href={source.href}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 rounded-[6px] px-1.5 py-1 text-[12px] text-ink-2 transition-colors duration-150 hover:bg-hover hover:text-ink"
>
<img src={sourceImage(source)} alt="" className="source-avatar size-4 rounded-[4px]" />
<span className="animated-underline">{source.name}</span>
<span className="ml-auto font-mono text-[10.5px] text-ink-3">{source.domain}</span>
</a>
))}
</div>
</div>
</div>
{/* follow-ups */}
<div
className="mt-2.5 transition-opacity duration-400"
style={{ opacity: done ? 1 : 0, pointerEvents: done ? "auto" : "none" }}
>
<p className="text-[12px] font-medium text-ink-2">{l.followUps}</p>
<div className="mt-0.5 flex flex-col">
{followUps.map((text, i) => (
<button
key={text}
onClick={() => onFollowUp?.(text, i)}
className="-mx-1.5 flex items-center gap-2 rounded-[7px] border-b border-line
px-1.5 py-1.5 text-left text-[12.5px] text-ink transition-colors
duration-100 hover:bg-hover-2"
style={
done
? { animation: `fade-up 350ms cubic-bezier(0.23,1,0.32,1) ${i * 90}ms both` }
: { opacity: 0 }
}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<path d="M9 10l-5 5 5 5" />
<path d="M20 4v7a4 4 0 0 1-4 4H4" />
</svg>
{text}
</button>
))}
</div>
</div>
</div>
);
}Installation
pnpm dlx shadcn@latest add @ward/streaming-textnpx shadcn@latest add @ward/streaming-textyarn dlx shadcn@latest add @ward/streaming-textbunx --bun shadcn@latest add @ward/streaming-textAdd the Ward registry to components.json once:
"registries": {
"@ward": "https://ward.so/r/{name}.json"
}Or install from the URL with no configuration: npx shadcn@latest add https://ward.so/r/streaming-text.json
Add the Ward items it builds on.
npx shadcn@latest add @ward/foundationCopy each file from the Code tab into:
components/ward/StreamingText.tsx
Update the import paths to match your project.
Usage
import StreamingText from "@/components/ward/StreamingText";<StreamingText
loop={false}
fill
content={[{ text: "Sales" }, { text: "rose" }, { text: "23%." }, { text: "", cite: true }]}
sources={[{ name: "Work Data", domain: "example.com", href: "https://example.com", image: "/sources/work.png" }]}
followUps={["Break it down by region"]}
onFollowUp={(text) => console.log(text)}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | string | "Default" | Accepted for registry parity; StreamingText has no visual variants. |
content | StreamingToken[] | TOKENS | Tokens streamed in one at a time, where a token with cite set renders an inline chip for the first source. |
sources | StreamingSource[] | SOURCES | Cited sources shown in the inline chip, the avatar stack and the expandable sources list. |
followUps | string[] | FOLLOW_UPS | Follow-up prompts listed once the stream completes. |
labels | Partial<StreamingLabels> | - | Overrides for the sources toggle label and the follow-ups heading. |
loop | boolean | true | Restarts the stream after a pause; turn it off when embedding in a real thread. |
fill | boolean | false | Fills the parent width instead of using the fixed gallery width. |
onDone | () => void | - | Called when the stream finishes, but only when loop is off. |
onFollowUp | (text: string, index: number) => void | - | Called with the prompt text and its index when a follow-up is chosen. |
Ward · Version 1.1.0 · Registry manifest