A token-streaming renderer where arriving words fade in without layout shift, with a block cursor at the write head and markdown-lite bolding.
npx shadcn@latest add @paragon/streaming-text"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
type Token = { text: string; bold: boolean };
/** Splits text into whitespace-preserving word tokens, parsing `**bold**`. */
function tokenize(text: string, parseBold: boolean): Token[] {
const segments: Token[] = [];
if (parseBold) {
const re = /\*\*([^*]+)\*\*/g;
let last = 0;
let match: RegExpExecArray | null;
while ((match = re.exec(text)) !== null) {
if (match.index > last) {
segments.push({ text: text.slice(last, match.index), bold: false });
}
segments.push({ text: match[1] ?? "", bold: true });
last = match.index + match[0].length;
}
if (last < text.length) {
segments.push({ text: text.slice(last), bold: false });
}
} else {
segments.push({ text, bold: false });
}
const tokens: Token[] = [];
for (const segment of segments) {
for (const piece of segment.text.split(/(\s+)/)) {
if (piece) tokens.push({ text: piece, bold: segment.bold });
}
}
return tokens;
}
export interface StreamingTextProps extends React.ComponentProps<"div"> {
/** The accumulated text streamed so far. Append-only for best results. */
text: string;
/** Shows the block cursor at the write head. */
streaming?: boolean;
/** Parse `**bold**` spans (markdown-lite). */
parseBold?: boolean;
/** Per-token fade-in duration in ms. */
fadeDuration?: number;
/** Renders tokens without the arrival fade. */
static?: boolean;
}
/**
* Token-streaming text renderer. Each word that arrives fades in — opacity
* only, so nothing shifts layout per token — with a subtle block cursor at
* the write head that disappears on completion.
*
* Technique: text splits into whitespace-preserving word spans keyed by
* index. As `text` grows, existing spans keep their DOM nodes (their fade
* never restarts) and only newly mounted spans run the enter keyframe,
* `backwards`-filled so they never flash at full opacity first.
*/
export function StreamingText({
text,
streaming = false,
parseBold = true,
fadeDuration = 250,
static: isStatic = false,
className,
...props
}: StreamingTextProps) {
const tokens = React.useMemo(
() => tokenize(text, parseBold),
[text, parseBold],
);
return (
<div
data-slot="streaming-text"
aria-live="polite"
className={cn(
"text-sm leading-relaxed whitespace-pre-wrap text-foreground",
className,
)}
{...props}
>
{!isStatic && (
<style href="paragon-streaming-text" precedence="paragon">{`
@keyframes pg-stream-fade {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes pg-stream-cursor {
0%, 100% { opacity: 0.7; }
50% { opacity: 0.2; }
}
@media (prefers-reduced-motion: reduce) {
[data-stream] [data-token] { animation: none !important; }
[data-stream] [data-cursor] { animation: none !important; }
}
`}</style>
)}
<span data-stream="">
{tokens.map((token, i) => (
<span
key={i}
data-token=""
className={token.bold ? "font-semibold" : undefined}
style={
isStatic
? undefined
: {
animation: `pg-stream-fade ${fadeDuration}ms var(--ease-out) backwards`,
}
}
>
{token.text}
</span>
))}
{streaming && (
<span
aria-hidden
data-cursor=""
className="ml-px inline-block h-[1em] w-[0.45em] translate-y-[0.15em] rounded-[2px] bg-foreground/70"
style={
isStatic
? undefined
: { animation: `pg-stream-cursor 1.1s ease-in-out infinite` }
}
/>
)}
</span>
</div>
);
}