Building Chat Interfaces for AI: Design Patterns and Best Practices
Expert Guide to Creating Intuitive, Accessible, and Performant AI Chat Interfaces
I’ve designed and built chat interfaces for over 20 AI applications, and I can tell you: the difference between a good chat interface and a great one isn’t the AI—it’s the UX. A well-designed chat interface makes AI feel intelligent, responsive, and trustworthy. A poorly designed one makes even the best AI feel broken.
In this guide, I’ll share the design patterns, UX principles, and implementation details I’ve learned building production chat interfaces. You’ll learn how to create interfaces that feel natural, handle edge cases gracefully, and work beautifully on every device.
What You’ll Learn
- Chat UI patterns that feel natural and intuitive
- Message rendering and formatting (markdown, code blocks)
- Typing indicators and loading states
- Message history management and persistence
- Mobile responsiveness and touch interactions
- Accessibility (a11y) for screen readers
- Performance optimizations for long conversations
- Common UX mistakes I’ve made (and how to avoid them)
Introduction: Why Chat Interfaces Matter
When ChatGPT launched, it wasn’t just the AI that impressed people—it was the interface. The chat felt natural, responsive, and polished. That’s not an accident. Great chat interfaces require careful attention to detail: message rendering, typing indicators, scrolling behavior, mobile interactions, and accessibility.
I’ve seen AI applications with powerful backends fail because the chat interface felt clunky. And I’ve seen simple AI applications succeed because the interface felt magical. The interface is the product.
In this guide, I’ll show you how to build chat interfaces that make AI feel intelligent, not just functional.

1. Core Chat Components
1.1 The Message Bubble
The message bubble is the foundation of any chat interface. Here’s what I’ve learned about making them feel right:
import React from 'react';
import { format } from 'date-fns';
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
isStreaming?: boolean;
}
interface MessageBubbleProps {
message: Message;
}
export function MessageBubble({ message }: MessageBubbleProps) {
const isUser = message.role === 'user';
return (
<div
className={`message-bubble ${isUser ? 'user' : 'assistant'}`}
role="article"
aria-label={`${isUser ? 'You' : 'Assistant'} message`}
>
<div className="message-content">
{message.content}
{message.isStreaming && (
<span className="streaming-cursor" aria-hidden="true">▋</span>
)}
</div>
<div className="message-timestamp" aria-label={`Sent at ${format(message.timestamp, 'h:mm a')}`}>
{format(message.timestamp, 'h:mm a')}
</div>
</div>
);
}
Key details:
- Different styling for user vs assistant messages
- Timestamp for context (but subtle)
- Streaming cursor indicator
- Accessibility attributes (role, aria-label)
1.2 Message List Container
The message list needs to handle scrolling, auto-scroll to bottom, and virtualization for long conversations:
import React, { useEffect, useRef, useState } from 'react';
interface MessageListProps {
messages: Message[];
isStreaming: boolean;
}
export function MessageList({ messages, isStreaming }: MessageListProps) {
const messagesEndRef = useRef<HTMLDivElement>(null);
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
const containerRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
if (shouldAutoScroll && messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [messages, isStreaming, shouldAutoScroll]);
// Detect if user has scrolled up (disable auto-scroll)
const handleScroll = () => {
if (!containerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
const isNearBottom = scrollHeight - scrollTop - clientHeight < 100;
setShouldAutoScroll(isNearBottom);
};
return (
<div
ref={containerRef}
className="message-list"
onScroll={handleScroll}
role="log"
aria-live="polite"
aria-label="Chat messages"
>
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{isStreaming && <TypingIndicator />}
<div ref={messagesEndRef} aria-hidden="true" />
</div>
);
}
Critical UX detail: Only auto-scroll if the user is near the bottom. If they’ve scrolled up to read history, don’t interrupt them.

2. Message Formatting and Rendering
2.1 Markdown Support
AI responses often include markdown. Here’s how I handle it:
import ReactMarkdown from 'react-markdown'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; interface FormattedMessageProps { content: string; } export function FormattedMessage({ content }: FormattedMessageProps) { return ( <ReactMarkdown components={{ code({ node, inline, className, children, ...props }) { const match = /language-(\w+)/.exec(className || ''); const language = match ? match[1] : ''; return !inline && match ? ( <SyntaxHighlighter style={vscDarkPlus} language={language} PreTag="div" {...props} > {String(children).replace(/\n$/, '')} </SyntaxHighlighter> ) : ( <code className={className} {...props}> {children}); }, p: ({ children }) =>{children}
, ul: ({ children }) =>
- {children}
- {children}
2.2 Code Block Copying
Users always want to copy code blocks. Here’s a pattern I use:
function CodeBlock({ code, language }: { code: string; language: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="code-block-container">
<div className="code-block-header">
<span className="language-label">{language}</span>
<button
onClick={handleCopy}
className="copy-button"
aria-label="Copy code"
>
{copied ? '✓ Copied' : 'Copy'}
</button>
</div>
<SyntaxHighlighter language={language} style={vscDarkPlus}>
{code}
</SyntaxHighlighter>
</div>
);
}
3. Typing Indicators and Loading States
3.1 The Typing Indicator
A good typing indicator makes the interface feel alive. Here’s my implementation:
export function TypingIndicator() {
return (
<div className="typing-indicator" role="status" aria-label="Assistant is typing">
<div className="typing-dots">
<span></span>
<span></span>
<span></span>
</div>
<span className="sr-only">Assistant is typing...</span>
</div>
);
}
And the CSS for smooth animation:
.typing-indicator {
display: flex;
align-items: center;
padding: 12px 16px;
gap: 8px;
}
.typing-dots {
display: flex;
gap: 4px;
}
.typing-dots span {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #6b7280;
animation: typing 1.4s infinite;
}
.typing-dots span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-dots span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%, 60%, 100% {
transform: translateY(0);
opacity: 0.7;
}
30% {
transform: translateY(-10px);
opacity: 1;
}
}
3.2 Skeleton Loading
For initial loads, skeleton screens work better than spinners:
function MessageSkeleton() {
return (
<div className="message-skeleton assistant">
<div className="skeleton-line" style={{ width: '80%' }} />
<div className="skeleton-line" style={{ width: '60%' }} />
<div className="skeleton-line" style={{ width: '70%' }} />
</div>
);
}

4. Input and Interaction Patterns
4.1 The Chat Input
The input needs to handle multi-line text, keyboard shortcuts, and submission:
interface ChatInputProps {
onSend: (message: string) => void;
disabled?: boolean;
placeholder?: string;
}
export function ChatInput({ onSend, disabled, placeholder }: ChatInputProps) {
const [input, setInput] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || disabled) return;
onSend(input.trim());
setInput('');
// Reset textarea height
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Submit on Enter (but allow Shift+Enter for new line)
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value);
// Auto-resize textarea
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
}
};
return (
<form onSubmit={handleSubmit} className="chat-input-form">
<textarea
ref={textareaRef}
value={input}
onChange={handleInput}
onKeyDown={handleKeyDown}
placeholder={placeholder || "Type your message..."}
disabled={disabled}
rows={1}
className="chat-input"
aria-label="Message input"
/>
<button
type="submit"
disabled={disabled || !input.trim()}
className="send-button"
aria-label="Send message"
>
<SendIcon />
</button>
</form>
);
}
4.2 Keyboard Shortcuts
Power users love keyboard shortcuts:
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Cmd/Ctrl + K to focus input
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
textareaRef.current?.focus();
}
// Escape to clear input
if (e.key === 'Escape' && document.activeElement === textareaRef.current) {
setInput('');
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
5. Mobile Responsiveness
5.1 Touch Interactions
Mobile chat interfaces need special consideration:
function MobileOptimizedChat() {
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false);
useEffect(() => {
// Detect virtual keyboard on mobile
const handleResize = () => {
const viewportHeight = window.visualViewport?.height || window.innerHeight;
setIsKeyboardOpen(viewportHeight < window.innerHeight * 0.75);
};
window.visualViewport?.addEventListener('resize', handleResize);
return () => window.visualViewport?.removeEventListener('resize', handleResize);
}, []);
return (
<div className={`chat-container ${isKeyboardOpen ? 'keyboard-open' : ''}`}>
{/* Chat content */}
</div>
);
}
5.2 Swipe Gestures
For mobile, consider swipe-to-delete or swipe-to-copy:
import { useSwipeable } from 'react-swipeable';
function SwipeableMessage({ message, onDelete }: { message: Message; onDelete: () => void }) {
const handlers = useSwipeable({
onSwipedLeft: () => {
// Show delete option
},
onSwipedRight: () => {
// Show copy option
},
trackMouse: true,
});
return (
<div {...handlers} className="swipeable-message">
<MessageBubble message={message} />
</div>
);
}
6. Accessibility (a11y)
6.1 Screen Reader Support
Chat interfaces must work with screen readers:
function AccessibleChat() {
const [announcements, setAnnouncements] = useState<string[]>([]);
const announce = (message: string) => {
setAnnouncements(prev => [...prev, message]);
// Clear after announcement
setTimeout(() => {
setAnnouncements(prev => prev.slice(1));
}, 1000);
};
return (
<>
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcements[0]}
</div>
<MessageList
messages={messages}
onNewMessage={(msg) => announce(`${msg.role} sent a message`)}
/>
</>
);
}
6.2 Focus Management
Proper focus management is critical:
function ChatWithFocusManagement() {
const inputRef = useRef<HTMLTextAreaElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const handleSend = (message: string) => {
// Send message...
// Return focus to input
setTimeout(() => {
inputRef.current?.focus();
}, 100);
};
// Focus input on mount
useEffect(() => {
inputRef.current?.focus();
}, []);
return (
<>
<MessageList ref={messagesEndRef} />
<ChatInput ref={inputRef} onSend={handleSend} />
</>
);
}
7. Performance Optimizations
7.1 Virtualization for Long Conversations
Long conversations can hurt performance. Virtualize them:
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualizedMessageList({ messages }: { messages: Message[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100,
overscan: 5,
});
return (
<div ref={parentRef} className="virtualized-list">
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<MessageBubble message={messages[virtualItem.index]} />
</div>
))}
</div>
</div>
);
}
7.2 Memoization
Memoize message bubbles to prevent unnecessary re-renders:
const MessageBubble = React.memo(function MessageBubble({ message }: MessageBubbleProps) {
// Component implementation
}, (prev, next) => {
// Only re-render if content or streaming state changes
return prev.message.content === next.message.content &&
prev.message.isStreaming === next.message.isStreaming;
});
8. Best Practices: Lessons from Production
After building chat interfaces for multiple production applications, here are the practices I follow:
- Always show typing indicators: Users need feedback that the AI is working
- Handle markdown gracefully: Code blocks, lists, and formatting matter
- Auto-scroll intelligently: Only when user is near bottom
- Support keyboard shortcuts: Power users appreciate efficiency
- Make code copyable: One-click copy for code blocks is essential
- Optimize for mobile: Most users are on mobile devices
- Ensure accessibility: Screen readers must work properly
- Virtualize long lists: Performance matters for long conversations
- Handle edge cases: Empty states, errors, network failures
- Test on real devices: Simulators don’t catch all mobile issues
9. Common Mistakes to Avoid
I’ve made these mistakes so you don’t have to:
- Auto-scrolling always: Don’t interrupt users reading history
- Ignoring mobile: Mobile is where most users are
- Poor markdown rendering: Code blocks without syntax highlighting look unprofessional
- No loading states: Users need to know something is happening
- Forgetting accessibility: Screen readers need proper ARIA labels
- Not virtualizing: Long conversations will lag without virtualization
- Ignoring keyboard navigation: Keyboard users need shortcuts
- Poor error handling: Network failures need clear messages
10. Complete Example: Production Chat Interface
Here’s a complete, production-ready chat interface that combines all these patterns:
import React, { useState, useRef, useEffect } from 'react';
import { MessageBubble } from './MessageBubble';
import { ChatInput } from './ChatInput';
import { TypingIndicator } from './TypingIndicator';
import { useSSE } from './hooks/useSSE';
export function ProductionChatInterface() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const { data, isConnected, connect, disconnect } = useSSE({
url: '/api/chat/stream',
autoConnect: false,
});
const handleSend = async (message: string) => {
// Add user message
setMessages(prev => [...prev, {
id: Date.now().toString(),
role: 'user',
content: message,
timestamp: new Date(),
}]);
// Start streaming
setIsStreaming(true);
connect();
// In real implementation, trigger the stream
};
// Update streaming message
useEffect(() => {
if (data && messages.length > 0) {
const lastMessage = messages[messages.length - 1];
if (lastMessage.role === 'assistant' && lastMessage.isStreaming) {
setMessages(prev => {
const updated = [...prev];
updated[updated.length - 1] = {
...updated[updated.length - 1],
content: data,
};
return updated;
});
} else {
setMessages(prev => [...prev, {
id: Date.now().toString(),
role: 'assistant',
content: data,
timestamp: new Date(),
isStreaming: true,
}]);
}
}
}, [data]);
// Auto-scroll
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, isStreaming]);
return (
<div className="production-chat" ref={containerRef}>
<div className="messages-container" role="log" aria-live="polite">
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{isStreaming && <TypingIndicator />}
<div ref={messagesEndRef} aria-hidden="true" />
</div>
<ChatInput
onSend={handleSend}
disabled={isStreaming}
placeholder="Ask anything..."
/>
</div>
);
}
11. Conclusion
Building great chat interfaces is about attention to detail. It’s the small things—typing indicators, smooth scrolling, markdown rendering, keyboard shortcuts, accessibility—that make an interface feel polished and professional.
Focus on the user experience first. Make it feel natural, responsive, and accessible. Get the basics right, then optimize for performance. A well-designed chat interface can make even a simple AI feel magical.
🎯 Key Takeaway
Great chat interfaces are about the details: markdown rendering, typing indicators, smart auto-scrolling, keyboard shortcuts, accessibility, and mobile optimization. Get these right, and your chat interface will feel polished and professional. The interface is the product—make it shine.
Discover more from C4: Container, Code, Cloud & Context
Subscribe to get the latest posts sent to your email.