Email & Inbox

A working three-pane mail client assembled almost entirely from the package: a mailbox notice, folders, a filterable message list whose label chips fold into “+N”, a reading pane that rests on an EmptyState, a collapsible thread, a floating bulk-action bar with a ConfirmDialog behind its delete, and a composer with address chips and send-later.

Every demo below is live — search filters, chips toggle, rows select, stars stick, deleting really moves mail to Trash, and an address that is not one shows up as one. 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

Two things get defined once for the whole screen. Mail labels are a StatusConfig table, so every chip is coloured from the same source — and a message carries several of them, in the table's priority order. A single fixed NOW makes every timestamp on the page deterministic. What the screen no longer defines is an English label set: through 2.x the search bar needed one, sr-only strings included. Since 3.0.0 the package speaks English by default and the language is chosen once, at the root, so the only words this page passes are the inbox's own — “Search mail…”, “messages”, what the palette searches.

mail-config.tsx
import { STATUS_TONES, 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' | 'follow-up' | 'customer' | 'billing' | 'renewal' | '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. A message carries several, and
   `priority` is the order a row shows them in, most important first. */
export const MAIL_LABELS: readonly StatusConfig<MailLabel>[] = [
  { value: 'urgent',    label: 'Urgent',    priority: 1, ...STATUS_TONES.destructive },
  { value: 'follow-up', label: 'Follow up', priority: 2, ...STATUS_TONES.warning },
  { value: 'customer',  label: 'Customer',  priority: 3, ...STATUS_TONES.info },
  { value: 'billing',   label: 'Billing',   priority: 4, ...STATUS_TONES.warning },
  { value: 'renewal',   label: 'Renewal',   priority: 5, ...STATUS_TONES.success },
  { value: 'internal',  label: 'Internal',  priority: 6, ...STATUS_TONES.primary },
  { value: 'automated', label: 'Automated', shortLabel: 'Auto', priority: 7, ...STATUS_TONES.neutral },
]

/* What is NOT here any more: an English labels object for SearchFilterBar. Since 3.0.0 every
   string the package ships — sr-only text included — is English by default, and the language
   is chosen once, in the root layout:

     <ComitorLocaleProvider locale={userLocale}>{children}</ComitorLocaleProvider>

   A screen passes only the words it owns: "Search mail…", "messages". */

Inbox layout

Folders, a message list and a reading pane, under a Banner for the one mailbox-wide state worth a line of its own — the out-of-office reply. The toolbar is a SearchFilterBar with FilterChips under it; each row's labels fold into “+N” with BadgeOverflow when the column is narrow; ticking rows raises an ActionBar over the foot of the list. Try it: search for export, filter to unread, star a row, select two messages and move them to Trash, 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.

Your out-of-office reply is on
Everyone who writes to you gets it until Friday, September 4.
Compose
  • Follow upCustomer
    4 messages in this thread
  • Auto
  • Follow upInternal
    2 messages in this thread
  • UrgentBilling
  • UrgentFollow upCustomer
    3 messages in this thread
  • CustomerBillingRenewal
    5 messages in this thread
  • Auto
  • Internal
    2 messages in this thread

The bar belongs to the list, not the window: centred on a three-pane window it would float under the reading pane, far from the rows it acts on. So it is scoped. portalContainer renders it inside the list pane — right after the rows, where Tab reaches it next — and its className swaps fixed for absolute, so the relative pane pins it (isolate keeps its z-50 inside the pane). While the bar is open the list keeps its height free at the foot — more of it where the bar wraps to two lines — so the bar never sits on the last row it is about to change. A 17rem pane has no room for words, so its actions are icon buttons with a Tooltip and an aria-label; Move to Trash calls preventDefault() in onSelect so the bar and its selection stay while the ConfirmDialog asks.

The banner has an action and no close button. It reports a state that stays true until someone ends it, so it offers “Turn off” rather than a way to stop seeing it — turn the reply back on from the palette (Jump to…). The rows are one button each: its ::after stretches over the whole row, so a click anywhere opens the message, while the checkbox and the star sit above it. The labels live beside that button rather than in it, because BadgeOverflow renders <div>s and a button may only hold phrasing content.

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.

The palette's screen-reader title and description are English by default and follow ComitorLocaleProvider like every other string — through 2.x they were Vietnamese, and no prop reached them before 1.0.0. The default description speaks of “commands, pages or people”, so this inbox passes a single key, labels.description, to say what it actually searches: folders, messages and commands. placeholder is the inbox's own wording too; emptyText and recentLabel keep their defaults.

Message row

The unit the whole screen is made of: LetterAvatar for the correspondent, StatusPills for the labels inside a BadgeOverflow (narrow the window and they fold into an outline “+N” that a screen reader hears as “and 1 more”), 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 — and the row button's ::after stretches over the avatar and the labels, so the whole row still opens the message and carries one focus ring.

  • Follow upCustomer
    4 messages in this thread
  • UrgentFollow upCustomer
    3 messages in this thread
  • Auto

Click anywhere on 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 count announced through role="status" as it changes, and an ActionBar that carries the actions. Its delete 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. The bar is scoped to the list, as in the inbox, and the list pads its foot while the bar is open, so no row ever sits underneath it.

8 messages
  • 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

The bar's contract is small: open follows the selection, and onOpenChange(false) — the ✕, Escape, or an item that has done its job — clears it. Choosing an ActionBarItem closes the bar, which is right for Mark read and Archive; Delete calls preventDefault() so the selection it is about survives while the dialog asks, and the dialog lives outside the bar. The result — “2 messages archived” — goes to a role="status" line of its own, because by then the bar has gone. When an action or the ✕ closes the bar, the package hands focus back to wherever it came from on the way in, so a keyboard user who runs an action is not dropped on the page itself — unless that control left with the rows the action removed.

ConfirmDialog keeps itself open and disabled while an async onConfirm is in flight, so you never build the double-submit guard yourself — the delete here waits on a pretend server for a moment so you can watch it. Its confirmLabel, cancelLabel and pendingLabel default to a generic “Confirm”, “Cancel” and “Working…”: always pass a verb phrase that names the action, pendingLabel included, or the button turns generic at the moment the user is watching it.

Composer

To, Cc and Bcc are TagsInput fields: Enter or a comma turns what you typed into a chip, pasting “[email protected], [email protected]” adds both, and blurBehavior="add" keeps a half-typed address as a chip when focus leaves — the classic way a recipient silently goes missing. An address that is not one stays, marked with a wavy underline and a note for screen readers, and Send waits until it is fixed: double-click the chip to edit it, or remove it from the keyboard — Backspace in the empty field selects the last chip, ← and → move along, Backspace again removes it. Send also waits for a subject and a body, and the KeyboardHint beside it is honest: really does send, and the hint renders ⌘ on macOS and Ctrl everywhere else.

New message

it-ops@northwind (not a valid address)

1 address is not valid — double-click to fix, or Backspace to remove.

acs-endpoints-eu.txt

Send later adds a DatePicker and a TimePicker. The time picker shows a 12-hour clock with AM/PM because this page is English; under a Vietnamese provider the same markup is a 24-hour clock and the AM/PM segment and column disappear — the value is "HH:mm" either way, and the summary line formats it with the same locale from useComitorLocale(). Its min and max bound a time of day and cannot see the date picked beside it, so the composer checks the moment itself and refuses one that has already passed; minDate on the date picker rules out earlier days.

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, and let BadgeOverflow fold the ones that do not fit.
  • Route every destructive bulk action through ConfirmDialog, and list what it affects.
  • Scope the bulk bar to the list it acts on, and keep its height free at the foot of that list while it is open.
  • Keep a half-typed recipient when focus leaves (blurBehavior="add"), and show an invalid address instead of dropping it.
  • 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 copy English label sets from old code to “fix” the package's language: since 3.0.0 English is the default, and the language is set once with ComitorLocaleProvider. Override only the words that are the inbox's own.
  • Do not hand-roll a chip, an empty state, a confirm dialog or a bulk bar 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 let a floating bar cover the rows it is about to change.
  • Do not nest the row checkbox inside the row button — nor the labels, which are <div>s; stretch the button over them instead.
  • Do not give a banner that reports a live state a close button — give it the action that ends the state.
  • Do not rely on TimePicker's min and max for a moment: they bound the time of day, not the date beside it, so check the moment yourself.
  • 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 whole row opens the message, but only one element in it is the control: the button, whose ::after carries both the hit area and the focus ring. The avatar is aria-hidden, because the button already names the sender.
  • The star is a toggle with aria-pressed and one label, Star, in both states — a label that flipped to Unstar would be read as “Unstar, pressed”.
  • A label chip's “+N” is aria-hidden; screen readers hear “and N more” instead. The inbox renders its own compact chip through renderOverflow, so it writes that sentence itself.
  • The selection count sits in a role="status" region so it is announced as it changes.
  • The bulk bar is a role="toolbar" named “Bulk actions”. Its items are one tab stop moved with the arrow keys, Escape clears the selection, and the inbox's icon-only items carry an aria-label as well as a tooltip. Rendered inside the list, it is the next tab stop after the rows, and when an action or its ✕ closes it, focus returns to the control it came from.
  • Recipient chips are removed with Backspace or Delete from the field. An invalid chip carries a screen-reader note, and the field is aria-invalid and described by the error line, a polite live region.
  • Each TimePicker segment is named — Hour, Minute, AM/PM — the clock button is “Open time picker”, and the group is labelled by TimePickerLabel.
  • 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 a label saying whose faces these are, where the default would be a generic “Members”.
  • ConfirmDialog and CommandPalette trap focus and close on Escape for free — and an Escape they handle does not also clear the bulk selection.

Composites on this page