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 .