Chat Message UI
Machine-coding brief for a chat message list — bubbles, grouping, scroll anchoring, composer, and a11y.
- machine-coding
- interview
- react
- realtime
Problem statement
Build a chat message UI: scrollable message list + composer. Own vs other bubbles, timestamps, send on Enter, and correct scroll behavior when new messages arrive. Interviewers score list state, scroll anchoring, and empty/loading states — not WebSocket infrastructure (unless they expand scope).
Requirements
Must have
- Message list for one conversation (mock data or prop)
- Distinguish mine vs theirs (alignment + style)
- Composer: text input + Send; Enter sends, Shift+Enter newline (clarify)
- Auto-scroll to bottom on send / when already near bottom on incoming
- Empty conversation state
- Accessible names on controls; messages in a live-friendly structure
Should have
- Group consecutive messages from same author (hide repeated avatars)
- Relative timestamps (“2m ago”) with full time on hover/title
- Optimistic send with pending/failed status
- “New messages” jump button when user scrolled up
Nice to have
- Image attachments / emoji
- Virtualize 10k messages
- Read receipts
- Day separators (“Today”, “Yesterday”)
Planning (5 minutes out loud)
- Message model — id, authorId, body, createdAt, status
- Scroll policy — stick to bottom unless user scrolled away
- Composer ownership — controlled input in parent or child
- MVP — list + send + mine/theirs; then stickiness + grouping
- Don’t build sockets unless asked —
onSendcallback is enough
Architecture
ChatWindow
├── MessageList
│ ├── DaySeparator?
│ └── MessageBubble
├── JumpToLatestButton
└── Composer
Data model
type MessageStatus = "sending" | "sent" | "failed";
type Message = {
id: string;
authorId: string;
body: string;
createdAt: number;
status?: MessageStatus;
};
type ChatProps = {
messages: Message[];
currentUserId: string;
onSend: (body: string) => void | Promise<void>;
isLoadingHistory?: boolean;
};
Implementation sketch
Near-bottom detection
function useStickToBottom(ref: RefObject<HTMLElement | null>, deps: unknown[]) {
const stick = useRef(true);
useEffect(() => {
const el = ref.current;
if (!el) return;
function onScroll() {
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
stick.current = distance < 80;
}
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, [ref]);
useLayoutEffect(() => {
const el = ref.current;
if (el && stick.current) {
el.scrollTop = el.scrollHeight;
}
}, deps);
}
Composer
function Composer({ onSend }: { onSend: (body: string) => void }) {
const [text, setText] = useState("");
function submit() {
const body = text.trim();
if (!body) return;
onSend(body);
setText("");
}
return (
<form
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
<label className="sr-only" htmlFor="chat-input">
Message
</label>
<textarea
id="chat-input"
rows={1}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
/>
<button type="submit" disabled={!text.trim()}>
Send
</button>
</form>
);
}
Bubble + grouping
function MessageBubble({
message,
mine,
showMeta,
}: {
message: Message;
mine: boolean;
showMeta: boolean;
}) {
return (
<div
className={mine ? "msg mine" : "msg theirs"}
data-status={message.status}
>
{showMeta && !mine && <div className="author">{message.authorId}</div>}
<p>{message.body}</p>
{showMeta && (
<time dateTime={new Date(message.createdAt).toISOString()}>
{formatTime(message.createdAt)}
</time>
)}
{message.status === "failed" && (
<button type="button">Retry</button>
)}
</div>
);
}
Group when prev.authorId === curr.authorId and time gap < 5 minutes.
Accessibility essentials
- Message list:
role="log"+aria-live="polite"or announce only the jump control — fullaria-liveon every message can spam screen readers. Prefer polite live region for “N new messages” and semantic list structure for history. - Composer textarea labeled; Send disabled when empty
- Failed sends exposed as text, not color alone
- Don’t trap focus inside chat unless it’s a modal overlay
Performance notes
- For interviews, 200 messages as a flat map is fine
- If they ask scale: virtualize with fixed or measured heights; keep stick-to-bottom logic using virtualizer scroll API
- Avoid re-rendering entire list on every keystroke — isolate composer state
- Images: fixed aspect placeholders to prevent scroll jumps
Footguns
- Always force scroll to bottom — user reading history gets yanked
- Lost scroll position when prepending history (load older)
- Enter vs Shift+Enter inconsistency with product expectations
- Duplicate keys when optimistic id replaced by server id
- Timezone on timestamps without locale
Loading older messages
When prepending:
const el = listRef.current;
const prevHeight = el.scrollHeight;
// setMessages(older => [...older, ...prev])
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight - prevHeight + el.scrollTop;
});
Interview out-loud answer
Chat is a virtual list plus composer. I’d model messages with client ids and status for optimistic UI. Scroll sticks to bottom only if the user was already near bottom; otherwise show a jump control. Composer owns draft text so typing doesn’t re-render every bubble. MVP is bubbles + send + stickiness; grouping and retries next. Realtime transport is a thin
messagesprop unless they ask for sockets.
Related on this site
- Nested Comments Thread
- Toast Notification System
- Realtime WebSocket UI Design
- Machine Coding Interview Framework