Streaming Message
SaveChat messages that stream in as Markdown, plus a scroller that follows new text without stealing the reader's scroll position.
- Category
- Agent
- Version
- 0.2.0
- Frameworks
- Next.js, Vite + React
- Styling
- Tailwind CSS v4
Preview
You:
What are the new rate limits?
Assistant:
Free plans allow 60 requests/min. Pro plans allow
Installation
npx @seamlessui/cli add streaming-messagePre-release@seamlessui/cli is not on npm yet. Install from a local build.
Review the plan first. A dry run lists every file and package without changing anything:
npx @seamlessui/cli add streaming-message --dry-runUsage
import { StreamingMessage } from "@/components/seamless/agent/streaming-message";
export default function StreamingMessageExample() {
return (
<div className="grid w-full max-w-md gap-3">
<StreamingMessage role="user" content="What are the new rate limits?" />
<StreamingMessage role="assistant" status="streaming" content={"Free plans allow **60 requests/min**. Pro plans allow"} />
</div>
);
}States
Every state, rendered from local fixture data.
Assistant:
Checking the docs for
Assistant:
Free plans allow 60 requests/min.
- Retry after a
429 - See the docs
Assistant:
I found three pages, but
The connection closed before the reply finished.
API
| Prop | Type | Default | Description |
|---|---|---|---|
| role | "user" | "assistant" | โ | Who wrote the message. |
| content | string | โ | Markdown text. Raw HTML is shown as text and never executed; links allow only http(s), mailto, and relative URLs. |
| status | "streaming" | "complete" | "failed" | "complete" | Streaming shows a caret after the last word; failed keeps the partial text and shows the error. |
| MessageScroller.contentKey | unknown | โ | Changes when content grows. The scroller follows new content only while the reader is at the bottom. |
Accessibility
- MessageScroller is a log region that announces new messages politely.
- Scrolling up keeps the reader's place; a Jump to latest button returns to the newest message.
- Jump to latest scrolls instantly when reduced motion is preferred, and the caret holds still.
- Failures are announced with role=alert and keep the partial text.
Files
Installed into src/components/seamless, or components/seamless when the project has no src directory.
src/components/seamless/agent/streaming-message.tsxstreaming-messagesrc/components/seamless/agent/markdown.tsxstreaming-message- Also installs agent-run@0.1.1registry dependency
- npm packages: noneReact and Tailwind CSS v4 required
Source
The exact source the CLI installs, from /r/items/react/streaming-message/0.2.0.json.
agent/streaming-message.tsxShowHide
"use client";
import { type ComponentProps, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { MessageStatus } from "./agent-run";
import { Markdown } from "./markdown";
export interface StreamingMessageProps extends Omit<ComponentProps<"article">, "children"> {
role: "user" | "assistant";
/** Message text, as Markdown. Raw HTML is shown as text, never executed. */
content: string;
status?: MessageStatus;
/** Shown when status is "failed". */
error?: ReactNode;
author?: ReactNode;
}
/**
* One chat message. User messages sit in a quiet tinted bubble; assistant replies are
* open text. While streaming, a caret follows the last word; failures keep the partial text.
*/
export function StreamingMessage({ role, content, status = "complete", error, author, className, ...props }: StreamingMessageProps) {
const isUser = role === "user";
const streaming = status === "streaming";
const caret = streaming ? (
<span
aria-hidden="true"
className="ml-0.5 inline-block h-[1.1em] w-[2px] translate-y-[0.2em] rounded-full bg-sky-600 motion-safe:animate-pulse dark:bg-sky-400"
/>
) : null;
return (
<article
data-role={role}
data-status={status}
aria-busy={streaming || undefined}
className={[
"flex transition-[opacity,translate] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] starting:translate-y-1 starting:opacity-0 motion-reduce:transition-none",
isUser ? "justify-end" : "justify-start",
className,
]
.filter(Boolean)
.join(" ")}
{...props}
>
<div
className={[
"min-w-0 text-sm leading-relaxed text-zinc-950 dark:text-white",
isUser
? "max-w-[85%] rounded-2xl rounded-br-md border border-zinc-950/[0.08] bg-zinc-950/[0.04] px-3.5 py-2 dark:border-white/15 dark:bg-black"
: "w-full max-w-prose py-0.5",
].join(" ")}
>
<p className="sr-only">{author ?? (isUser ? "You" : "Assistant")}:</p>
{content ? (
<Markdown source={content} trailing={caret} />
) : streaming ? (
<p className="flex items-center gap-2 text-zinc-500">
<svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="size-3.5 text-sky-600 dark:text-sky-400">
<circle cx="8" cy="8" r="6.25" strokeOpacity="0.2" />
<path d="M8 1.75A6.25 6.25 0 1 1 1.75 8" className="origin-center motion-safe:animate-spin" />
</svg>
Thinking
</p>
) : null}
{status === "failed" ? (
<p
role="alert"
className="mt-3 flex items-start gap-2 rounded-lg border border-red-500/35 bg-red-500/[0.08] px-3 py-2 text-sm text-red-700 transition-[opacity,translate] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] starting:-translate-y-1 starting:opacity-0 motion-reduce:transition-none dark:text-red-300"
>
<svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="mt-0.5 size-4 shrink-0">
<circle cx="8" cy="8" r="6.25" />
<path d="m6.1 6.1 3.8 3.8m0-3.8-3.8 3.8" />
</svg>
<span>{error ?? "The response stopped before it finished."}</span>
</p>
) : null}
</div>
</article>
);
}
export interface MessageScrollerProps extends ComponentProps<"div"> {
/** Changes whenever content grows (e.g. total characters); used to follow new text. */
contentKey: unknown;
}
/**
* A scrolling message list that follows new content only while the reader is at the
* bottom. Scrolling up keeps their place and shows a "Jump to latest" button.
*/
export function MessageScroller({ contentKey, className, children, ...props }: MessageScrollerProps) {
const ref = useRef<HTMLDivElement>(null);
const [following, setFollowing] = useState(true);
const followingRef = useRef(true);
const onScroll = useCallback(() => {
const element = ref.current;
if (!element) return;
const atBottom = element.scrollHeight - element.scrollTop - element.clientHeight < 24;
followingRef.current = atBottom;
setFollowing(atBottom);
}, []);
useLayoutEffect(() => {
const element = ref.current;
if (element && followingRef.current) element.scrollTop = element.scrollHeight;
}, [contentKey]);
useEffect(() => {
const element = ref.current;
element?.addEventListener("scroll", onScroll, { passive: true });
return () => element?.removeEventListener("scroll", onScroll);
}, [onScroll]);
function jumpToLatest() {
const element = ref.current;
if (!element) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
element.scrollTo({ top: element.scrollHeight, behavior: reduce ? "auto" : "smooth" });
followingRef.current = true;
setFollowing(true);
}
return (
<div className="relative min-h-0">
<div ref={ref} role="log" aria-live="polite" aria-relevant="additions" className={["h-full overflow-y-auto", className].filter(Boolean).join(" ")} {...props}>
{children}
</div>
{following ? null : (
<button
type="button"
onClick={jumpToLatest}
className={[
"absolute bottom-3 left-1/2 inline-flex h-8 -translate-x-1/2 items-center gap-1.5 rounded-lg border border-zinc-950/10 bg-white px-3 text-xs font-medium text-zinc-800",
"shadow-[0_12px_32px_-8px_rgb(0_0_0/0.18)] transition-[opacity,translate,background-color] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] starting:translate-y-1 starting:opacity-0",
"hover:bg-zinc-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 motion-reduce:transition-none",
"dark:border-white/15 dark:bg-black dark:text-zinc-200 dark:shadow-[0_16px_48px_-12px_rgb(0_0_0/1),inset_0_1px_0_rgb(255_255_255/0.08)] dark:hover:border-white/25",
].join(" ")}
>
Jump to latest
<svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="size-3.5">
<path d="M8 3v10M4 9l4 4 4-4" />
</svg>
</button>
)}
</div>
);
}agent/markdown.tsxShowHide
import { Fragment, type ReactNode } from "react";
type Block =
| { type: "code"; language: string; code: string }
| { type: "heading"; level: number; text: string }
| { type: "list"; ordered: boolean; items: string[] }
| { type: "paragraph"; text: string };
export interface MarkdownProps {
source: string;
/** Rendered at the end of the last line of text, e.g. a streaming caret. */
trailing?: ReactNode;
}
/**
* Renders a safe subset of Markdown as React elements: paragraphs, headings, lists,
* fenced code, inline code, bold, italic, and links. Raw HTML is shown as text and
* never executed; links are limited to http(s), mailto, and relative URLs.
* Unfinished syntax (common while streaming) renders as plain text.
*/
export function Markdown({ source, trailing }: MarkdownProps) {
const blocks = parse(source);
const last = blocks.length - 1;
// A caret after a code block would sit inside preformatted text, so it goes below instead.
const trailingInline = trailing != null && last >= 0 && blocks[last]!.type !== "code";
return (
<div className="grid gap-3">
{blocks.map((block, index) => {
const tail = trailingInline && index === last ? trailing : null;
switch (block.type) {
case "code":
return (
<pre
key={index}
className="overflow-x-auto rounded-lg border border-zinc-950/[0.07] bg-zinc-950/[0.02] px-3 py-2.5 font-mono text-xs leading-relaxed dark:border-white/[0.08] dark:bg-transparent"
>
<code data-language={block.language || undefined}>{block.code}</code>
</pre>
);
case "heading":
return (
<p key={index} role="heading" aria-level={Math.min(block.level + 3, 6)} className="font-semibold">
{inline(block.text)}
{tail}
</p>
);
case "list": {
const List = block.ordered ? "ol" : "ul";
return (
<List
key={index}
className={`${block.ordered ? "list-decimal" : "list-disc"} space-y-1 pl-5 marker:text-zinc-400 dark:marker:text-zinc-600`}
>
{block.items.map((item, i) => (
<li key={i} className="pl-0.5">
{inline(item)}
{i === block.items.length - 1 ? tail : null}
</li>
))}
</List>
);
}
default:
return (
<p key={index}>
{inline(block.text)}
{tail}
</p>
);
}
})}
{trailing != null && !trailingInline ? <p>{trailing}</p> : null}
</div>
);
}
const LIST = /^\s*([-*]|\d+\.)\s+/;
function parse(source: string): Block[] {
const blocks: Block[] = [];
const lines = source.replace(/\r\n?/g, "\n").split("\n");
let index = 0;
while (index < lines.length) {
const line = lines[index]!;
const fence = line.match(/^```\s*([\w-]*)\s*$/);
if (fence) {
const code: string[] = [];
index++;
while (index < lines.length && !/^```\s*$/.test(lines[index]!)) code.push(lines[index++]!);
index++;
blocks.push({ type: "code", language: fence[1] ?? "", code: code.join("\n") });
continue;
}
const heading = line.match(/^(#{1,3})\s+(.*)$/);
if (heading) {
blocks.push({ type: "heading", level: heading[1]!.length, text: heading[2]! });
index++;
continue;
}
if (LIST.test(line)) {
const ordered = /^\s*\d+\./.test(line);
const items: string[] = [];
while (index < lines.length && LIST.test(lines[index]!)) items.push(lines[index++]!.replace(LIST, ""));
blocks.push({ type: "list", ordered, items });
continue;
}
if (!line.trim()) {
index++;
continue;
}
const paragraph: string[] = [];
while (index < lines.length && lines[index]!.trim() && !/^(```|#{1,3}\s|\s*([-*]|\d+\.)\s)/.test(lines[index]!)) paragraph.push(lines[index++]!);
blocks.push({ type: "paragraph", text: paragraph.join(" ") });
}
return blocks;
}
const INLINE = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*\s][^*]*\*|\[[^\]]+\]\([^)\s]+\))/g;
function inline(text: string): ReactNode {
return text.split(INLINE).map((part, i) => {
if (part.startsWith("`") && part.endsWith("`") && part.length > 2) {
return (
<code
key={i}
className="rounded-md border border-zinc-950/[0.08] bg-zinc-950/[0.03] px-1 py-px font-mono text-[0.85em] dark:border-white/12 dark:bg-transparent"
>
{part.slice(1, -1)}
</code>
);
}
if (part.startsWith("**") && part.endsWith("**") && part.length > 4)
return (
<strong key={i} className="font-semibold">
{part.slice(2, -2)}
</strong>
);
if (part.startsWith("*") && part.endsWith("*") && part.length > 2) return <em key={i}>{part.slice(1, -1)}</em>;
const link = part.match(/^\[([^\]]+)\]\(([^)\s]+)\)$/);
if (link) {
const href = safeHref(link[2]!);
return href ? (
<a
key={i}
href={href}
className="rounded-sm font-medium underline decoration-current/30 underline-offset-[3px] transition-[text-decoration-color] duration-150 hover:decoration-current focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 motion-reduce:transition-none"
rel="noopener noreferrer nofollow"
target={href.startsWith("/") ? undefined : "_blank"}
>
{link[1]}
</a>
) : (
<Fragment key={i}>{link[1]}</Fragment>
);
}
return <Fragment key={i}>{part}</Fragment>;
});
}
function safeHref(href: string): string | null {
if (href.startsWith("/") && !href.startsWith("//")) return href;
try {
const url = new URL(href);
return ["http:", "https:", "mailto:"].includes(url.protocol) ? url.toString() : null;
} catch {
return null;
}
}