Skip to content
Seamless UI

Streaming Message

Save

Chat 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

Streaming Message preview

You:

What are the new rate limits?

Assistant:

Free plans allow 60 requests/min. Pro plans allow

Installation

npx @seamlessui/cli add streaming-message

Pre-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-run

Usage

streaming-message-example.tsx
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.

Streaming Message states

Assistant:

Checking the docs for

Assistant:

Free plans allow 60 requests/min.

Assistant:

I found three pages, but

API

PropTypeDefaultDescription
role"user" | "assistant"โ€”Who wrote the message.
contentstringโ€”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.contentKeyunknownโ€”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-message
  • src/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.tsxShow
agent/streaming-message.tsx
"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.tsxShow
agent/markdown.tsx
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;
  }
}