Chat UI in React: Message List, Input, Typing Indicator
Build a complete chat UI in React with a scrolling message list, auto-growing input, and animated typing indicator — no library required, just hooks and CSS.
What You're Actually Building
Chat UIs look simple until you're in one. Then you hit the edge cases: the message list that needs to auto-scroll to the bottom unless the user has manually scrolled up, the input that should grow with the text but never eat half the screen, and that bouncing dots indicator that needs to appear and disappear without causing layout shift. All of this before you've even touched WebSockets.
This guide walks you through a complete, self-contained chat UI in React 18 — no third-party UI library required, just hooks and a bit of CSS. You'll end up with three composable components: <MessageList />, <ChatInput />, and <TypingIndicator />. They're deliberately small so you can drop them into your existing design system or style them to match whichever aesthetic you're going for — and if you want a head start on visuals, the glassmorphism components on Empire UI work great as card wrappers for chat bubbles.
Worth noting: the code here targets React 18.3+ and TypeScript. If you're on 17, the logic is identical but you won't get the concurrent-mode benefits in useTransition if you choose to add it later.
Structuring Your Message Data
Before you write a single JSX line, nail down the data shape. Every bug in chat UIs traces back to a fuzzy message type. Here's the minimal interface you need:
export type MessageRole = 'user' | 'assistant' | 'system';
export interface Message {
id: string; // crypto.randomUUID() is fine
role: MessageRole;
content: string;
timestamp: number; // Date.now()
status?: 'sending' | 'sent' | 'error';
}The status field is optional but you'll thank yourself later. When a message is in sending state you can show a spinner instead of a timestamp. When it's error you can offer a retry button. Skipping this field means bolting it on after the fact — and that's always messier than it sounds.
Keep your messages in a useState array at the top of your chat container component. Don't reach for Zustand or Redux unless you have multi-tab sync requirements. Local state is fast, predictable, and easy to debug. In practice, I've seen teams over-engineer the state layer of a chat widget and spend two weeks on it when useState would've been fine.
One more thing — generate IDs with crypto.randomUUID() rather than incrementing integers. If you're ever doing optimistic updates or merging messages from a server, sequential IDs collide in ways that are painful to debug at 11pm.
The Message List: Auto-Scroll Without Fighting the User
The scroll behavior is where most implementations go wrong. The naive approach — useEffect(() => bottomRef.current?.scrollIntoView(), [messages]) — auto-scrolls on every new message regardless of whether the user has scrolled up to read history. That's annoying. The fix is a userHasScrolled ref.
import { useEffect, useRef, useState } from 'react';
import type { Message } from './types';
interface MessageListProps {
messages: Message[];
isTyping?: boolean;
}
export function MessageList({ messages, isTyping }: MessageListProps) {
const bottomRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const userScrolled = useRef(false);
// Detect manual scroll-up
const handleScroll = () => {
const el = containerRef.current;
if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
userScrolled.current = distanceFromBottom > 48; // 48px threshold
};
// Auto-scroll on new messages unless user has scrolled up
useEffect(() => {
if (!userScrolled.current) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [messages, isTyping]);
return (
<div
ref={containerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto px-4 py-3 space-y-3"
>
{messages.map((msg) => (
<ChatBubble key={msg.id} message={msg} />
))}
{isTyping && <TypingIndicator />}
<div ref={bottomRef} />
</div>
);
}The 48px threshold is deliberate. It gives the user about one line of breathing room before the auto-scroll kicks back in. Too small and it feels glitchy; too large and new messages don't scroll into view when the user is near the bottom. Tweak it to taste, but 48px works well across most viewport sizes.
For the bubble itself, keep it simple: role === 'user' gets right-aligned with a colored background, everything else gets left-aligned with a neutral surface. You can style this with Tailwind ml-auto / mr-auto plus max-w-[75%] to keep bubbles from stretching wall-to-wall on wide screens.
The Chat Input: Auto-Growing Textarea
A single-line <input> is wrong for chat. Users paste multi-line code, write long messages, and expect the box to grow. But an unconstrained <textarea> can swallow the whole viewport. The sweet spot is a textarea that grows from 1 line to about 5, then scrolls internally.
import { useRef, useEffect, type KeyboardEvent } from 'react';
interface ChatInputProps {
onSend: (content: string) => void;
disabled?: boolean;
placeholder?: string;
}
export function ChatInput({
onSend,
disabled = false,
placeholder = 'Type a message…',
}: ChatInputProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const autoResize = () => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto'; // collapse first
el.style.height = `${Math.min(el.scrollHeight, 160)}px`; // 160px ≈ 5 lines
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
};
const submit = () => {
const el = textareaRef.current;
if (!el || !el.value.trim()) return;
onSend(el.value.trim());
el.value = '';
el.style.height = 'auto'; // reset height
};
return (
<div className="flex items-end gap-2 border-t border-white/10 px-4 py-3">
<textarea
ref={textareaRef}
rows={1}
disabled={disabled}
placeholder={placeholder}
onInput={autoResize}
onKeyDown={handleKeyDown}
className="flex-1 resize-none overflow-y-auto rounded-xl bg-white/5 px-4 py-2.5
text-sm text-white placeholder:text-white/40 outline-none
focus:ring-2 focus:ring-violet-500/60 transition-shadow"
/>
<button
onClick={submit}
disabled={disabled}
className="shrink-0 rounded-xl bg-violet-600 px-4 py-2.5 text-sm
font-medium text-white hover:bg-violet-500 disabled:opacity-40
transition-colors"
>
Send
</button>
</div>
);
}Notice we're not using controlled state (useState) for the textarea value. That's intentional. Controlled inputs call setState on every keystroke, which triggers a re-render, which is fine — but for a high-frequency input like a chat box it's unnecessary overhead. The uncontrolled ref approach reads the value only on submit. Honestly, this is one of those places where 'best practice' and 'right tool for the job' point in different directions.
The Shift+Enter to newline behavior is standard across every major chat app since Slack popularized it around 2014. Don't fight convention here. Users will expect it.
Quick aside: the 160px max height clamps to roughly 5 lines at 16px line-height with normal padding. If your design uses a larger base font, adjust accordingly — the math is (lineHeight * maxLines) + verticalPadding.
The Typing Indicator: Animated Dots
Three bouncing dots. Looks trivial. Has broken more chat UIs than you'd think because people implement it as a fixed-height element and then the message list jumps 32px when it appears and disappears. The trick is to let the <TypingIndicator /> live inside the message list flow, just like a normal message — that way its appearance is just another item being appended, and scroll behavior stays consistent.
export function TypingIndicator() {
return (
<div className="flex items-center gap-1 px-4 py-2 w-fit rounded-2xl bg-white/10">
{[0, 1, 2].map((i) => (
<span
key={i}
className="block h-2 w-2 rounded-full bg-white/60 animate-bounce"
style={{ animationDelay: `${i * 0.15}s`, animationDuration: '0.8s' }}
/>
))}
</div>
);
}Tailwind's animate-bounce does the job. The staggered animationDelay of 150ms per dot gives the rolling wave effect. If you want more control — spring physics, custom easing — you'd reach for Framer Motion, but for most chat widgets this CSS-only version is plenty.
Conditionally render <TypingIndicator /> via an isTyping boolean prop passed down from your container. Your WebSocket or polling logic sets it to true when the server sends a typing event, and back to false when the response arrives or after a 3-second timeout (whichever comes first). The timeout prevents a stuck indicator if the connection drops mid-response.
Look, the visual polish of this component matters more than you might think. Chat UIs are high-context interfaces — users stare at them. If your typing indicator looks clunky or jumpy, it undermines the whole experience. If you want to take the visual up a notch, wrapping the indicator in a glassmorphism generator card gives it that polished, modern feel without much extra code.
Wiring It All Together
Here's the container that connects all three pieces. This version uses a fake async delay to simulate a real API — swap simulateResponse for your actual fetch or WebSocket call.
import { useState, useCallback } from 'react';
import { MessageList } from './MessageList';
import { ChatInput } from './ChatInput';
import type { Message } from './types';
const INITIAL: Message[] = [
{
id: crypto.randomUUID(),
role: 'assistant',
content: 'Hey! How can I help you today?',
timestamp: Date.now(),
status: 'sent',
},
];
export function ChatContainer() {
const [messages, setMessages] = useState<Message[]>(INITIAL);
const [isTyping, setIsTyping] = useState(false);
const handleSend = useCallback(async (content: string) => {
const userMsg: Message = {
id: crypto.randomUUID(),
role: 'user',
content,
timestamp: Date.now(),
status: 'sending',
};
setMessages((prev) => [...prev, userMsg]);
setIsTyping(true);
try {
// Replace with real API call
const reply = await simulateResponse(content);
setMessages((prev) => [
...prev.map((m) =>
m.id === userMsg.id ? { ...m, status: 'sent' as const } : m
),
{
id: crypto.randomUUID(),
role: 'assistant',
content: reply,
timestamp: Date.now(),
status: 'sent',
},
]);
} catch {
setMessages((prev) =>
prev.map((m) =>
m.id === userMsg.id ? { ...m, status: 'error' as const } : m
)
);
} finally {
setIsTyping(false);
}
}, []);
return (
<div className="flex flex-col h-full max-h-[700px] rounded-2xl
bg-black/30 backdrop-blur-md border border-white/10">
<MessageList messages={messages} isTyping={isTyping} />
<ChatInput onSend={handleSend} disabled={isTyping} />
</div>
);
}
async function simulateResponse(input: string): Promise<string> {
await new Promise((r) => setTimeout(r, 1200 + Math.random() * 800));
return `You said: "${input}" — I'd usually say something smarter here.`;
}The disabled={isTyping} prop on <ChatInput /> prevents the user from sending a second message while the first response is pending. Whether that's the right UX depends on your product — some chat apps let you queue messages. But for AI assistant interfaces, blocking is usually the right call.
That said, one thing worth hardening before you ship: the error state. Right now a failed message just gets an error status but nothing in the UI signals that to the user. Add a small red dot or a 'Retry' button on status === 'error' bubbles. Users don't forgive silent failures in messaging apps.
You can slot this whole <ChatContainer /> into any page layout. It respects its parent's height via h-full, so put it inside a fixed-height column and it'll fill the space correctly. If you want it floating — like a support widget — wrap it in a fixed bottom-6 right-6 div and toggle visibility with a button. From there, styling with the gradient generator for the toggle button takes about 2 minutes.
Real-Time: WebSocket vs. SSE vs. Polling
So you've got the UI. How do you feed it live data? Three realistic options: WebSockets, Server-Sent Events (SSE), or plain polling. The right choice depends on your setup, not hype.
WebSockets are bidirectional — the server can push to the client and the client can push back. They're the right call if you're building a peer-to-peer chat, a collaborative editor, or anything where the server needs to react to client events in real time. The downside is infrastructure: you need a stateful server or a relay service (Ably, Pusher, Soketi). In a Next.js App Router project, you can't run a WebSocket server inside a route handler — you'd need a separate Node process.
SSE is underrated. It's unidirectional (server-to-client only), runs over HTTP/1.1, works through proxies without special config, and is natively supported in every modern browser since 2012. If you're building an AI assistant where the client sends a message via fetch and the server streams back tokens, SSE is the cleanest architecture. You use the browser's EventSource API or the fetch streaming body with ReadableStream.
// Consuming an SSE stream from an AI backend
async function streamResponse(
content: string,
onChunk: (chunk: string) => void
) {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: content }),
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
onChunk(decoder.decode(value, { stream: true }));
}
}Polling is the unsexy fallback. setInterval calls fetch every N seconds, gets whatever's new, appends to state. It adds latency (half your polling interval on average), hammers your server at scale, and feels laggy compared to real push. Use it when you genuinely can't do better — like when you're on a serverless platform that kills connections after 30 seconds and SSE isn't an option. In practice, most modern hosting platforms (Vercel, Fly, Railway) support SSE just fine.
FAQ
Track the scroll position with a ref and check the distance from the bottom before calling scrollIntoView. If scrollHeight - scrollTop - clientHeight is more than ~48px, the user has scrolled up — skip the auto-scroll.
Uncontrolled (using a ref) works better here. You only need the value on submit, so firing setState on every keystroke is unnecessary overhead. Reset the height and value on submit via the ref directly.
Render the <TypingIndicator /> inside the message list as a regular list item, not as an overlay or absolutely-positioned element. That way it flows naturally and the scroll behavior stays predictable.
SSE. AI chat is effectively unidirectional — you POST a message and stream tokens back. SSE handles that cleanly over standard HTTP, requires no special infrastructure, and works through proxies without config.