Patterns
Chat & Messaging
A team inbox: a filtered conversation list, a grouped transcript, a composer that obeys the shortcuts people already know and suggests names after @, messages scheduled for later, and the states around them — presence, typing, delivery, a dropped connection, 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, the people button opens the member list, the composer offers the conversation's own members after @, 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
↵SendShift+↵New line@Mention
Three 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 sits in the future. 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. And the member list is one component with two shapes — ResponsiveDialog is a centred Dialog on a desktop and a bottom-sheet Drawer under 768px. The two sit on different surfaces, bg-card and bg-background, so the presence rings read the data-variant that every part of it carries.
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. The English default, “Members”, fits every group, so a screen that shows several names each one — “Participants” in the header, “Seen by” under a message. 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, named by overflowLabel — “View all 6 members” unless you say which six. 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. Its language is the page's, not a prop's: English by default, and “6 phút trước” under a Vietnamese ComitorLocaleProvider at the root. The day separators are literal strings carried on the message only because this data is frozen. Live data keeps the timestamp and works out “Today” or “Yesterday” at render time, in the viewer's time zone passed explicitly — left to each machine's own zone, the server and the browser would disagree.
A mention is stored as an id and found again in the text when the message renders — teal on someone else's bubble, gold when the name is yours. That gold @Thành is why #product-sync sits under Mentions in the inbox.
The composer
Five shortcuts, all of them live below: Enter sends, Shift+Enter starts a new line, @ opens the channel's members, 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; its last line is Mention's value, which holds ids, never names.
↵SendShift+↵New line@Mention↑Edit lastEscClear
What the composer just did
Type in the composer, try the shortcuts above, and type @ to mention someone.
Mention value → []
Mention turns the textarea into a combobox. While its list is open, Enter, Esc and the arrows belong to the list — and because your onKeyDown runs before Mention's, and a key you preventDefault never reaches it, the composer checks the list first and steps aside. A picked name lands as one block with a space after it: Backspace deletes @Mai whole, never a letter at a time, and ← → jump over it. Three things are yours to decide. Its built-in filter matches each item's label — here the given name it inserts — with or without accents, so @hung finds Hùng; to find him by family name as well, onFilter runs matchesSearch on the full name, and @Lê finds Lê Văn Hùng. It keeps the ranges of the names it inserted in state nothing outside can clear, so each sent message remounts it with a new key and hands focus to the new textarea. And it takes one trigger character, read once when it mounts — #channels in the same box is not something it does.
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 jump that lands the caret straight in the text needs your own Textarea instead.
Send later
Scheduling is a second way to send, not a second composer. Send later opens a ResponsiveDialog — centred on a desktop, a bottom sheet on a phone — with three presets, a DatePicker and a TimePicker. A preset only fills the two fields, so what will happen is on screen before Schedule. Scheduled messages wait above the composer until you send them early or cancel them; cancelling puts the text back.
Scheduled · 0
Nothing scheduled. Write a message, then press Send later.
The clock follows the language: English shows a 12-hour clock with AM/PM and dates as MM/dd/yyyy, Vietnamese a 24-hour clock and dd/MM/yyyy — and the TimePicker value is "HH:mm" in 24-hour either way, so the code that joins it to the date never asks which language is on. Format your own strings with useComitorLocale().dateFnsLocale and they switch with everything else. TimePicker’s min and max bound a time of day and cannot see the date picked beside it, so the check against the clock is the app's: pick an earlier time today and the dialog refuses it in words, with invalid on the field.
One focus detail. After Schedule the draft is empty, so the Send later button is disabled by the time the dialog closes — and a disabled button cannot take focus back, from Radix or from the package’s own return. onCloseAutoFocus runs before both and hands focus to the composer instead, which is where the next message starts anyway.
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. The spreadsheet's upload is live: cancel it, retry it, or start it again.
8 photos ·
2.4 MB · PDF
Uploading · 0.0 of 1.8 MB
Each thumbnail is a real button with a spoken label, not a clickable div, and the lightbox's close button is named “Close” without being told. An upload in flight puts a CircularProgress where the file icon goes, at the icon's size, so the row does not move when it finishes: the ring becomes the icon, the progress becomes the file size, Cancel becomes Download. That last one is a single button whose label and icon change, so a keyboard user who pressed Cancel is still on it when it turns into Retry. The ring is a role="progressbar" with no visible label, which is why it carries an aria-label.
Presence, typing, delivery and connection
The states that tell a reader whether anyone is there, and whether they are: presence on the avatar, a typing indicator while someone composes, a delivery state under your own messages, and a banner when the connection drops. 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.
Drop the connection and a Banner spans the top of the frame: a notice for the whole workspace rather than a toast that times out, and dismissible={false}, because closing it would not reconnect anyone. A message sent meanwhile is queued as Sending and turns Sent when the connection is back, and the typing indicator goes with the connection — offline, nobody's typing reaches you.
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.
- Let the @ list own Enter while it is open; send only once it has closed.
- Check a scheduled time against the clock yourself —
TimePicker’sminbounds a time of day, not the date beside it. - Set the language once, with
ComitorLocaleProviderat the root; keep props for the words that belong to one screen.
Don't
- Do not distinguish sent from received by colour alone — alignment carries it too.
- Do not switch language one prop at a time: no
localeon everyRelativeTime, nolabelsobject per component. The package is English by default and the provider changes all of it. - Do not block the composer while a message is in flight — send optimistically.
- Do not trust
Mention's value alone; keep the ids whose @Name is still in the text. - Do not make a connection banner dismissible, or turn it into a toast that times out.
- Do not work out a day separator in the machine's own time zone: the server and the browser disagree, and hydration fails. Pass the viewer's, explicitly.
- 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”.- With
Mentionthe textarea becomes arole="combobox":aria-controlspoints at the suggestion list (the package fixed the upstream reference that pointed at nothing) andaria-activedescendantfollows ↑ and ↓. The list is named “Suggestions” unless you passaria-label— this page says “People in this conversation”. - Presence dots carry a visually hidden text equivalent —
Online,Away,Offline— because the colour is not readable to everyone. Where the row prints the word, as in the member list, the dot stays silent rather than saying it twice. - Avatars in an
AvatarGroupare focusable buttons whose accessible name is the person's name, so the roster is reachable without a mouse. - The member list and Send later open as a
Dialogor, under 768px, aDrawer. Both move focus inside when they open — the package'sDrawerdefaultsautoFocusto true since 2.1.0 — and hand it back to the trigger when they close. - Every
TimePickersegment has a spoken name — Hour, Minute, AM/PM — the group takes its name fromTimePickerLabel, andinvalidsetsaria-invalidon the segments as well as the red border. 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 — as the scheduled list does.- 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, Esc closes it, and focus goes back to the thumbnail that opened it — noDialogTriggerneeded since 3.3.0. The attachments are anItemGrouplist, which gives eachItemamong its childrenrole="listitem"; the uploading row is drawn by its own component, out of the group's sight, so it carries the role itself.CircularProgressreports its value asaria-valuetext(“62%”) and, with no visible label, needs anaria-label. - The connection
Banneris a politerole="status", but it joins the page together with its text, and a live region that arrives already filled is not reliably announced. The demo also writes the state into a status line that is always mounted.
