Chat Interface in React: Messages, Timestamps, Typing Indicator
Build a full React chat interface with message bubbles, relative timestamps, and an animated typing indicator — all from scratch with hooks and Tailwind.
What You're Actually Building
Chat UIs look deceptively simple — a list of bubbles, a text box, maybe a little dot animation. But the moment you start wiring it up in React, you hit a pile of decisions: where does state live, how do you auto-scroll without fighting the browser, what do you do when the typing indicator appears before a message arrives? This article walks through every one of those decisions.
We're building a self-contained chat component that handles incoming messages (simulated here, trivially swappable with a WebSocket or server-sent events), timestamps that display relative time and age out gracefully, and a typing indicator with a three-dot bounce animation. No external chat SDK required. Nothing beyond React 18 and Tailwind v3.
The full component fits in one file for clarity, but the patterns — custom hooks, message normalisation, scroll management — all scale to production. If you want ideas on how the final thing can look, check out the glassmorphism components gallery for some moody dark-panel aesthetics that work really well for chat UIs.
Message Data Model and State Shape
Get the data model right first, because refactoring it later means rewriting half your render logic. Each message needs at minimum: a unique id, a role ('user' or 'assistant'), a content string, and a createdAt timestamp. That's it. Don't overengineer it.
type Role = 'user' | 'assistant';
interface Message {
id: string;
role: Role;
content: string;
createdAt: Date;
}
interface ChatState {
messages: Message[];
isTyping: boolean;
}The isTyping flag lives at the same level as messages — it's chat-level state, not per-message. A lot of implementations make the mistake of attaching typing state to the last message, which creates weird render coupling. Keep them separate.
Worth noting: if you're connecting to a real backend, you'll normalise incoming payloads into this shape at the boundary (in your WebSocket onmessage handler or your fetch result). Every layer below that boundary operates on clean Message objects, not raw API responses. This makes testing dramatically easier.
Building the Message List with Auto-Scroll
Auto-scroll is where most implementations go wrong. You can't just call scrollIntoView on every render — that fights the user if they've scrolled up to read history. The rule: only auto-scroll when the user was already at the bottom before the new message arrived.
import { useEffect, useRef } from 'react';
function useAutoScroll(dependency: unknown) {
const containerRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const isAtBottom =
container.scrollHeight - container.scrollTop - container.clientHeight < 60;
if (isAtBottom) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [dependency]);
return { containerRef, bottomRef };
}The 60px threshold is intentional — 0px would mean the user has to be pixel-perfect at the bottom. In practice, anything under ~80px feels like "I'm at the bottom" to a human scrolling on a trackpad.
Now wire it into the message list. The dependency you pass is your messages array length plus the isTyping flag, so the scroll triggers both when new messages arrive and when the typing indicator appears or disappears.
export function MessageList({ messages, isTyping }: {
messages: Message[];
isTyping: boolean;
}) {
const { containerRef, bottomRef } = useAutoScroll(
`${messages.length}-${isTyping}`
);
return (
<div
ref={containerRef}
className="flex-1 overflow-y-auto px-4 py-3 space-y-3"
>
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
{isTyping && <TypingIndicator />}
<div ref={bottomRef} />
</div>
);
}Message Bubbles and Relative Timestamps
The bubble layout is a flexbox row that swaps alignment based on role. User messages sit on the right, assistant messages on the left. Classic iMessage pattern, used this way since 2011 because it genuinely works.
function MessageBubble({ message }: { message: Message }) {
const isUser = message.role === 'user';
return (
<div className={`flex items-end gap-2 ${
isUser ? 'flex-row-reverse' : 'flex-row'
}`}>
{!isUser && (
<div className="w-8 h-8 rounded-full bg-purple-600 flex-shrink-0 flex items-center justify-center text-xs text-white">
AI
</div>
)}
<div className={`max-w-[70%] ${
isUser
? 'bg-purple-600 text-white rounded-2xl rounded-br-sm'
: 'bg-zinc-800 text-zinc-100 rounded-2xl rounded-bl-sm'
} px-4 py-2.5`}>
<p className="text-sm leading-relaxed">{message.content}</p>
<span className="block text-[11px] mt-1 opacity-60">
{formatRelativeTime(message.createdAt)}
</span>
</div>
</div>
);
}The timestamp formatter is where people typically reach for a library. Don't. A lightweight local function handles the 95% case — 'just now', '2m ago', '1h ago', 'Yesterday', and then the actual date for anything older than 48 hours.
function formatRelativeTime(date: Date): string {
const diffMs = Date.now() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
if (diffSec < 30) return 'just now';
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHour < 24) return `${diffHour}h ago`;
if (diffHour < 48) return 'Yesterday';
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}Honestly, the max-w-[70%] cap matters more than it seems. Without it, a short user message stretches the full width on wide screens and looks bizarre — like a sticky note the size of a billboard. 70% is the sweet spot for desktop; on mobile you'd bump that to max-w-[85%].
The Typing Indicator Animation
Three bouncing dots. You've seen them a thousand times. They're surprisingly hard to get right — timing has to feel natural, not mechanical. The classic mistake is using the same animation delay for all three dots, which makes them bounce in unison and looks wrong.
The trick is staggered animation-delay values: 0ms, 160ms, 320ms. That cadence is close to the tempo of natural speech rhythm, which is why it reads as 'someone is thinking' rather than 'a clock is ticking'.
export function TypingIndicator() {
return (
<div className="flex items-end gap-2">
<div className="w-8 h-8 rounded-full bg-purple-600 flex-shrink-0 flex items-center justify-center text-xs text-white">
AI
</div>
<div className="bg-zinc-800 rounded-2xl rounded-bl-sm px-4 py-3">
<div className="flex items-center gap-1">
{[0, 1, 2].map((i) => (
<span
key={i}
className="block w-2 h-2 rounded-full bg-zinc-400 animate-bounce"
style={{ animationDelay: `${i * 160}ms` }}
/>
))}
</div>
</div>
</div>
);
}Tailwind's animate-bounce uses a default duration of 1000ms per cycle. That's slightly slow for a typing indicator — in your global CSS or a tailwind.config.js extension, override the keyframe duration to 600ms for a snappier feel. Quick aside: you can also pull animation inspiration directly from the gradient generator and box shadow generator tools to style the dots themselves with a subtle gradient shimmer instead of a flat colour.
Input Bar and Sending Messages
The input bar has one job: capture text, fire a handler, clear itself. Keep it dumb — no business logic in the component. The send handler lives above and decides what to do with the message (add to list, trigger API call, whatever).
function ChatInput({ onSend, disabled }: {
onSend: (text: string) => void;
disabled?: boolean;
}) {
const [value, setValue] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = value.trim();
if (!trimmed || disabled) return;
onSend(trimmed);
setValue('');
};
return (
<form
onSubmit={handleSubmit}
className="flex items-center gap-2 border-t border-zinc-800 px-4 py-3"
>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Message..."
disabled={disabled}
className="flex-1 bg-zinc-900 text-zinc-100 placeholder-zinc-500
rounded-xl px-4 py-2.5 text-sm focus:outline-none
focus:ring-2 focus:ring-purple-500 disabled:opacity-50"
/>
<button
type="submit"
disabled={!value.trim() || disabled}
className="p-2.5 rounded-xl bg-purple-600 text-white
disabled:opacity-40 hover:bg-purple-500 transition-colors"
>
<SendIcon className="w-4 h-4" />
</button>
</form>
);
}The disabled prop does double duty — it blocks sending while the assistant is 'typing', and visually signals that to the user via disabled:opacity-50. That 50% opacity on the input field is heavy enough to notice but not so heavy that it looks broken. In practice, anything below 40% starts reading as 'this is permanently broken' rather than 'hold on a second'.
One more thing — handle the Enter key submit naturally via form.onSubmit. Don't intercept keydown events manually for this. The form submission approach handles accessibility (screen readers announce form role), mobile keyboards (they show a 'send' button), and password managers that accidentally autofill your chat input — all for free.
Putting the Chat Container Together
The top-level component holds ChatState, simulates an assistant reply with setTimeout (swap this for your real API call), and renders the three sub-components in a flex column. The container needs a fixed height so the message list can scroll independently — h-[600px] or h-screen depending on your layout.
import { useState, useCallback } from 'react';
import { v4 as uuidv4 } from 'uuid';
export function ChatContainer() {
const [state, setState] = useState<ChatState>({
messages: [],
isTyping: false,
});
const handleSend = useCallback((text: string) => {
const userMsg: Message = {
id: uuidv4(),
role: 'user',
content: text,
createdAt: new Date(),
};
setState((prev) => ({
...prev,
messages: [...prev.messages, userMsg],
isTyping: true,
}));
// Swap this block with your actual API call
setTimeout(() => {
const assistantMsg: Message = {
id: uuidv4(),
role: 'assistant',
content: `You said: "${text}" — and I have thoughts.`,
createdAt: new Date(),
};
setState((prev) => ({
messages: [...prev.messages, assistantMsg],
isTyping: false,
}));
}, 1800);
}, []);
return (
<div className="flex flex-col h-[600px] w-full max-w-lg mx-auto
bg-zinc-900 rounded-2xl overflow-hidden border border-zinc-800">
<header className="px-4 py-3 border-b border-zinc-800">
<p className="text-sm font-semibold text-zinc-100">Assistant</p>
<p className="text-xs text-zinc-500">
{state.isTyping ? 'Typing...' : 'Online'}
</p>
</header>
<MessageList
messages={state.messages}
isTyping={state.isTyping}
/>
<ChatInput
onSend={handleSend}
disabled={state.isTyping}
/>
</div>
);
}Look, the 1800ms delay in the simulation isn't random — it's long enough to see the typing indicator animate through at least two full bounce cycles, which gives you realistic QA coverage of the animation before you wire up a real backend. Set it shorter and you'll miss edge cases in the scroll behaviour.
From here, replacing setTimeout with a WebSocket listener is straightforward. Your onmessage handler calls setState the same way — set isTyping: true on typing_start events, push the message and set isTyping: false on message events. The component doesn't care where the data comes from. That's the whole point of the separation. Want to go further with the visual design? The cyberpunk and vaporwave style hubs on Empire UI have card and panel components that adapt really nicely to a dark-themed chat shell.
FAQ
Replace the setTimeout block in handleSend with your WebSocket send call, then in your ws.onmessage handler call setState the same way — push messages and toggle isTyping based on event type. The component shape doesn't change at all.
Array indexes change when you prepend history or delete messages, which breaks React's key reconciliation and causes flickering. A stable UUID means React always knows exactly which DOM node maps to which message.
Add a status field to your Message type: 'sending' | 'sent' | 'read'. Render different icons in the timestamp area based on that value, and update status via your backend event stream.
The chat container needs to be a Client Component ('use client') because it uses useState and event handlers. Wrap it in a Server Component that fetches initial message history and passes it as props — that's the right split.