Patterns
Chat & Messaging
A team inbox: a filtered conversation list, a grouped transcript, a composer that obeys the shortcuts people already know, and the states around them — presence, typing, delivery, attachments, and three different kinds of empty. Bubbles are the app's own markup; almost everything else on this page is a composite the package already ships.
The inbox screen
Everything here is live. Search filters the list with matchesSearch, which ignores accents — typing hung finds Hùng. The chips filter by unread and by mention, opening a conversation clears its badge, the topic under the title is an InlineEdit, and a message you send moves that conversation to the top of the list. The app pattern shows the same screen inside the real AppShell; this one stays in a box so the docs chrome keeps working.
#product-sync
Huy
Mai
Hùng
Mai
Two details worth stealing. The list timestamps pass addSuffix={false} — an inbox is entirely in the past, so “6 minutes” carries the same meaning as “6 minutes ago” in half the width; the transcript keeps the suffix because a scheduled message could sit in the future. And channels get a square avatar while people get a round one: square means a thing, round means a person, the same rule the workspace switcher follows.
Participants, and the surface they sit on
AvatarGroup overlaps its avatars on purpose, and avatar tones are alpha tints. Without an opaque layer underneath, the top avatar's tint composites over the avatar below it rather than over the page, and the faces smear together exactly where they overlap. surfaceClassName supplies that layer plus the colour of the separating ring — it defaults to bg-card ring-card, so any group that is not on a card has to restate both classes.
bg-card · default
bg-muted · restated
wrong — card surface declared on a muted panel
The ring and the underlay are still card-coloured, so each avatar carries a pale halo that does not belong to this panel.
overflow chip as a button
Click +3 to open the roster.
The group is a role="group" whose accessible name comes from label — Vietnamese by default, so an English UI passes “Participants”. Each avatar is focusable and names its person on hover and on focus, and giving the overflow chip an onOverflowClick promotes it from a decorative span to a real button — one that carries its own Vietnamese overflowLabel, so translating label alone leaves the last button answering in Vietnamese. Presence dots are hand-built: the package ships no presence primitive, and the dot always carries a text equivalent because colour alone is never the message.
The transcript
Grouping is what makes a thread readable: consecutive messages from one author inside ten minutes lose the repeated avatar and name and become a single block. Toggle it off to see what the same thread costs without it.
Same author, inside ten minutes, no day break
Huy
Mai
Hùng
Mai
Every timestamp is a RelativeTime measured from one fixed now. That is what makes this page deterministic — the same strings on the server, in your browser and in a screenshot next month — and it is the right call for any frozen data. Live data drops now and lets the component refresh itself once a minute. The locale is not optional in an English product: leave it out and every line reads “6 phút trước”. The day separators are literal strings carried on the message rather than dates formatted at render time, because formatting depends on the viewer's timezone and the server and the browser would disagree.
The composer
Four shortcuts, all of them live below: Enter sends, Shift+Enter starts a new line, Esc clears the draft, and ↑ on an empty composer hands your last message to an InlineEdit — click the text to open the field. The log underneath records what each key actually did.
↵SendShift+↵New line↑Edit lastEscClear
What the composer just did
Type in the composer and try the four shortcuts above.
KeyboardHint resolves the modifier per platform — ⌘ on macOS, Ctrl everywhere else — and renders aria-hidden, because ⌘⇧↵ read aloud is noise. The control it decorates still needs a spoken name, which is why the send button carries its own aria-label. Editing in place uses InlineEdit rather than a permanently mounted input: a transcript full of text fields invites accidental edits, and the display stays a button until someone means it. That is also its one limit — InlineEdit owns the editing flag internally, so ↑ can hand a message to one but cannot open the field for you. A Slack-style jump that lands the caret in the text needs your own Textarea instead.
Rich media messages
Bubbles that carry more than text — a link preview with its source and cover, a photo with a caption, and a voice note you can actually play. The bubble shape is the app's; what is inside comes from the package.

Offsite venue — this is the one.
Read
0:00 / 0:18
AspectRatio reserves the space before the image arrives, so a slow photo never shoves the thread you are reading. The voice note is a Progress track and a Button whose label flips between Play and Pause — a waveform drawn out of divs looks the same and says nothing to a screen reader.
Albums, files and video
Multiple images collapse into a grid with an overflow count; any of them opens a Dialog lightbox. Documents are Item rows — the same primitive the Tasks and CRM patterns use for list rows — so an attachment looks like everything else in the product.
8 photos ·
2.4 MB · PDF
Uploading · 62%
Each thumbnail is a real button with a spoken label, not a clickable div, and the dialog gets closeLabel="Close" — another plain string prop that ships Vietnamese. An upload in flight keeps the same Item row and adds a Progress track, so the row does not change shape when it finishes.
Presence, typing and delivery
The three states that tell a reader whether anyone is there: presence on the avatar, a typing indicator while someone composes, and a delivery state under your own messages. Delivery is an app-level enum, so the app declares the table and StatusPill renders it.
Nguyễn Đức Thành
Online
Phạm Minh Anh
Away
Đỗ Quang Huy
Offline
Read
Not delivered
The tones come from STATUS_TONES, which are token class pairs measured at AA on both themes; the text step is always the -ink one. Never build these class names by interpolation — Tailwind scans your source as text, so bg-${tone}/15 is purged from the bundle and the pill arrives colourless.
Empty and loading
A chat app has at least three different nothings, and telling them apart is the whole value: nothing selected, nothing in this thread yet, and nothing matching the current filter. One EmptyState with three different sets of words.
Nothing selected
Filtered to nothing
Selected, but empty
Loading the list
icon takes the component itself — icon={Inbox}, never icon={<Inbox />}; the type will not save you, because a Lucide icon is a forwardRef object and no runtime check can tell one from a rendered element. And loading is a shaped skeleton rather than a spinner, so the rows land where they will settle.
Best Practices
Do
- Group consecutive messages from one author, and keep the timestamp on each.
- Give
RelativeTimea fixednowfor fixtures and a real clock for live data. - Restate
surfaceClassNamewhenever an avatar group leaves the card surface. - Auto-scroll only when the reader is already at the bottom of the thread.
- Show delivery state on your own messages, and offer a retry when it fails.
Don't
- Do not distinguish sent from received by colour alone — alignment carries it too.
- Do not leave the Vietnamese defaults in place:
locale,label,ariaLabel,labels,overflowLabelandcloseLabelall ship in Vietnamese. - Do not block the composer while a message is in flight — send optimistically.
- Do not format day separators at render time; the timezone will not match on hydration.
- Do not rebuild a file row, an empty state or a status chip by hand.
Accessibility
- Render the transcript as an
aria-live="polite"region so arriving messages are announced — but only the new ones, never the whole list. KeyboardHintisaria-hidden. The button it sits next to still needs its own label, e.g. “Send message”.- Presence dots carry a visually hidden text equivalent —
Online,Away,Offline— because the colour is not readable to everyone. - Avatars in an
AvatarGroupare focusable buttons whose accessible name is the person's name, so the roster is reachable without a mouse. RelativeTimeemits a real<time>with an exactdateTime; the precise moment lives in a native tooltip, which is not reachable by keyboard, so put it on screen when it matters.- Enter sends and Shift+Enter inserts a newline. Anyone who cannot produce that distinction still has the send button, which is a real
<button type="submit">. - Every thumbnail and attachment is a button or a link with a spoken label; the lightbox is a
Dialog, so focus is trapped and Esc closes it.
