Building Chat Interfaces for AI: Design Patterns and Best Practices

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.

Chat Interface Architecture and Components
Figure 1: Chat Interface Architecture and Components

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.

Message Rendering Patterns and Formatting
Figure 2: Message Rendering Patterns and Formatting

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}
, ol: ({ children }) =>
    {children}
, }} > {content} ); }

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>
  );
}
UX Patterns and User Interactions
Figure 3: UX Patterns and User Interactions

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:

  1. Always show typing indicators: Users need feedback that the AI is working
  2. Handle markdown gracefully: Code blocks, lists, and formatting matter
  3. Auto-scroll intelligently: Only when user is near bottom
  4. Support keyboard shortcuts: Power users appreciate efficiency
  5. Make code copyable: One-click copy for code blocks is essential
  6. Optimize for mobile: Most users are on mobile devices
  7. Ensure accessibility: Screen readers must work properly
  8. Virtualize long lists: Performance matters for long conversations
  9. Handle edge cases: Empty states, errors, network failures
  10. 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.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.