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

Yesterday
Agenda for tomorrow: billing migration, the status colour pass, and whatever support wants to escalate.
Read

Huy

I will bring the regression list — three of them are still open on staging.
Today

Mai

Status colours are merged. Every pill is measured at AA on both themes now, and the ink steps are what carry the text contrast.
Release notes go out with 0.9.2.
Nice. I will pull them into the docs site this afternoon.
Read
Seen by

Hùng

@Thành the gateway sandbox is flaky again — I opened INF-118 with the traces attached.

Mai

Tokens are merged — release notes next.
Mai is typing…

SendNew lineMention

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

+2

bg-muted · restated

+2

bg-sidebar · restated

+2

wrong — card surface declared on a muted panel

+2

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

Yesterday
Agenda for tomorrow: billing migration, the status colour pass, and whatever support wants to escalate.
Read

Huy

I will bring the regression list — three of them are still open on staging.
Today

Mai

Status colours are merged. Every pill is measured at AA on both themes now, and the ink steps are what carry the text contrast.
Release notes go out with 0.9.2.
Nice. I will pull them into the docs site this afternoon.
Read
Seen by

Hùng

@Thành the gateway sandbox is flaky again — I opened INF-118 with the traces attached.

Mai

Tokens are merged — release notes next.

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.

Are we shipping the release notes today?

Yes — drafting them now.

SendNew lineMentionEdit lastClear

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.

The coast at golden hour

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.

Vũ Thanh Hà

8 photos ·

Q3-report.pdf
0%
launch-plan.xlsx

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.

Online

Nguyễn Đức Thành

Online

Away

Phạm Minh Anh

Away

Offline

Đỗ Quang Huy

Offline

SendingSentReadNot delivered
Release notes are drafted — sending them over now.

Read

Seen by
+1
And the changelog entry.

Not delivered

Mai is typing…

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

Pick a conversation

Filtered to nothing

No conversations

Selected, but empty

No messages yet

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 RelativeTime a fixed now for fixtures and a real clock for live data.
  • Restate surfaceClassName whenever 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’s min bounds a time of day, not the date beside it.
  • Set the language once, with ComitorLocaleProvider at 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 locale on every RelativeTime, no labels object 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.
  • KeyboardHint is aria-hidden. The button it sits next to still needs its own label, e.g. “Send message”.
  • With Mention the textarea becomes a role="combobox": aria-controls points at the suggestion list (the package fixed the upstream reference that pointed at nothing) and aria-activedescendant follows ↑ and ↓. The list is named “Suggestions” unless you pass aria-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 AvatarGroup are 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 Dialog or, under 768px, a Drawer. Both move focus inside when they open — the package's Drawer defaults autoFocus to true since 2.1.0 — and hand it back to the trigger when they close.
  • Every TimePicker segment has a spoken name — Hour, Minute, AM/PM — the group takes its name from TimePickerLabel, and invalid sets aria-invalid on the segments as well as the red border.
  • RelativeTime emits a real <time> with an exact dateTime; 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 — no DialogTrigger needed since 3.3.0. The attachments are an ItemGroup list, which gives each Item among its children role="listitem"; the uploading row is drawn by its own component, out of the group's sight, so it carries the role itself. CircularProgress reports its value as aria-valuetext (“62%”) and, with no visible label, needs an aria-label.
  • The connection Banner is a polite role="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.

What this page is built from