A working three-pane mail client assembled almost entirely from Tier 2 composites: folders, a filterable message list, a reading pane that rests on an EmptyState, a collapsible thread, bulk actions behind a ConfirmDialog, and a composer.
Every demo below is live — search filters, chips toggle, rows select, stars stick, deleting really moves mail to Trash. The hand-written markup is only the parts no package component covers: the pane layout, the folder rail, and the arrangement of fields inside a row and inside the composer.
Labels, tones and the clock
Three things get defined once for the whole screen. Mail labels are a StatusConfig table so every chip is coloured from the same source; the search bar gets one English labels object because the package ships Vietnamese defaults — including sr-only strings no other prop reaches; and a single fixed NOW makes every timestamp on the page deterministic.
mail-config.tsx
import { STATUS_TONES, type SearchFilterBarLabels, type StatusConfig } from '@comitor/ui'
/* Every timestamp on this screen measures from one fixed instant, so the strings are
identical on the server and in the browser. Drop `now` and pass live data instead
the moment the mailbox comes from an API. */
export const NOW = new Date('2026-08-30T09:12:00Z')
export type MailLabel = 'urgent' | 'customer' | 'billing' | 'internal' | 'automated'
/* Mail labels are the app's status table. Tones come from STATUS_TONES — never a local
colour switch, and never a raw scale like bg-red-100. */
export const MAIL_LABELS: readonly StatusConfig<MailLabel>[] = [
{ value: 'urgent', label: 'Urgent', priority: 1, ...STATUS_TONES.destructive },
{ value: 'customer', label: 'Customer', priority: 2, ...STATUS_TONES.info },
{ value: 'billing', label: 'Billing', priority: 3, ...STATUS_TONES.warning },
{ value: 'internal', label: 'Internal', priority: 4, ...STATUS_TONES.primary },
{ value: 'automated', label: 'Automated', shortLabel: 'Auto', priority: 5, ...STATUS_TONES.neutral },
]
/* The package ships Vietnamese defaults, including the sr-only strings. One shared object
per screen beats repeating the overrides at every call site. */
export const EN_SEARCH_LABELS: Partial<SearchFilterBarLabels> = {
searchPlaceholder: 'Search mail…',
unitLabel: 'messages',
clearSearch: 'Clear search',
filterCount: (count) => (count === 1 ? '1 filter' : `${count} filters`),
clearFilters: 'Clear filters',
resultSummary: (resultCount, total, unitLabel) => (
<>
<strong className="font-medium text-foreground tabular-nums">{resultCount}</strong>
{total !== undefined ? ` of ${total}` : ''} {unitLabel}
</>
),
}
Inbox layout
Folders, a message list and a reading pane. The toolbar is a SearchFilterBar with FilterChips under it; selecting rows swaps the chips for a bulk toolbar whose delete goes through ConfirmDialog. Try it: search for export, filter to unread, star a row, select two messages and delete them, then open Trash and find them there. Close the reading pane with the ✕ button to see its resting state — an EmptyState, not a blank column. Below md the rail and the reading pane drop away and the list takes the full width, which is what a real mail client does on a phone.
Our identity team finished the SAML setup this morning. I have attached the IdP metadata for the staging tenant — the entity ID is urn:northwind:staging.
Two things we still need from you: the ACS URL for the EU region, and confirmation that group claims arrive as a multi-value attribute rather than a comma-separated string.
We want to move the pilot group across on Thursday, so anything you can send before Wednesday noon helps.
northwind-idp-metadata.xml
Two details worth copying. The list uses addSuffix={false} on RelativeTime so the column reads “25 minutes” rather than “25 minutes ago” — safe only because a received-time column has one possible direction; the reading pane keeps the suffix. And the palette is mounted with registerShortcut={false} so this documentation page does not swallow Ctrl+K for the whole site. In your app, leave it on.
One thing to set, because it is invisible on screen: the dialog title and description CommandPalette announces are Vietnamese sr-only text by default. Through 0.9.1 no prop reached them, so an English page whose every visible string was English still announced two Vietnamese ones whenever the palette opened. 1.0.0 adds a labels prop — title and description — so pass it here. The three visible strings (placeholder, emptyText, recentLabel) were always separate props and are unchanged.
Message row
The unit the whole screen is made of: LetterAvatar for the correspondent, StatusPill for the label, a RelativeTime for the timestamp, plus unread weight, an attachment clip and a thread count. The checkbox, the row and the star are three sibling controls — a checkbox nested inside a button is invalid markup and unusable by keyboard.
Click a row to open it (which clears unread), the star to flag it, the checkbox to select it.
Conversation thread
Older messages collapse to a single preview line, the newest starts open, and every header is a Collapsible — which is what earns each trigger its aria-expandedand the aria-controls pointing at its own panel, a pairing a hand-wired button cannot make without inventing ids. AvatarGroup carries the participants — each avatar is focusable and names its person in a tooltip, so the row is information rather than decoration.
SSO rollout — SAML metadata for the staging tenant
4 messages · Northwind
Customer
Our identity team finished the SAML setup this morning. I have attached the IdP metadata for the staging tenant — the entity ID is urn:northwind:staging.
We want to move the pilot group across on Thursday, so anything you can send before Wednesday noon helps.
Bulk actions
A select-all checkbox that goes indeterminate on a partial selection, a live count announced through role="status", and a delete that opens a ConfirmDialog listing exactly which messages are about to go — “3 items” is not something anyone can check. Archive and delete both really remove rows here; empty the list and the EmptyState hands you a way back.
2 selected
PRPriya RamanPriya RamanUnread. Re: SSO rollout — SAML metadata for the staging tenantCustomer
DBDeploy botDeploy botUnread. Release 0.9.1 promoted to productionAuto
BBillingBillingInvoice INV-2841 is past dueBilling
AOAmara OseiAmara OseiUnread. Data export keeps timing out at 40k rowsUrgent
LJLars JensenLars JensenRenewal — can we move to annual billing?Customer
WDWorkspace digestWorkspace digestYour workspace this weekAuto
MTMei TanakaMei TanakaQ4 headcount plan — draft for reviewInternal
ConfirmDialog keeps itself open and disabled while an async onConfirm is in flight, so you never have to build the double-submit guard yourself. Its confirmLabel and cancelLabel default to Vietnamese — always pass an English verb phrase naming the action, never “OK”. The in-flight label it swaps in was the one string here you could not override in 0.9.1; 1.0.0 adds pendingLabel for it, so set that too or the button turns Vietnamese at the moment the user is watching it.
Composer
Recipient and subject fields, a body area, removable attachment chips, and a Send button that stays disabled until all three fields have content. The KeyboardHint next to it is honest: Ctrl+↵ really does send, and the hint renders ⌘ on macOS and Ctrl everywhere else, which is why hard-coding the string is wrong for half your readers.
New message
acs-endpoints-eu.txt
Best Practices
Do
Mark unread with weight, a tint and a screen-reader word — never colour alone.
Give the reading pane a real resting state with EmptyState.
Colour labels from one StatusConfig table, not a per-screen switch.
Route every destructive bulk action through ConfirmDialog, and list what it affects.
Pass now to RelativeTime for fixtures, screenshots and tests.
Keep archive and delete one click away; hide the rest behind an overflow menu.
Collapse older thread messages and expand only the newest by default.
Don't
Do not leave the package's Vietnamese defaults in an English inbox.
Do not hand-roll a chip, an empty state or a confirm dialog the package already ships.
Do not auto-mark as read on hover or on a timer — wait for a deliberate open.
Do not keep three panes below md; move to a stacked flow.
Do not float the bulk toolbar over the rows it is about to change.
Do not nest the row checkbox inside the row button.
Do not hard-code “Ctrl” in a shortcut hint; let KeyboardHint decide.
Accessibility
Unread is announced in text (<span className="sr-only">Unread.</span>), not only by weight and tint.
The folder rail is a <nav aria-label> and the active folder carries aria-current="page".
Folder counts get a visually hidden noun, so “5” is read as “5 unread”.
Row checkboxes name their message: Select “Q4 headcount plan” from Mei Tanaka.
The star is a toggle with aria-pressed and a label that flips between Star and Unstar.
The selection count sits in a role="status" region so it is announced as it changes.
Thread headers are Collapsible triggers — aria-expanded plus aria-controls — inside real heading elements.
AvatarGroup makes each avatar focusable and exposes the person's name; the group takes an English label.
ConfirmDialog and CommandPalette trap focus and close on Escape for free.