Building a production chat interface with Vercel AI SDK’s useChat hook means solving real styling problems: streaming text that doesn’t jump, code blocks that render correctly, message bubbles that handle long content, and a composer that stays put during generation. This usechat chat ui styling tutorial walks through each piece with code you can drop into a Next.js app router project.
Step 1: Set up the base component structure
Start with a minimal page that wires useChat to an API route. The hook returns messages, input, handleInputChange, handleSubmit, isLoading, and status — everything you need for a complete loop.
// app/chat/page.tsx
"use client";
import { useChat } from "ai/react";
import { Message } from "ai";
import styles from "./page.module.css";
export default function ChatPage() {
const { messages, input, handleInputChange, handleSubmit, isLoading, status } = useChat({
api: "/api/chat",
});
return (
<main className={styles.container}>
<div className={styles.messages} role="log" aria-live="polite">
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{status === "streaming" && <StreamingIndicator />}
</div>
<form onSubmit={handleSubmit} className={styles.composer}>
<textarea
value={input}
onChange={handleInputChange}
placeholder="Type a message…"
disabled={isLoading}
className={styles.input}
rows={1}
/>
<button type="submit" disabled={isLoading || !input.trim()} className={styles.send}>
Send
</button>
</form>
</main>
);
}
Create the API route that streams from your model provider. The SDK handles the protocol; you only need to return a StreamingTextResponse.
// app/api/chat/route.ts
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = await streamText({
model: openai("gpt-4o-mini"),
messages,
});
return result.toDataStreamResponse();
}
Verify: Run npm run dev, open /chat, send a message. You should see the assistant reply stream in real time with no styling yet — plain text in a vertical stack.
Step 2: Build the message bubble component
The bubble is the core visual unit. It needs to handle user vs assistant alignment, markdown rendering, code blocks, and long-word wrapping without horizontal overflow.
// app/chat/MessageBubble.tsx
"use client";
import { Message } from "ai";
import { Markdown } from "react-markdown";
import rehypeHighlight from "rehype-highlight";
import remarkGfm from "remark-gfm";
import styles from "./MessageBubble.module.css";
interface MessageBubbleProps {
message: Message;
}
export function MessageBubble({ message }: MessageBubbleProps) {
const isUser = message.role === "user";
return (
<div className={`${styles.wrapper} ${isUser ? styles.user : styles.assistant}`}>
<div className={styles.bubble}>
{isUser ? (
<p className={styles.text}>{message.content}</p>
) : (
<Markdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[[rehypeHighlight, { ignoreMissing: true }]]}
components={markdownComponents}
className={styles.markdown}
>
{message.content}
</Markdown>
)}
</div>
<time className={styles.timestamp} dateTime={message.createdAt?.toISOString()}>
{message.createdAt?.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</time>
</div>
);
}
const markdownComponents = {
code: ({ node, children, ...props }: any) => {
const language = node.properties?.data?.language || "";
return (
<pre className={styles.codeBlock} {...props}>
<code className={`language-${language}`}>{String(children).trim()}</code>
</pre>
);
},
a: ({ href, children, ...props }: any) => (
<a href={href} target="_blank" rel="noopener noreferrer" className={styles.link} {...props}>
{children}
</a>
),
};
CSS modules keep styles scoped and avoid collisions. The key tricks: max-width on the bubble, word-break: break-word for long tokens, and a constrained code block that scrolls horizontally.
/* app/chat/MessageBubble.module.css */
.wrapper {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 85%;
animation: fadeIn 120ms ease-out;
}
.user {
align-self: flex-end;
align-items: flex-end;
}
.assistant {
align-self: flex-start;
align-items: flex-start;
}
.bubble {
padding: 12px 16px;
border-radius: 18px;
line-height: 1.5;
font-size: 14px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.user .bubble {
background: #0066cc;
color: white;
border-bottom-right-radius: 4px;
}
.assistant .bubble {
background: #f1f3f5;
color: #1a1a2e;
border-bottom-left-radius: 4px;
}
.text {
white-space: pre-wrap;
word-break: break-word;
margin: 0;
}
.markdown {
margin: 0;
word-break: break-word;
}
.markdown p {
margin: 8px 0;
}
.markdown p:first-child {
margin-top: 0;
}
.markdown p:last-child {
margin-bottom: 0;
}
.codeBlock {
margin: 12px -16px;
padding: 12px 16px;
background: #1e1e1e;
border-radius: 0;
overflow-x: auto;
font-size: 13px;
line-height: 1.6;
}
.user .codeBlock {
background: #004499;
margin: 12px -16px;
}
.timestamp {
font-size: 11px;
color: #868e96;
padding: 0 4px;
}
.link {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
Verify: Send a message with markdown — headers, lists, a fenced code block, a link. The assistant bubble should render all of it with syntax highlighting. User messages stay plain text.
Step 3: Handle streaming text without layout shift
Streaming tokens arrive character by character. If you render directly into the bubble, the height changes on every chunk, causing the scroll position to jump. The fix: render into a fixed-height container or use a virtualized list. For most apps, a simpler approach works — append to a ref-backed string and only re-render the latest message.
// app/chat/StreamingMessage.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { Message } from "ai";
import { Markdown } from "react-markdown";
import rehypeHighlight from "rehype-highlight";
import remarkGfm from "remark-gfm";
import styles from "./StreamingMessage.module.css";
interface StreamingMessageProps {
message: Message;
isStreaming: boolean;
}
export function StreamingMessage({ message, isStreaming }: StreamingMessageProps) {
const [displayContent, setDisplayContent] = useState(message.content);
const contentRef = useRef(message.content);
const rafRef = useRef<number>();
// Batch updates to avoid thrashing on fast streams
useEffect(() => {
contentRef.current = message.content;
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
setDisplayContent(contentRef.current);
});
return () => rafRef.current && cancelAnimationFrame(rafRef.current);
}, [message.content]);
useEffect(() => () => rafRef.current && cancelAnimationFrame(rafRef.current), []);
return (
<div className={styles.wrapper}>
<div className={styles.bubble}>
<Markdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[[rehypeHighlight, { ignoreMissing: true }]]}
components={markdownComponents}
className={styles.markdown}
>
{displayContent}
</Markdown>
{isStreaming && <Cursor className={styles.cursor} />}
</div>
</div>
);
}
function Cursor({ className }: { className: string }) {
return <span className={className} aria-hidden="true">▌</span>;
}
const markdownComponents = {
code: ({ node, children, ...props }: any) => {
const language = node.properties?.data?.language || "";
return (
<pre className={styles.codeBlock} {...props}>
<code className={`language-${language}`}>{String(children).trim()}</code>
</pre>
);
},
};
/* app/chat/StreamingMessage.module.css */
.wrapper {
max-width: 85%;
align-self: flex-start;
animation: fadeIn 120ms ease-out;
}
.bubble {
background: #f1f3f5;
color: #1a1a2e;
padding: 12px 16px;
border-radius: 18px;
border-bottom-left-radius: 4px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
line-height: 1.5;
font-size: 14px;
}
.markdown {
margin: 0;
word-break: break-word;
}
.cursor {
display: inline-block;
width: 1ch;
animation: blink 1s step-end infinite;
color: #868e96;
margin-left: 2px;
vertical-align: text-bottom;
}
@keyframes blink {
50% { opacity: 0; }
}
.codeBlock {
margin: 12px -16px;
padding: 12px 16px;
background: #1e1e1e;
border-radius: 0;
overflow-x: auto;
font-size: 13px;
line-height: 1.6;
}
Wire it into the main page by swapping the last assistant message for the streaming variant when status === "streaming".
// app/chat/page.tsx (excerpt)
import { StreamingMessage } from "./StreamingMessage";
// inside the messages map
{messages.map((message, index) => {
const isLastAssistant = index === messages.length - 1 && message.role === "assistant";
if (isLastAssistant && status === "streaming") {
return <StreamingMessage key={message.id} message={message} isStreaming />;
}
return <MessageBubble key={message.id} message={message} />;
})}
Verify: Send a long prompt. The assistant bubble should grow smoothly without the viewport jumping. The blinking cursor appears only while streaming.
Step 4: Style the composer for fixed positioning and auto-resize
The input area must stay at the bottom, expand as the user types, and never push messages out of view. Use a flex column layout on the page container and a textarea that grows via scrollHeight.
/* app/chat/page.module.css */
.container {
display: flex;
flex-direction: column;
height: 100vh;
max-width: 768px;
margin: 0 auto;
padding: 24px 16px;
box-sizing: border-box;
background: white;
}
.messages {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
padding: 8px 4px 16px;
width: 100%;
scroll-behavior: smooth;
}
.composer {
display: flex;
gap: 12px;
padding-top: 16px;
border-top: 1px solid #e9ecef;
background: white;
position: sticky;
bottom: 0;
z-index: 10;
}
.input {
flex: 1;
min-height: 44px;
max-height: 200px;
padding: 10px 14px;
border: 1px solid #dee2e6;
border-radius: 12px;
font-size: 14px;
line-height: 1.5;
resize: none;
outline: none;
font-family: inherit;
box-sizing: border-box;
transition: border-color 0.15s, box-shadow 0.15s;
}
.input:focus {
border-color: #0066cc;
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.15);
}
.input:disabled {
background: #f8f9fa;
cursor: not-allowed;
}
.send {
align-self: flex-end;
height: 44px;
padding: 0 24px;
background: #0066cc;
color: white;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.send:hover:not(:disabled) {
background: #0052a3;
}
.send:disabled {
opacity: 0.5;
cursor: not-allowed;
}
Add the auto-resize logic to the textarea.
// app/chat/AutoResizeTextarea.tsx
"use client";
import { ForwardedRef, TextareaHTMLAttributes, forwardRef, useEffect, useRef } from "react";
interface AutoResizeTextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
minRows?: number;
maxRows?: number;
}
export const AutoResizeTextarea = forwardRef<HTMLTextAreaElement, AutoResizeTextareaProps>(
({ minRows = 1, maxRows = 8, value, onChange, ...props }, ref) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const attachedRef = ref as ForwardedRef<HTMLTextAreaElement>;
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
const singleRow = el.scrollHeight / Math.max(el.value.split("\n").length, 1);
const maxHeight = singleRow * maxRows;
el.style.height = Math.min(el.scrollHeight, maxHeight) + "px";
}, [value, maxRows]);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange?.(e);
const el = e.currentTarget;
el.style.height = "auto";
const singleRow = el.scrollHeight / Math.max(el.value.split("\n").length, 1);
const maxHeight = singleRow * maxRows;
el.style.height = Math.min(el.scrollHeight, maxHeight) + "px";
};
return (
<textarea
ref={(el) => {
textareaRef.current = el;
if (typeof attachedRef === "function") attachedRef(el);
else if (attachedRef) attachedRef.current = el;
}}
onChange={handleChange}
rows={minRows}
{...props}
/>
);
}
);
AutoResizeTextarea.displayName = "AutoResizeTextarea";
Swap the plain textarea in the composer for AutoResizeTextarea.
Verify: Type multiple lines. The textarea grows until maxRows, then scrolls internally. The composer stays pinned at the bottom; the message list scrolls independently.
Step 5: Add loading and error states
Users need feedback when the model is thinking, when a request fails, and when they can retry. The status field from useChat gives you "submitted" | "streaming" | "ready" | "error". Pair it with error for the failure message.
// app/chat/StatusIndicators.tsx
"use client";
import styles from "./StatusIndicators.module.css";
export function StreamingIndicator() {
return (
<div className={styles.streaming} aria-live="polite" aria-atomic="true">
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.srOnly}>Assistant is typing</span>
</div>
);
}
export function ErrorBanner({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div className={styles.error} role="alert">
<span>{message}</span>
<button onClick={onRetry} className={styles.retry}>
Retry
</button>
</div>
);
}
/* app/chat/StatusIndicators.module.css */
.streaming {
display: flex;
gap: 4px;
padding: 8px 12px;
color: #868e96;
font-size: 13px;
align-self: flex-start;
}
.dot {
width: 6px;
height: 6px;
background: currentColor;
border-radius: 50%;
animation: bounce 1.4s ease-in-out infinite both;
}
.dot:nth-child(2) { animation-delay: 0.16s; }
.dot:nth-child(3) { animation-delay: 0.32s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; }
40% { transform: scale(1); opacity: 1; }
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
background: #fff5f5;
border: 1px solid #feb2b2;
border-radius: 10px;
color: #c53030;
font-size: 13px;
max-width: 85%;
align-self: flex-start;
}
.retry {
padding: 6px 12px;
background: #c53030;
color: white;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.retry:hover {
background: #9b2c2c;
}
Integrate into the message list.
// app/chat/page.tsx (excerpt)
import { StreamingIndicator, ErrorBanner } from "./StatusIndicators";
import { useChat } from "ai/react";
// inside the component
const { messages, input, handleInputChange, handleSubmit, isLoading, status, error, reload } = useChat({
api: "/api/chat",
});
// in the message list, after the map
{status === "streaming" && <StreamingIndicator />}
{error && <ErrorBanner message={error.message} onRetry={reload} />}
Verify: Disconnect network or hit a rate limit. The error banner appears with a working Retry button. During the first chunk, the three-dot indicator shows.
Step 6: Polish — scroll behavior, focus management, and mobile
Three details separate a demo from a shippable UI.
Auto-scroll on new content — but only if the user is already near the bottom.
// app/chat/useAutoScroll.ts
"use client";
import { useEffect, useRef } from "react";
export function useAutoScroll(dependency: unknown, enabled = true) {
const containerRef = useRef<HTMLDivElement>(null);
const isNearBottomRef = useRef(true);
useEffect(() => {
if (!enabled || !containerRef.current) return;
const el = containerRef.current;
const threshold = 100;
isNearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
if (isNearBottomRef.current) {
el.scrollTop = el.scrollHeight;
}
}, [dependency, enabled]);
const onScroll = () => {
if (!containerRef.current) return;
const el = containerRef.current;
const threshold = 100;
isNearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
};
return { containerRef, onScroll };
}
Attach it to the messages container.
// app/chat/page.tsx
import { useAutoScroll } from "./useAutoScroll";
const { containerRef, onScroll } = useAutoScroll([messages, status], status !== "streaming");
// on the messages div
<div ref={containerRef} onScroll={onScroll} className={styles.messages} role="log" aria-live="polite">
Focus management — return focus to the input after send so the user can keep typing.
// in the page component
const inputRef = useRef<HTMLTextAreaElement>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// useChat's handleSubmit is async; focus after the state updates
const originalHandleSubmit = useChat({ api: "/api/chat" }).handleSubmit;
// simpler: just focus in a microtask after submit
setTimeout(() => inputRef.current?.focus(), 0);
};
// on the textarea
ref={inputRef}
Mobile viewport — prevent the virtual keyboard from covering the composer. The sticky composer with bottom: 0 works on iOS Safari when you add env(safe-area-inset-bottom).
/* app/chat/page.module.css addition */
.composer {
padding-bottom: calc(16px + env(safe-area-inset-bottom));
}
@supports (padding-bottom: env(safe-area-inset-bottom)) {
.container {
padding-bottom: env(safe-area-inset-bottom);
}
}
Verify: On mobile, open the keyboard, type a long message, send. The composer stays above the keyboard. Scroll up in history, send a new message — the list does not auto-scroll away from your position. Scroll to bottom, send — it follows.
Step 7: Theming and design tokens
Hardcoded colors make dark mode painful. Extract a token layer using CSS custom properties. This also lets you swap themes per user preference or brand.
/* app/chat/tokens.css */
:root {
--chat-bg: #ffffff;
--chat-surface: #f8f9fa;
--chat-border: #dee2e6;
--chat-text: #1a1a2e;
--chat-text-muted: #868e96;
--chat-primary: #0066cc;
--chat-primary-hover: #0052a3;
--chat-primary-light: rgba(0, 102, 204, 0.1);
--chat-user-bg: #0066cc;
--chat-user-text: #ffffff;
--chat-assistant-bg: #f1f3f5;
--chat-assistant-text: #1a1a2e;
--chat-code-bg: #1e1e1e;
--chat-error-bg: #fff5f5;
--chat-error-border: #feb2b2;
--chat-error-text: #c53030;
--chat-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
--chat-radius: 18px;
--chat-radius-tight: 4px;
--chat-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--chat-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--chat-bg: #1a1a2e;
--chat-surface: #21213a;
--chat-border: #3d3d5c;
--chat-text: #f1f3f5;
--chat-text-muted: #adb5bd;
--chat-primary: #4d9fff;
--chat-primary-hover: #7ab3ff;
--chat-primary-light: rgba(77, 159, 255, 0.15);
--chat-user-bg: #4d9fff;
--chat-user-text: #1a1a2e;
--chat-assistant-bg: #2d2d4a;
--chat-assistant-text: #f1f3f5;
--chat-code-bg: #121212;
--chat-error-bg: #3d1a1a;
--chat-error-border: #c53030;
--chat-error-text: #fc8181;
--chat-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
}
Import tokens.css in your global stylesheet, then replace hardcoded values in the component modules with var(--chat-*).
/* Example: MessageBubble.module.css after tokenization */
.bubble {
padding: 12px 16px;
border-radius: var(--chat-radius);
line-height: 1.5;
font-size: 14px;
box-shadow: var(--chat-shadow);
font-family: var(--chat-font);
}
.user .bubble {
background: var(--chat-user-bg);
color: var(--chat-user-text);
border-bottom-right-radius: var(--chat-radius-tight);
}
.assistant .bubble {
background: var(--chat-assistant-bg);
color: var(--chat-assistant-text);
border-bottom-left-radius: var(--chat-radius-tight);
}
.codeBlock {
font-family: var(--chat-mono);
background: var(--chat-code-bg);
}
Verify: Toggle OS dark mode. The entire chat — bubbles, composer, code blocks, error states — switches without a flash.
Step 8: Accessibility checklist
A styled chat UI is useless if it fails WCAG. Run through these before shipping.
| Requirement | Implementation |
|---|---|
| Semantic structure | <main>, <form>, <ul role="list"> for messages, <li> per bubble |
| Live region | aria-live="polite" on the message container; aria-atomic="true" on streaming indicator |
| Focus order | Composer at bottom in DOM; tabIndex={0} on interactive elements only |
| Color contrast | Tokens ensure 4.5:1 for text, 3:1 for UI components in both themes |
| Keyboard | Enter sends, Shift+Enter newlines; Escape clears input; Tab moves logically |
| Screen readers | Timestamps in <time>, cursor hidden with aria-hidden, error in role="alert" |
| Reduced motion | @media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } } |
Add the reduced-motion rule to tokens.css.
Verify: Navigate with keyboard only. Turn on VoiceOver or NVDA. Send a message, wait for stream, scroll history. No traps, no unlabeled controls, no motion sickness.
Wrapping up
You now have a styled, streaming chat UI built on useChat that handles:
- Markdown and syntax-highlighted code blocks
- Smooth streaming without layout shift
- Auto-resizing composer pinned to the viewport
- Loading, error, and retry states
- Dark mode via design tokens
- Accessible markup and focus management
The components are small, composable, and framework-agnostic enough to move into a shared UI package. If you’re routing requests through a gateway that handles provider fallback and usage metering — like n4n.ai — the same frontend works unchanged while you swap models or add caching headers upstream.
Next steps to consider: message actions (copy, regenerate, branch), virtualized lists for 10k+ message threads, optimistic UI for instant user bubbles, and streaming tool-call rendering. Each builds on the same foundation you just styled.