Chat UI cheatsheet.

Vercel AI SDK (React + Next.js)

npm i ai @ai-sdk/openai @ai-sdk/anthropic
"use client";
import { useChat } from "ai/react";

export default function Chat() {
  const { messages, input, handleInputChange, handleSubmit, isLoading, stop } = useChat();
  
  return (
    <div className="flex flex-col h-screen">
      <div className="flex-1 overflow-auto p-4">
        {messages.map(m => (
          <Message key={m.id} role={m.role} content={m.content} />
        ))}
      </div>
      <form onSubmit={handleSubmit} className="p-4 border-t">
        <input
          value={input}
          onChange={handleInputChange}
          disabled={isLoading}
          className="w-full"
        />
        {isLoading && <button onClick={stop}>Stop</button>}
      </form>
    </div>
  );
}

Backend:

import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";

export async function POST(req: Request) {
  const { messages } = await req.json();
  const result = streamText({ model: openai("gpt-5"), messages });
  return result.toDataStreamResponse();
}

Markdown rendering

npm i react-markdown remark-gfm rehype-highlight
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";

<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
  {content}
</ReactMarkdown>

Add CSS for highlight (highlight.js theme).

Code block with copy

function CodeBlock({ children, className }) {
  const lang = className?.replace("language-", "");
  return (
    <div className="relative my-4">
      <span className="absolute top-2 right-12 text-xs">{lang}</span>
      <button
        onClick={() => navigator.clipboard.writeText(children)}
        className="absolute top-2 right-2"
      >
        Copy
      </button>
      <pre className={className}><code>{children}</code></pre>
    </div>
  );
}

Auto-scroll

const ref = useRef<HTMLDivElement>(null);

useEffect(() => {
  ref.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);

Tool call UI

{message.toolInvocations?.map(t => (
  <div className="bg-gray-100 p-2 rounded my-2">
    <div className="text-sm">Tool: {t.toolName}</div>
    <pre>{JSON.stringify(t.args, null, 2)}</pre>
    {t.result && <pre>{JSON.stringify(t.result, null, 2)}</pre>}
  </div>
))}

Loading / thinking

{isLoading && (
  <div className="flex items-center gap-2 p-3">
    <Spinner /> Thinking...
  </div>
)}

For reasoning models, show “thinking” longer.

Streaming “typing” effect

Token-by-token is fast; sometimes too fast. Slow visually:

const [visible, setVisible] = useState("");
useEffect(() => {
  let i = 0;
  const id = setInterval(() => {
    setVisible(message.content.slice(0, i++));
    if (i > message.content.length) clearInterval(id);
  }, 10);
  return () => clearInterval(id);
}, [message.content]);

Typically unnecessary; just render as it streams.

Stop / regenerate

<button onClick={() => stop()}>Stop</button>
<button onClick={() => reload()}>Regenerate</button>

reload re-runs from last user message.

Edit message

const [editing, setEditing] = useState<string | null>(null);

function saveEdit(id, content) {
  setMessages(messages.map(m => m.id === id ? { ...m, content } : m));
  setEditing(null);
  // Optionally regenerate from edit
}

Persistence

Save to localStorage or backend:

useEffect(() => {
  localStorage.setItem("chat", JSON.stringify(messages));
}, [messages]);

const [stored] = useState(() => JSON.parse(localStorage.getItem("chat") || "[]"));

For multi-device: backend DB with user auth.

Message bubbles

function Message({ role, content }) {
  return (
    <div className={`flex ${role === "user" ? "justify-end" : "justify-start"} mb-4`}>
      <div className={`max-w-3xl px-4 py-2 rounded-lg ${
        role === "user" ? "bg-blue-500 text-white" : "bg-gray-100"
      }`}>
        <ReactMarkdown>{content}</ReactMarkdown>
      </div>
    </div>
  );
}

Input enhancements

  • Multi-line via Shift+Enter.
  • Submit on Enter.
  • Drag-drop files for image inputs.
  • Voice input (Web Speech API + Whisper).
function handleKeyDown(e) {
  if (e.key === "Enter" && !e.shiftKey) {
    e.preventDefault();
    handleSubmit(e);
  }
}

File attachments

// Vercel AI SDK
const { messages, handleSubmit } = useChat({
  experimental_attachments: true,
});

// Pass files in submit
handleSubmit(e, { experimental_attachments: files });

Suggestions / starters

{messages.length === 0 && (
  <div className="grid grid-cols-2 gap-2">
    {["Write a poem", "Explain TLS", "Code review", "Summarize URL"].map(s => (
      <button key={s} onClick={() => append({ role: "user", content: s })}>
        {s}
      </button>
    ))}
  </div>
)}

Feedback (thumbs)

<button onClick={() => rateMessage(message.id, 1)}>👍</button>
<button onClick={() => rateMessage(message.id, -1)}>👎</button>

Accessibility

  • ARIA roles (role="log", aria-live="polite").
  • Focus management.
  • Keyboard navigation.
  • High contrast support.

Common mistakes

  • Re-rendering whole list per token → lag. Use React.memo on Message.
  • No auto-scroll → user has to scroll manually.
  • Storing massive history in localStorage (quota limit).
  • Not handling streaming errors (abort signal).
  • Mixing markdown + raw HTML insecurely.

Read this next

If you want my chat UI components, they’re at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .