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

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 line

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

+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 — 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

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. 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.

Are we shipping the release notes today?

Yes — drafting them now.

SendNew lineEdit lastClear

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.

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.

Vũ Thanh Hà

8 photos ·

Q3-report.pdf
launch-plan.xlsx

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.

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.

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.

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, overflowLabel and closeLabel all 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.
  • KeyboardHint is aria-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 AvatarGroup are focusable buttons whose accessible name is the person's name, so the roster is reachable without a mouse.
  • 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.
  • 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.

What this page is built from