chat-and-messaging

Chat is a conversation, not a document.

Chat and Messaging Interfaces

Chat is a conversation, not a document. Design for flow and readability.

Message Structure

interface Message {
  role: 'user' | 'assistant' | 'system';
  content: string;
  timestamp: Date;
  status?: 'sending' | 'sent' | 'error';
  attachments?: Attachment[];
}

Visual Design Guidelines

Layout:

.chat-container {
  max-width: 800px;
  margin: 0 auto;
  padding: var(--space-4);
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}

.message {
  display: flex;
  gap: var(--space-3);
  padding: var(--space-4);
  border-radius: 12px;
  max-width: 75%;
}

.message-user {
  align-self: flex-end;
  background: var(--color-primary);
  color: var(--color-primary-foreground);
}

.message-assistant {
  align-self: flex-start;
  background: var(--color-muted);
  color: var(--color-foreground);
}

.message-timestamp {
  font-size: 11px;
  color: var(--color-muted-foreground);
  margin-top: var(--space-1);
}

Message States

/* Sending state - subtle opacity */
.message-sending {
  opacity: 0.6;
  transition: opacity 0.2s;
}

/* Error state - clear but not alarming */
.message-error {
  border-left: 3px solid var(--color-error);
  background: color-mix(in srgb, var(--color-error) 5%, transparent);
}

/* Streaming state - subtle pulse */
@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.5; }
}

.message-streaming::after {
  content: '▊';
  animation: pulse 1s ease-in-out infinite;
  font-family: 'Zen Mono', monospace;
}

Input Area Design

Features:

const ChatInput = () => {
  const [message, setMessage] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  const adjustHeight = () => {
    const textarea = textareaRef.current;
    if (textarea) {
      textarea.style.height = 'auto';
      textarea.style.height = `${Math.min(textarea.scrollHeight, 8 * 24)}px`; // max 8 lines
    }
  };

  return (
    <div className="chat-input">
      <textarea
        ref={textareaRef}
        value={message}
        onChange={(e) => {
          setMessage(e.target.value);
          adjustHeight();
        }}
        onKeyDown={(e) => {
          if (e.key === 'Enter' && !e.shiftKey) {
            e.preventDefault();
            handleSend();
          }
        }}
        placeholder="Type a message..."
        disabled={isSubmitting}
      />
      <button onClick={handleSend} disabled={isSubmitting || !message.trim()}>
        {isSubmitting ? <Spinner /> : <SendIcon />}
      </button>
    </div>
  );
};

Scroll Behavior

CRITICAL: Respect user scroll position while auto-scrolling for new messages.

function ChatMessages({ messages }: { messages: Message[] }) {
  const messagesEndRef = useRef<HTMLDivElement>(null);
  const scrollContainerRef = useRef<HTMLDivElement>(null);
  const [isUserScrolling, setIsUserScrolling] = useState(false);

  const scrollToBottom = () => {
    if (!isUserScrolling) {
      messagesEndRef.current?.scrollIntoView({ 
        behavior: 'smooth',
        block: 'end'
      });
    }
  };

  useEffect(() => {
    const container = scrollContainerRef.current;
    if (!container) return;

    const handleScroll = () => {
      const { scrollHeight, scrollTop, clientHeight } = container;
      // User is near bottom if within 100px
      const isNearBottom = scrollHeight - scrollTop - clientHeight < 100;
      setIsUserScrolling(!isNearBottom);
    };

    container.addEventListener('scroll', handleScroll);
    return () => container.removeEventListener('scroll', handleScroll);
  }, []);

  useEffect(() => {
    scrollToBottom();
  }, [messages]);

  return (
    <div ref={scrollContainerRef} className="messages-container">
      {messages.map((msg) => (
        <Message key={msg.id} message={msg} />
      ))}
      <div ref={messagesEndRef} />
    </div>
  );
}

Markdown Rendering

Libraries:

import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeSanitize from 'rehype-sanitize';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';

function MessageContent({ content }: { content: string }) {
  return (
    <ReactMarkdown
      remarkPlugins={[remarkGfm]}
      rehypePlugins={[rehypeSanitize]}
      components={{
        code({ node, inline, className, children, ...props }) {
          const match = /language-(\w+)/.exec(className || '');
          return !inline && match ? (
            <SyntaxHighlighter
              style={oneDark}
              language={match[1]}
              PreTag="div"
              {...props}
            >
              {String(children).replace(/\n$/, '')}
            </SyntaxHighlighter>
          ) : (
            <code className={className} {...props}>
              {children}
            </code>
          );
        }
      }}
    >
      {content}
    </ReactMarkdown>
  );
}

Streaming Messages

For real-time streaming (like ChatGPT):

function StreamingMessage({ stream }: { stream: ReadableStream }) {
  const [content, setContent] = useState('');
  const [isComplete, setIsComplete] = useState(false);

  useEffect(() => {
    const reader = stream.getReader();
    const decoder = new TextDecoder();

    async function read() {
      try {
        while (true) {
          const { done, value } = await reader.read();
          if (done) {
            setIsComplete(true);
            break;
          }
          const text = decoder.decode(value, { stream: true });
          setContent(prev => prev + text);
        }
      } catch (error) {
        console.error('Stream error:', error);
      }
    }

    read();
  }, [stream]);

  return (
    <div className="streaming-message">
      <MessageContent content={content} />
      {!isComplete && <span className="cursor-blink">▊</span>}
    </div>
  );
}

Best Practices

Do:

Don't: