Building Chat UIs That Don't Annoy Users
Auto-scroll discipline, typing indicators, optimistic sends, Stop buttons. The patterns every chat UI lives or dies on.
Chat UI looks like a solved problem. It isn't. The visible parts (bubbles, input, send button) take a day. The invisible parts — scroll behavior, focus discipline, optimistic state, the dance between Send and Stop — take a month and are what users actually feel. Get them right and your product feels native. Get them wrong and people quietly switch to a competitor without filing a bug report.
Let me show you the patterns that have emerged across ChatGPT, Claude, Perplexity, Linear, and every serious chat product. Most of these were learned the hard way; you can have them for free.
01Auto-scroll, the way users actually want it
The naive approach is "scroll to bottom whenever a new message arrives." It works for the first thirty seconds and then becomes the most-hated feature in your product. The user scrolls up to copy a code snippet, a token arrives, you yank them back to the bottom. They swear at you and leave.
The pattern that works is what I call pinned-to-bottom. Track whether the user is currently within ~60 pixels of the bottom. If they are, they're "pinned" — auto-scroll on new content. If they scrolled up, leave them alone. Show a small "Jump to latest" pill so they can opt back in.
const onScroll = () => {
const el = scrollRef.current;
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
setPinned(distance < 60);
};
useEffect(() => {
if (pinned) {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
}
}, [messages, pinned]);
Why 60 pixels? Because iOS momentum scrolling continues for ~200ms after the user lifts their finger, and subpixel rounding on retina displays means "exactly at the bottom" is rarely literally equal. Pick a buffer larger than your line-height and stop overthinking it.
02The typing indicator and what it's really for
The three blinking dots aren't decoration. They bridge the dead time between "user hit send" and "first token arrives." Without them, the user stares at empty space and wonders if their click registered. With them, the UI feels alive.
The trick is the indicator should appear immediately when streaming starts and disappear the moment the first token renders. The transition has to be invisible. If users can perceive the swap from dots-to-text, the illusion breaks.
For products with longer thinking time (Perplexity, Cursor agents), the next-level move is state-aware indicators — instead of generic dots, show what the model is actually doing: "Searching the web", "Reading 4 sources", "Planning the answer." Users tolerate longer waits when they can see progress.
03Send / Stop is one button, not two
While the model is responding, the Send button should turn into a Stop button. Same slot, different state. Don't add a separate Stop button next to Send — it doubles the visual surface and forces the user to find the right one under time pressure.
{streaming
? <button onClick={stop} className="bg-red-500">Stop</button>
: <button onClick={send} disabled={!input.trim()}>Send</button>}
This is a small thing that takes ten minutes and immediately makes your UI feel professional. ChatGPT, Claude, Perplexity all do this. The reason it works is that "send" and "stop" are mutually exclusive — you're never in both states at once — so they should occupy the same physical control.
04Optimistic UI: render before the server replies
When the user hits send, append their message to the conversation in the same frame as the click. Don't wait for the API. Add a placeholder for the bot response right after — empty for now, with the typing indicator. The user sees activity instantly.
If the request fails, you have two choices: roll back the user's message (annoying — they have to retype) or keep it visible and add a retry affordance under it. The retry pattern is almost always better. Users hate losing their typed work.
const send = async () => {
const userMsg = { id: nextId(), role: 'user', text: input };
const botMsg = { id: nextId(), role: 'bot', text: '' };
setMessages((m) => [...m, userMsg, botMsg]); // optimistic
setInput('');
try {
await streamReply(botMsg.id);
} catch (err) {
setMessages((m) =>
m.map((x) => (x.id === botMsg.id ? { ...x, error: true } : x))
);
}
};05Disable input while streaming (or queue)
Fast users will hit Enter again while a response is streaming. If your input isn't disabled, you'll fire a second fetch and end up with two streams writing into the same conversation. Easy choice: disable the textarea while streaming === true.
The fancier alternative is to queue the next message and send it after the current stream completes. That's nicer for power users but adds complexity. For most products, disable-while-streaming is the right default.
If you bring up this race condition unprompted in a chat-UI interview, you've signaled production experience. Most candidates ship the happy path and only think about double-sends after the interviewer asks "what if the user is impatient?"
Key Takeaways
- 01Pinned-to-bottom auto-scroll respects the user. Strict scroll-to-bottom is the most hated chat antipattern.
- 02Use a buffer (60px) for "near bottom" detection — momentum scrolling and subpixel math break strict equality.
- 03Send and Stop share one button slot. Same control, different state.
- 04Render optimistically: user message + placeholder bot bubble in the same frame as the click.
- 05Disable input while streaming, or queue the next message — never let two streams write to the same conversation.
- 06State-aware typing indicators ("Searching the web") buy you patience for longer waits.