By Sagar Shankaran, Founder of CallSphere
Learn how to build a production-quality chat interface for AI agents using React and TypeScript. Covers message bubble components, input handling, and smooth auto-scroll behavior.
Key takeaways
The chat paradigm dominates AI agent interfaces for good reason. Users already understand turn-based conversation from messaging apps, so adopting it for agent interaction eliminates onboarding friction. Building a solid chat UI in React requires three core components: a message list that renders bubbles, an input area that handles submissions, and auto-scroll logic that keeps the latest message visible without disrupting manual scrolling.
Start with a TypeScript type that represents a single chat message. This type drives rendering decisions throughout the component tree.
flowchart LR
INPUT(["User intent"])
PARSE["Parse plus<br/>classify"]
PLAN["Plan and tool<br/>selection"]
AGENT["Agent loop<br/>LLM plus tools"]
GUARD{"Guardrails<br/>and policy"}
EXEC["Execute and<br/>verify result"]
OBS[("Trace and metrics")]
OUT(["Outcome plus<br/>next action"])
INPUT --> PARSE --> PLAN --> AGENT --> GUARD
GUARD -->|Pass| EXEC --> OUT
GUARD -->|Fail| AGENT
AGENT --> OBS
style AGENT fill:#4f46e5,stroke:#4338ca,color:#fff
style GUARD fill:#f59e0b,stroke:#d97706,color:#1f2937
style OBS fill:#ede9fe,stroke:#7c3aed,color:#1e1b4b
style OUT fill:#059669,stroke:#047857,color:#fff
interface ChatMessage {
id: string;
role: "user" | "assistant" | "system";
content: string;
timestamp: Date;
status: "sending" | "sent" | "error";
}
The role field determines bubble alignment and styling. The status field enables optimistic UI patterns where messages appear immediately before server confirmation.
Each message renders as a bubble with alignment and color based on the sender role.
Hear it before you finish reading
Talk to a live CallSphere AI voice agent in your browser — 60 seconds, no signup.
interface BubbleProps {
message: ChatMessage;
}
function MessageBubble({ message }: BubbleProps) {
const isUser = message.role === "user";
return (
<div
className={`flex ${isUser ? "justify-end" : "justify-start"} mb-3`}
>
<div
className={`max-w-[75%] rounded-2xl px-4 py-2.5 ${
isUser
? "bg-blue-600 text-white rounded-br-md"
: "bg-gray-100 text-gray-900 rounded-bl-md"
}`}
>
<p className="text-sm leading-relaxed whitespace-pre-wrap">
{message.content}
</p>
<span className="text-xs opacity-60 mt-1 block">
{message.timestamp.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
</div>
);
}
Key design choices: max-w-[75%] prevents bubbles from stretching across the full viewport. The rounded-br-md and rounded-bl-md classes create a flat corner on the side where the bubble attaches to the sender, which is a familiar pattern from iMessage and WhatsApp.
Auto-scroll must bring new messages into view but stop scrolling when the user has intentionally scrolled up to read history. This requires tracking whether the user is near the bottom.
import { useRef, useEffect, useCallback, useState } from "react";
function useAutoScroll(messages: ChatMessage[]) {
const containerRef = useRef<HTMLDivElement>(null);
const [isNearBottom, setIsNearBottom] = useState(true);
const handleScroll = useCallback(() => {
const el = containerRef.current;
if (!el) return;
const threshold = 100;
const distanceFromBottom =
el.scrollHeight - el.scrollTop - el.clientHeight;
setIsNearBottom(distanceFromBottom < threshold);
}, []);
useEffect(() => {
if (isNearBottom && containerRef.current) {
containerRef.current.scrollTo({
top: containerRef.current.scrollHeight,
behavior: "smooth",
});
}
}, [messages, isNearBottom]);
return { containerRef, handleScroll, isNearBottom };
}
The 100-pixel threshold prevents minor floating-point differences from breaking the near-bottom check. The behavior: "smooth" creates a polished animation instead of a jarring jump.
The input component handles both text entry and submission. It should support multi-line input with Shift+Enter and submit on Enter.
Still reading? Stop comparing — try CallSphere live.
CallSphere ships complete AI voice agents per industry — 14 tools for healthcare, 10 agents for real estate, 4 specialists for salons. See how it actually handles a call before you book a demo.
import { useState, KeyboardEvent } from "react";
interface ChatInputProps {
onSend: (text: string) => void;
disabled?: boolean;
}
function ChatInput({ onSend, disabled }: ChatInputProps) {
const [text, setText] = useState("");
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (text.trim()) {
onSend(text.trim());
setText("");
}
}
};
return (
<div className="border-t p-3 flex gap-2">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
disabled={disabled}
rows={1}
className="flex-1 resize-none rounded-xl border px-4 py-2.5
focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={() => {
if (text.trim()) {
onSend(text.trim());
setText("");
}
}}
disabled={disabled || !text.trim()}
className="rounded-xl bg-blue-600 px-4 py-2.5 text-white
disabled:opacity-50"
>
Send
</button>
</div>
);
}
Combine the bubble list, auto-scroll hook, and input into a single container component.
function AgentChat() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const { containerRef, handleScroll } = useAutoScroll(messages);
const sendMessage = async (text: string) => {
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
role: "user",
content: text,
timestamp: new Date(),
status: "sent",
};
setMessages((prev) => [...prev, userMsg]);
// Call your agent API here and append assistant response
};
return (
<div className="flex flex-col h-[600px] border rounded-2xl">
<div
ref={containerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto p-4"
>
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
</div>
<ChatInput onSend={sendMessage} />
</div>
);
}
Set the textarea height to auto on each change, then immediately set it to scrollHeight. Use a useEffect that runs when the text value changes: ref.current.style.height = "auto"; ref.current.style.height = ref.current.scrollHeight + "px";. Cap it with a max-height CSS property so it does not grow infinitely.
A flat array works well for most chat UIs under a few thousand messages. If you need frequent lookups by ID — for editing, deleting, or updating status — a Map<string, ChatMessage> paired with an ordered ID array gives O(1) lookups while preserving order. For typical agent conversations that stay under a few hundred messages, arrays are simpler and fast enough.
Track isNearBottom from the auto-scroll hook. When it is false, render a floating button at the bottom of the message container that calls containerRef.current.scrollTo({ top: containerRef.current.scrollHeight, behavior: "smooth" }). Hide the button when isNearBottom returns to true.
#React #ChatUI #TypeScript #Frontend #AIAgentInterface #AgenticAI #LearnAI #AIEngineering

Written by
Sagar Shankaran· Founder, CallSphere
LinkedInSagar Shankaran is the founder of CallSphere, where he builds production AI voice and chat agents deployed across healthcare, hospitality, real estate, and home services. He writes about agentic AI, LLM engineering, and shipping voice agents that handle real calls in production.
See how AI voice agents work for your industry. Live demo available -- no signup required.
AI SDK 5 ships fully typed chat for React, Svelte, Vue, and Angular plus first-class agent loop primitives. Here are the patterns that matter for shipping in 2026.
Mastra.ai is becoming the go-to TypeScript agent framework in 2026. Workflows, RAG, evals, and an honest comparison with Vercel AI SDK 5 for serious teams.
How to wire Vercel AI SDK 5 tool calls to a React UI with streaming, partial UI updates, and proper error handling that survives flaky network conditions.
The chat UI is half the user experience. The 2026 patterns for chat interfaces that surface LLM strengths and hide weaknesses.
AGUI is the emerging protocol for streaming agent state to UIs without bespoke glue. The spec, the Vercel/CopilotKit implementations, and the adoption signals to watch.
Convex's reactive queries auto-push every transcript update to every subscribed client. Real working code for actions, mutations, and OpenAI Realtime streaming.
© 2026 CallSphere Inc. All rights reserved.
Made within San Francisco
Watch how CallSphere handles real customer calls, schedules appointments, and processes payments — live.