Patterns

Email & Inbox

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.

Compose

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 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-expanded and 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
  1. 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.

1 of 4 expanded

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
  • Priya RamanUnread. Re: SSO rollout — SAML metadata for the staging tenant
  • Deploy botUnread. Release 0.9.1 promoted to production
  • Daniel OkaforUnread. Design review notes — inbox pattern
  • BillingInvoice INV-2841 is past due
  • Amara OseiUnread. Data export keeps timing out at 40k rows
  • Lars JensenRenewal — can we move to annual billing?
  • Workspace digestYour workspace this week
  • Mei TanakaQ4 headcount plan — draft for review

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

Composites on this page