Skip to content
Learn Agentic AI11 min read0 views

Building a Chat UI with React: Message Bubbles, Input, and Auto-Scroll

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.

Why Chat Is the Default Agent Interface

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.

Defining the Message Model

Start with a TypeScript type that represents a single chat message. This type drives rendering decisions throughout the component tree.

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.

The Message Bubble Component

Each message renders as a bubble with alignment and color based on the sender role.

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 with Manual Override

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.

See AI Voice Agents Handle Real Calls

Book a free demo or calculate how much you can save with AI voice automation.

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 Chat Input Component

The input component handles both text entry and submission. It should support multi-line input with Shift+Enter and submit on Enter.

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>
  );
}

Assembling the Full Chat Container

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>
  );
}

FAQ

How do I auto-resize the textarea as the user types?

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.

Should I use a flat array or a Map for storing messages?

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.

How do I handle the scroll-to-bottom button that appears when the user scrolls up?

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

Share this article
C

CallSphere Team

Expert insights on AI voice agents and customer communication automation.

Try CallSphere AI Voice Agents

See how AI voice agents work for your industry. Live demo available -- no signup required.