AI Streaming UI

beginner

React components for streaming AI responses with typing indicators and message lists.

aistreamingreactuicomponents
Tested on201619TS5.9
$ bunx sinew add ai/ai-streaming-ui
Interactive demo coming soon

1The Problem

AI chat interfaces need:

  • Real-time streaming text display
  • Typing indicators while loading
  • Auto-scrolling message lists
  • Accessible input handling

2The Solution

Pre-built React components using the Vercel AI SDK's useChat hook. Includes streaming text, message lists, and chat input components.

3Files

components/ai/streaming-text.tsx

components/ai/streaming-text.tsxTSX
"use client";

import { useEffect, useRef } from "react";

interface StreamingTextProps {
  text: string;
  isStreaming?: boolean;
  className?: string;
}

export function StreamingText({ text, isStreaming = false, className = "" }: StreamingTextProps) {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (isStreaming && containerRef.current) {
      containerRef.current.scrollTop = containerRef.current.scrollHeight;
    }
  }, [text, isStreaming]);

  return (
    <div ref={containerRef} className={`relative ${className}`}>
      <p className="whitespace-pre-wrap">{text}</p>
      {isStreaming && <span className="ml-1 inline-block h-4 w-2 animate-pulse bg-current" />}
    </div>
  );
}

components/ai/chat-messages.tsx

components/ai/chat-messages.tsxTSX
"use client";

import { useRef, useEffect } from "react";
import type { UIMessage } from "ai";
import { StreamingText } from "./streaming-text";

interface ChatMessagesProps {
  messages: UIMessage[];
  isLoading?: boolean;
  className?: string;
}

// v5+ UIMessages hold their text in a parts array, not a content string.
function messageText(message: UIMessage): string {
  return message.parts.map((part) => (part.type === "text" ? part.text : "")).join("");
}

export function ChatMessages({ messages, isLoading = false, className = "" }: ChatMessagesProps) {
  const messagesEndRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);

  return (
    <div className={`flex flex-col space-y-4 overflow-y-auto p-4 ${className}`}>
      {messages.map((message, index) => {
        const isUser = message.role === "user";
        const isLastAssistant = !isUser && index === messages.length - 1 && isLoading;
        const text = messageText(message);

        return (
          <div key={message.id} className={`flex ${isUser ? "justify-end" : "justify-start"}`}>
            <div
              className={`max-w-[80%] rounded-2xl px-4 py-3 ${
                isUser ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-900"
              }`}
            >
              {isLastAssistant ? (
                <StreamingText text={text} isStreaming={true} />
              ) : (
                <p className="whitespace-pre-wrap">{text}</p>
              )}
            </div>
          </div>
        );
      })}

      {isLoading && messages[messages.length - 1]?.role === "user" && (
        <div className="flex justify-start">
          <div className="rounded-2xl bg-gray-100 px-4 py-3">
            <TypingIndicator />
          </div>
        </div>
      )}

      <div ref={messagesEndRef} />
    </div>
  );
}

function TypingIndicator() {
  return (
    <div className="flex space-x-1">
      <div
        className="h-2 w-2 animate-bounce rounded-full bg-gray-400"
        style={{ animationDelay: "0ms" }}
      />
      <div
        className="h-2 w-2 animate-bounce rounded-full bg-gray-400"
        style={{ animationDelay: "150ms" }}
      />
      <div
        className="h-2 w-2 animate-bounce rounded-full bg-gray-400"
        style={{ animationDelay: "300ms" }}
      />
    </div>
  );
}

components/ai/chat-input.tsx

components/ai/chat-input.tsxTSX
"use client";

import { useRef, useEffect, KeyboardEvent } from "react";

interface ChatInputProps {
  value: string;
  onChange: (value: string) => void;
  onSubmit: () => void;
  isLoading?: boolean;
  placeholder?: string;
}

export function ChatInput({
  value,
  onChange,
  onSubmit,
  isLoading = false,
  placeholder = "Type a message...",
}: ChatInputProps) {
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  useEffect(() => {
    const textarea = textareaRef.current;
    if (textarea) {
      textarea.style.height = "auto";
      textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`;
    }
  }, [value]);

  const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      if (value.trim() && !isLoading) onSubmit();
    }
  };

  return (
    <div className="flex items-end gap-2 border-t p-4">
      <textarea
        ref={textareaRef}
        value={value}
        onChange={(e) => onChange(e.target.value)}
        onKeyDown={handleKeyDown}
        placeholder={placeholder}
        disabled={isLoading}
        rows={1}
        className="flex-1 resize-none rounded-2xl border px-4 py-3 focus:ring-2 focus:ring-blue-500 focus:outline-none"
      />
      <button
        onClick={onSubmit}
        disabled={isLoading || !value.trim()}
        className="rounded-2xl bg-blue-600 px-4 py-3 text-white disabled:opacity-50"
      >
        Send
      </button>
    </div>
  );
}

hooks/use-ai-chat.ts

hooks/use-ai-chat.tsTypeScript
"use client";

import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useCallback, useMemo, useState } from "react";

export function useAIChat({
  apiEndpoint = "/api/chat",
  sessionId,
  systemPrompt,
}: {
  apiEndpoint?: string;
  sessionId?: string;
  systemPrompt?: string;
} = {}) {
  // useChat no longer manages input state in v5+ — own it here.
  const [input, setInput] = useState("");

  const chat = useChat({
    transport: new DefaultChatTransport({
      api: apiEndpoint,
      body: { sessionId, systemPrompt },
    }),
  });

  const isLoading = chat.status === "submitted" || chat.status === "streaming";

  const handleSubmit = useCallback(() => {
    const text = input.trim();
    if (!text || isLoading) return;
    chat.sendMessage({ text });
    setInput("");
  }, [chat, input, isLoading]);

  const clearChat = useCallback(() => {
    chat.setMessages([]);
    setInput("");
  }, [chat]);

  const isEmpty = useMemo(() => chat.messages.length === 0, [chat.messages.length]);

  return { ...chat, input, setInput, handleSubmit, isLoading, clearChat, isEmpty };
}

4Dependencies

$ bun add ai

5Usage

Complete Chat Interface

"use client";

import { useAIChat } from "@/hooks/use-ai-chat";
import { ChatMessages } from "@/components/ai/chat-messages";
import { ChatInput } from "@/components/ai/chat-input";

export function ChatContainer({ sessionId }: { sessionId: string }) {
  const { messages, input, setInput, handleSubmit, isLoading, clearChat } = useAIChat({
    sessionId,
  });

  return (
    <div className="flex h-full flex-col">
      <div className="flex justify-between border-b p-4">
        <h2>Chat</h2>
        <button onClick={clearChat}>Clear</button>
      </div>
      <ChatMessages messages={messages} isLoading={isLoading} />
      <ChatInput
        value={input}
        onChange={setInput}
        onSubmit={() => handleSubmit()}
        isLoading={isLoading}
      />
    </div>
  );
}
TSX

Standalone Streaming Text

import { StreamingText } from "@/components/ai/streaming-text";

<StreamingText text={streamingContent} isStreaming={isGenerating} />;
TSX

Custom Styling

<ChatMessages messages={messages} isLoading={isLoading} className="rounded-lg bg-gray-50" />
TSX

6Customization

StreamingText and ChatMessages accept a className prop for custom styling. The components use Tailwind CSS classes but can be adapted to any styling solution.

Related patterns