Web Application

A complete product surface assembled from the package: the Tier 3 application frame with its workspace notices and first-run tour, a page header and container, KPI cards, a members table with search, sorting, selection, bulk actions and paging, tabbed content, create and edit forms, a stepped setup flow, and settings — split across tabs that are real routes, or kept on one long page with its own table of contents.

Nothing on this page is a drawing of a component. Every demo is the real export, wired to real state — the sidebar navigates, the table sorts, the filters filter, the notices queue, the tour walks the frame, and the confirmation dialogs actually remove rows.

Application Shell

AppShell is the whole frame: sidebar, header, workspace switcher, product launcher, user menu, mobile menu and the ⌘K palette slot, around a scrolling <main>. You pass data once at the layout; every part reads it back from the shell context. Two things live in the frame rather than in any page: workspace notices, queued with Banners, and a first-run Tour.

Skip to main content

Overview

Open tasks

248

+12vs last week

Closed this week

96

+18%vs last week

Recent activity

What the team changed today.

BIL-198 · Refund webhook retries
DS-072 · Striped DataTable
ONB-118 · Welcome email
BIL-214 · Proration off by one

Move around the sidebar and the screen changes. Switch workspace at the top of the sidebar, switch product from the launcher in the header — the two move independently, and the notice above the page follows the workspace. The search field opens the ⌘K palette; CRM is locked, so choosing it calls onUpsell instead of navigating.

The notice above the page follows the workspace, not the route. Acme Corp has two — a declined card and the weekend’s maintenance — and Banners shows the more urgent one; close it and the next slides in. Northwind only has the maintenance, and a notice you closed stays closed when you come back. Notices go in through useBanners().onBannerAdd, once per workspace: they are data that follows the workspace rather than markup in the layout, and the queue owns each one until someone closes it. Declaring <Banner>s inside <Banners> works too — they keep their place when the layout re-renders.

The tour starts from the card on Overview, from md up, and makes four stops. It moves focus into each step, keeps Tab there, ends on Escape or a click outside, and hands focus back to the button that started it. Create workspace in the switcher opens the flow from Workspace Setup below: finish it and the new workspace joins the switcher — start a trial and it brings a notice of its own.

Root Layout and Language

The package draws every string in English — visible text, accessible names, screen-reader-only text, drag-and-drop announcements — until one ComitorLocaleProvider says otherwise. It belongs in the root layout, above the shell: sign-in, error pages and anything portalled out of the frame render outside AppShell, and they must speak its language. A Comitor app takes the language from the user’s settings through next-intl; an app with one language writes locale="vi". The rest is in Set the language.

app/layout.tsx
// app/layout.tsx — the ROOT layout, a server component
import type { ReactNode } from 'react'
import { NextIntlClientProvider } from 'next-intl'
import { getLocale } from 'next-intl/server'
import { ComitorLocaleProvider } from '@comitor/ui'
import { ThemeProvider } from '@comitor/ui/shell'
import './globals.css' // @import "tailwindcss"; @import "@comitor/ui/styles.css";

/**
 * Every string the package draws — visible text, aria-labels, sr-only text,
 * drag-and-drop announcements — is English until ONE provider says otherwise,
 * and this is where it goes:
 *
 * - The ROOT, not the shell layout. Sign-in, error pages and anything portalled
 *   out of the frame render outside AppShell, and must speak its language.
 * - A Comitor app never hard-codes the language: it is the USER's setting
 *   (Account profile → cookie → next-intl), so the provider takes getLocale().
 *   An app with one language writes locale="vi" and needs no next-intl.
 * - locale is BCP 47. It picks the built-in table (vi → Vietnamese, anything
 *   else → English) AND drives Intl: numbers, times, the 12/24-hour clock.
 */
export default async function RootLayout({ children }: { children: ReactNode }) {
  const locale = await getLocale()

  return (
    <html lang={locale} suppressHydrationWarning>
      <body>
        <NextIntlClientProvider>
          <ThemeProvider>
            <ComitorLocaleProvider locale={locale}>{children}</ComitorLocaleProvider>
          </ThemeProvider>
        </NextIntlClientProvider>
      </body>
    </html>
  )
}

// app/locale-provider.tsx — only once you pass FUNCTIONS. The root layout then
// renders <AppLocaleProvider locale={locale}> where ComitorLocaleProvider was.
'use client'

import type { ReactNode } from 'react'
import { ComitorLocaleProvider, type ComitorMessagesOverride } from '@comitor/ui'
import { enGB } from 'date-fns/locale'

/**
 * A string that counts is a function (members(3) → "3 people"), and a date-fns
 * locale object is mostly functions. Neither crosses from a server component
 * into a client one, so the provider that receives them sits in a client file.
 * Module-level on purpose: a new messages object every render re-renders every
 * component that reads the provider.
 */
const MESSAGES: ComitorMessagesOverride = {
  shell: {
    workspace: 'Company',
    members: (count) => `${count} ${count === 1 ? 'person' : 'people'}`,
  },
}

export function AppLocaleProvider({ locale, children }: { locale: string; children: ReactNode }) {
  return (
    <ComitorLocaleProvider
      locale={locale}
      messages={MESSAGES}
      // date-fns dates map only vi → vi; every other code gets en-US (12/25/2026).
      // British dates need their locale passed in.
      dateFnsLocale={locale === 'en-GB' ? enGB : undefined}
    >
      {children}
    </ComitorLocaleProvider>
  )
}

AppShell also accepts locale. It is a shortcut for an app with no root layout of its own: it wraps only the frame, in a fresh provider that does not inherit messages or dateFnsLocale from an outer one, and adding or removing the prop remounts the whole shell. Region is a separate setting again: PhoneInput takes defaultCountry and MaskInput takes currency from the workspace, never from the language.

App Header

Three zones — identity, a centred search pill that opens the command palette, and the action cluster. The notification panel is deliberately not in the package: only the bell is, because the payload of a notification differs per product.

Page content starts here. The header keeps three zones: identity on the left, the search pill dead centre regardless of how long the left zone gets, and the action cluster on the right.

Open the bell for the app-owned panel, the grid for the product launcher, the avatar for the user menu, or click the search field to raise the command palette.

Stat Cards

StatCard carries the label, the figure, an icon tone, a delta and an optional footer. Colour comes from direction times polarity, so “overdue is up” reads red while “spend is down” reads green. Flip the switch to see the loading skeleton the package ships.

Open tasks

248

+12vs last week

Closed this week

96

+18%vs last week

Overdue

7

+3vs last week

Spend to date

$89.4K

-4.1%vs budget

Renews 01 Oct 2026

Data Table

The list screen every product needs: SearchFilterBar + DataTable + TablePagination, with a column toggle, row selection, bulk actions in an ActionBar, a confirmation behind ConfirmDialog and two different empty states. Search, sort, filter, select and remove rows — all of it is live.

Actions
Bình Phan[email protected]OwnerActive14
Dana Whitfield[email protected]AdminActive9
Hà Vũ[email protected]AdminActive11
Grace Okafor[email protected]AdminActive8
Sara Lindqvist[email protected]MemberActive7

Select a row and the bar rises: it counts the selection, resends invitations or removes members, and its close button — or Escape — clears the selection. An Escape that already closed a menu or the confirmation is not counted twice, and neither is one pressed while a Vietnamese input method is still composing a word. In an app the bar floats at the bottom of the viewport and portals to the end of <body>, so a keyboard user reaches it last — and it centres on the viewport, half the sidebar’s width off the table’s centre. Portal it into an empty <div> right after the table and pass anchor="container" to fix both. This demo passes portalContainer to keep it inside the box and next in the Tab order.

Content with Tabs

A page header with a primary action over a tabbed list of resource cards. These panels live in one document, so this is Tabs — the moment a tab becomes its own URL, it is RouteTabs instead.

Projects

Everything the workspace is currently shipping.

Billing rebuild

BIL · 18 open · due 12 Sep 2026

On track
+1
Updated

Onboarding v3

ONB · 26 open · due 05 Sep 2026

At risk
+1
Updated

Design system 1.0

DS · 5 open · due 19 Sep 2026

On track
+1
Updated

Create Dialog

A short create flow in a ResponsiveDialog — a centred dialog from 768px up, a bottom drawer below it, in reach of a thumb and dismissed with a swipe. Laid out with FormSection and FormField: spread the control argument into the input and label, description, error text and required state are wired for you.

The submit button stays disabled until name and email are valid. Narrow the window below 768px and it opens as a drawer.

Edit Drawer

The same fields in a side sheet — better for editing a record you want to keep in view, and it has room for a long form without the scrolling body a small dialog forces on you.

The drawer keeps its draft between opens — edit the role, save, then reopen it.

Workspace Setup

A create flow too long for one screen: Stepper in a ResponsiveDialog, with TagsInput for the people to invite. The shell only asks for it — the switcher shows Create workspace once onCreateWorkspace exists — and everything after that is the app’s.

Press Next with the name empty. Then type a Vietnamese name and watch the address follow it — “acme” is already taken.

Moving forward runs one validation, whether it came from Next or from a later step in the list — the list is a tablist, so the arrow keys walk it too. Back and Next stay the same two buttons from the first step to the last: StepperPrev and StepperNext disable themselves at the ends, and a focused button that is disabled or removed loses the keyboard user’s focus. The address comes from normalizeVi, so “Công ty Bình Minh” becomes cong-ty-binh-minh.

Settings Page

Four settings sections behind RouteTabs: real links carrying aria-current="page", drawn from the same class table as Tabs so the two are indistinguishable on screen. The header and the strip belong in the settings layout, not in each page. Billing measures its quotas with CircularProgress, whose arc turns to the warning ink from 80%.

Settings

Preferences for Acme Corp.

Workspace

Everyone in Acme Corp sees this.

Current route: /settings

Long Settings Page

When every setting belongs to one object — here, a webhook — one long page reads better than five tabs. ScrollSpy keeps an in-page table of contents in step with a fixed-height scroll area, and the custom headers are a KeyValue list: paste a few Header: value lines into a name field and they split into rows.

Webhook

Acme Corp · a POST to your endpoint whenever work changes.

Endpoint

Where each delivery goes.

Events

Only what you subscribe to is sent.

Custom headers

Sent with every delivery. Paste Header: value lines into a name field to add several at once.

Signing secret

Each delivery carries X-Comitor-Signature, an HMAC-SHA256 of the body. Verify it before trusting a payload.

Delivery

What happens when your endpoint does not answer 2xx in time.

2 headers · 2 of 4 events

ScrollSpyLink cancels the native #fragment jump, so Tab would carry on from the table of contents rather than from the section you jumped to — each section takes tabIndex={-1} and its link focuses it. KeyValue keeps its default trim: the field shows the space in “Bearer token” as it is typed, and every header it hands back is already trimmed. Below sm the table of contents is hidden and the sections read in order.

Multi-app Navigation

Comitor navigation has two perpendicular axes: workspace and product. Here the frame runs layout="header-first", so the workspace switcher moves into the header and the launcher sits beside it. A product outside the plan shows a padlock and calls onUpsell rather than landing the reader on a 403.

Skip to main content
Workspace
Acme Corp · Business
Product
Tasks
Route
/tasks

The two axes are perpendicular: changing workspace keeps you in the same product, and changing product keeps you in the same workspace. Chat carries its own accent, so the sidebar highlight and the icon tints follow the product you are in.

Open the grid icon in the header for the product launcher, and the workspace name on its left for the workspace list.

Best Practices

Do

  • Pass workspaces, apps, nav and user to AppShell once, in the layout, and let the parts read the context.
  • Set the language once, with ComitorLocaleProvider in the root layout fed the user’s locale — not with labels on every component, and not only around the shell.
  • Reset to page 1 whenever a filter changes, or the reader lands on an empty page.
  • Write two empty states: “nothing here yet” and “nothing matched” need different next actions.
  • Put irreversible actions behind ConfirmDialog, single-row deletes included.
  • Queue workspace notices from one component that adds them once per workspace, and remember what the reader closed.
  • Start a tour from a button the reader pressed, and save how it ended per user so it never starts by itself.

Don't

  • Do not rebuild DataTable out of <table> — you lose sorting, selection, pinned columns, skeletons and the empty state.
  • Do not hand-roll a selection bar: ActionBar is a named toolbar with roving focus, and its Escape leaves the layers above it alone.
  • Do not use Tabs when each tab is a URL: the roles it announces describe a panel swap that is not happening.
  • Do not omit LinkComponent on Next — every tab and nav item becomes a full page load.
  • Do not render a launcher tile for a product the workspace has not bought.
  • Do not colour a delta by its arrow alone; state the polarity of the metric.
  • Do not point a tour step at something off screen: below md the sidebar and the launcher live in the mobile menu.

Accessibility

  • The frame supplies the landmarks: <header>, <aside> with a labelled <nav>, and the scrolling <main>, with a skip link to it as the first thing Tab reaches (3.7.0) — do not draw a second one. A screen with a second navigation landmark — the ScrollSpy table of contents is one — names each of them.
  • Give every table an aria-label, and every icon-only row action one naming the record it acts on.
  • LetterAvatar already carries the name as screen-reader text — mark it aria-hidden when a visible name sits next to it, or the name is read twice.
  • A Progress bar is a mute picture; name it after the thing it measures. So is a CircularProgress: aria-label names the ring, and getValueText makes it say “24 of 30 seats” where the centre shows 80%.
  • Since 3.0.0 the strings only a screen reader hears — the Combobox clear button, the CommandPalette title and description, the close buttons of Dialog, Sheet, MobileMenu and Banner — come from the same provider as the visible text. They were the ones that used to slip past a visual review; one provider at the root now covers them too.
  • When you do override a string that is both visible and an accessible name, change both halves: an aria-label that says something the screen does not fails WCAG 2.5.3, Label in Name.
  • ActionBar is a toolbar named “Bulk actions”: the arrow keys move between its items, its close button says what it does (“Clear selection”), and Escape clears the selection — unless a menu or dialog above it has already used that Escape.
  • Each tour step is a role="dialog" named by its title and described by its text. Focus moves in, Tab stays in, Escape or a click outside ends the tour, and focus goes back to the button that started it.
  • Queued notices are role="status" live regions, read out politely as they arrive; their close button is named for you (“Close notification”).
  • The Stepper’s step list is a tablist, and a finished step keeps its number for screen readers (“Step 1, completed”) where the screen shows a tick. A polite live region reports the step the buttons moved to.
  • KeyValue names each remove button after its row (“Remove Authorization”), sends focus to the new row on Add and to the next remove button after a delete. ScrollSpy marks the link for the section in view with aria-current="location".