Composites

Tier 2 is the part of the package that is actually about Comitor: 48 patterns every app in the ecosystem repeats — a list screen, a status chip, a confirm dialog, a date range — assembled out of the Tier 1 primitives and shipped from the @comitor/ui entry, the same one the primitives come from — with four exceptions that carry an optional peer and therefore sit behind their own door: Upload, Data Grid, the two text editors — Markdown Editor and Rich Text Editor — and Chat, which is built on them.

Nothing extra to install and nothing extra to import: import { Button, DataTable } from '@comitor/ui' is one line reaching across both tiers.

Eighteen of these arrived in 2.1.0, ported from Dice UI — Kanban, Stepper, Timeline, Time Picker, Tags Input, Mention, Action Bar, Phone Input, Mask Input, Rating, Circular Progress, Banner, Responsive Dialog, Tour, Key Value, Cropper, Scroll Spy and Badge Overflow. The package files them as Tier 1, next to the primitives; this site keeps them here because each one is an assembly of parts that has already made its decisions, which is what this tier is for. Same entry, same import line, no new peer to install. Three more followed in 3.15.0 — Color Picker, Color Swatch and Compare Slider. What each port fixed on the way in is in the changelog.

What belongs in this tier

A primitive renders a control. A composite has already made a decision — and the value is in the decision, not the markup.

Decisions, not markup

How far a page header is inset, what an empty table says, which contrast step status text runs at, whether a rising number is good news. Rebuilding a composite by hand means re-deciding all of it, differently, in every app.

No framework dependency

Not one composite imports next/*. Where one needs a router link it takes a LinkComponent prop instead. That is what keeps the main entry working under Vite, Storybook and plain tests — the framework lives in Tier 3.

Colour through roles

Every tone in this tier is a role token, never a hex or a raw scale, so it follows the palette in use. Text and standalone icons take the -ink step; the plain step only ever fills a background.

A list screen, assembled

Live, and made of nothing but Tier 2: stat tiles, a debounced search bar with chip filters, rows carrying an avatar and a status pill, and the empty state that appears when the filters exclude everything. Type duc or ha to see matchesSearch find a name spelled with diacritics. The page header is the one piece left out — this documentation page already owns its h1, and PageHeader renders another.

Open

2

Blocked

1

+1vs last week

Done

1

  • Nguyễn Đức Thành

    Draft the Q3 handover note

    Nguyễn Đức Thành · updated

    Open
  • Trần Minh Anh

    Reconcile August invoices

    Trần Minh Anh · updated

    Blocked
  • Lê Thu Hà

    Ship the payroll export

    Lê Thu Hà · updated

    Done
  • Phạm Quốc Bảo

    Review the vendor contract

    Phạm Quốc Bảo · updated

    Open

Page frame

The outline of a screen. Page Header and Route Tabs are static, so a server component can render them, and both take a LinkComponent prop rather than importing next/link — that is what keeps the main entry usable outside Next. Scroll Spy is the table of contents for a screen too long to take in at once.

Data

The list screen, broken into parts you can use separately. Sorting, paging and filtering are all controlled — the composite renders the state and tells you it changed; fetching stays yours.

Data Table

Sorting, row selection, pinned columns, skeleton loading and a built-in empty state. Every string, aria-label included, is overridable.

DataTable · DataTableColumnToggle

Data Grid

The other half of the same problem: a spreadsheet for screens people TYPE into. Virtualised rows, nine cell kinds, range selection and an Excel-shaped clipboard. The one composite with @tanstack/react-table as a peer, so it lives behind @comitor/ui/data-grid.

DataGrid · useDataGrid · DataGridFilterMenu · …

Table Pagination

The bar under a table. It derives the page count from total and pageSize itself, so the app and the control can never disagree by one page.

TablePagination · DEFAULT_PAGE_SIZE_OPTIONS

Search & Filter

Debounced search box, a slot for filter controls, an active-filter badge and a result count. FilterChips is the toggle row that usually sits in that slot.

SearchFilterBar · FilterChips

Action Bar

The floating toolbar for bulk actions while rows are selected. An Escape already used by a menu above it — or pressed mid-word while typing Telex — no longer closes it and wipes the selection.

ActionBar · ActionBarSelection · ActionBarItem · …

Kanban

Boards and pipelines with drag and drop by mouse, touch and keyboard — and a keyboard drop really changes the data, which upstream’s never did. Escape puts the card back where it started.

Kanban · KanbanBoard · KanbanColumn · KanbanItem · KanbanOverlay

Timeline

Activity feeds and record history, vertical or horizontal, each item completed, active or pending.

Timeline · TimelineItem · TimelineDot · TimelineContent · …

Gantt

A task Gantt and a project roadmap in one: bars by day, four kinds of dependency arrow with violations found for you, drag to move or stretch, a critical path the app computes, five scales from day to year — virtualised, and driven from the keyboard.

Gantt · GANTT_SCALES · GanttItem · GanttDependency · …

Tree View

An outline tree for a work breakdown: Tab and Shift+Tab indent and outdent, drag and drop changes the parent, 1.2.3 numbering and columns that roll up. One onMove for every change, and moveTreeItems to apply it — on the server too.

TreeView · moveTreeItems · computeTreeRollup · …

Stat Card

A KPI tile with an optional delta. The delta is coloured by direction times polarity, because a rising count of overdue tickets is not good news.

StatCard · statCardIconVariants

Status Pill

A status chip in three variants, coloured from your own status table. An unknown value is shown rather than swallowed, so stale enums stay visible.

StatusPill

Badge Overflow

A row of chips that folds into “+N” when it runs out of room — and tells a screen reader how many more, instead of reading out “plus N”.

BadgeOverflow

Color Swatch

Shows a stored colour — a square with a name a screen reader can read, a checkerboard under alpha, a struck-through square when there is none. For colour that is data, not interface colour.

ColorSwatch

Compare Slider

Before and after, one image over the other, split by a handle you drag, click or move with the keyboard — horizontal or vertical, still scrollable on a phone.

CompareSlider · CompareSliderBefore · CompareSliderAfter · CompareSliderHandle · CompareSliderLabel

Forms

Layout, the frame of a multi-step form, and the inputs too big to be primitives — pickers, tags, mentions, two text editors and the chat composer built on them, phone numbers, masked amounts, ratings, key–value rows, an image cropper, a colour picker. They compose with the react-hook-form binding at @comitor/ui/form, but do not require it.

Form Layout

A 12-column grid with grouped, optionally collapsible sections. FormField hands your control its id and aria wiring so you never re-type them.

FormSection · FormField

Stepper

The frame of a multi-step form: the steps, their state, Previous and Next — right from the server render when the steps are written out in JSX.

Stepper · StepperItem · StepperContent · StepperNext · useStepper · …

Upload

One row for an avatar or a logo, a tall drop zone for documents, and the headless hook underneath when you need a third shape. The one composite behind its own entry — @comitor/ui/uploader, with @uppy/core and @uppy/xhr-upload as optional peers; your own route receives an ordinary multipart POST.

ImageUploadField · FileUpload · useFileUpload

Cropper

Crop an avatar or a logo before it is uploaded — by pointer, wheel, touch or keyboard, with + and − to zoom.

Cropper · CropperImage · CropperArea · useCropper

Color Picker

Enter a colour as data — a brand colour on a client record — in hex, RGB, HSL or HSB, with a two-axis area that works from the keyboard. Not for choosing interface colours.

ColorPicker · ColorPickerTrigger · ColorPickerContent · ColorPickerArea · …

Combobox

Searchable select, single or multiple, matching Vietnamese text with or without diacritics. Async mode hands the query back so you can fetch.

Combobox · MultiCombobox · SELECT_EMPTY_VALUE

Tags Input

Free-form values as removable chips — labels, recipient lists. Every delete button is named after the tag it removes.

TagsInput · TagsInputItem · TagsInputInput · …

Mention

@-mentions in a composer or a comment box, with a named suggestion list driven from the keyboard.

Mention · MentionInput · MentionContent · MentionItem

Markdown Editor

A Linear-style editor that stores Markdown: write on the formatted document, / for blocks, @ to mention, a formatting bubble on selection, images, tables, highlighted code. Behind its own entry — @comitor/ui/editor, with Tiptap as optional peers.

MarkdownEditor · MarkdownEditorHandle

Rich Text Editor

The same core storing HTML, for published content: a toolbar pinned on top, underline, table cells that hold lists and images, and an allow-list schema that drops scripts and event handlers on the way in.

RichTextEditor · RichTextEditorHandle

Chat

The conversation frame for an AI assistant: a thread that sticks to the bottom, messages drawn by the read-only Markdown Editor with an “AI-generated” label, an agent's steps, numbered sources, and a composer where Enter sends. Behind its own entry — @comitor/ui/chat, with the editor's peers.

ChatThread · ChatMessage · ChatSteps · ChatSources · ChatComposer

Date Picker

Calendar plus typed input plus presets. A date-fns locale drives both the calendar and the field order, so en-US types MM/dd/yyyy without extra config.

DatePicker · DateRangePicker · dateFormatForLocale

Time Picker

Typed time segments plus a scrolling picker. The clock follows the locale — 2:30 PM in English, 14:30 in Vietnamese — while the value is always 24-hour.

TimePicker · TimePickerInput · TimePickerContent · …

Phone Input

A country picker and a number field that emits E.164 — a domestic 0912 345 678 becomes +84912345678 once a country is set. There is no default country: pass the workspace’s.

PhoneInput · PhoneInputCountrySelect · PhoneInputField · usePhoneInput

Mask Input

Currency, percentages and fixed patterns formatted as you type, with the raw value alongside. Reads each currency’s real decimals — VND has none — and defaults to USD, so pass yours.

MaskInput · MASK_PATTERNS · applyMask · …

Rating

Stars, halves included, as one radio group: exactly one star aria-checked, every star named, and the initial value submitted with its form.

Rating · RatingItem · useRating

Key Value

Editable key–value rows for custom fields, headers and metadata; focus lands on the new row and never drops to <body>.

KeyValue · KeyValueItem · useKeyValueStore · …

Feedback

What the app says back, and the surfaces it says it on. Each one is a decision already made about how confirmation, absence, progress and asynchronous news look across the ecosystem — and how a dialog behaves on a phone.

Empty State

Icon, title, description and an action. The shape every "nothing here yet" takes, so Tasks, CRM and HR agree on what absence looks like.

EmptyState · IconComponent

Banner

A dismissible, workspace-wide notice in Alert’s tones, alone or stacked in a priority queue — whose onDismiss no longer fires on every parent re-render.

Banner · Banners · useBanners · …

Circular Progress

Progress as a ring, with a value or without one, beside the bar-shaped Progress. The label sits under the ring instead of under the percentage.

CircularProgress · CircularProgressIndicator · …

Confirm Dialog

An alert dialog around one promise. Return a promise from onConfirm and it stays open and busy until settled — closing first is how you get double deletes.

ConfirmDialog

Responsive Dialog

A Dialog on desktop and a Drawer on phones from one set of parts, with focus moved inside in both — and no one-frame flash of the wrong one.

ResponsiveDialog · ResponsiveDialogContent · …

Command Palette

The Cmd/Ctrl+K surface: grouped commands, a recents group, per-item shortcut hints, and an async mode that turns off local filtering.

CommandPalette

Tour

A first-run walkthrough that spotlights real elements, scrolls inside the app’s own scroll area, and traps focus only while it is open.

Tour · TourStep · TourSpotlight · …

Toast

Stacked notifications with Comitor status icons. Documented with the primitives — SonnerToaster in the shell entry is the next-themes-aware variant.

Toaster · toast · toastPromise · dismissToast

Utilities

Small components that show up on nearly every screen. Small does not mean thin: each one carries a fallback, a locale or a hydration rule you would otherwise rediscover.

Pure modules

Not components: plain constants and functions with no React import at all, so they work in a route handler, a test or a server component as readily as in the browser.

Strings follow one provider, and every one of them moves

Every string in this tier comes from one table per component, down to the ones only a screen reader ever hears — the checkbox aria-label on a table row, the four sr-only labels on the pagination arrows, the name of the button that opens a calendar, the sentence read out while a card is dragged across a board. With no provider those tables are English; one ComitorLocaleProvider at the root switches all of them at once, which is the only way to reach the strings no screenshot shows. The language step of the installation guide covers the provider itself; below is how the tables behave once it is there.

English

this page: the site’s root provider

Tiếng Việt

<ComitorLocaleProvider locale="vi">

  • labels is a Partial<>. The component spreads it over the provider’s table, so overriding one key keeps the rest in the active language. The DEFAULT_*_LABELS constants are still exported — as the English tables, in modules with no 'use client', readable from server code — and the Vietnamese one for any component is comitorMessagesVi.<namespace>.
  • Keys that interpolate are functions. range(start, end, total, unitLabel), pageStatus(page, pageCount), filterCount(count), pageSizeSelect(unitLabel) — never a template with placeholders in it, because word order is not a constant across languages. resultSummary goes further and returns a ReactNode: the number needs its own element to hold tabular-nums so it stops jittering as the count changes. A function cannot be passed from a Server Component, so declare these in a client one.
  • A standalone prop beats labels. unitLabel, label and searchPlaceholder stay separate props and win. They vary by content — “contacts”, “invoices” — while the tables vary by language. One provider per app, one prop per table.
  • Plain string props follow the provider too. CopyButton, ConfirmDialog, InlineEdit, CommandPalette and AvatarGroup take single string props instead of a bag — leave one out and it reads the same table: “Copy” and “Confirm” in English, “Sao chép” and “Xác nhận” under locale="vi". FilterChips gets its chip text from the items you pass; the two strings the items cannot carry — label, the screen-reader name of the group (“Quick filters”), and allLabel, the reset chip that showAll adds (“All”) — come from the table.
  • Dates follow the same provider. DatePicker, RelativeTime and Calendar format with the provider’s date-fns locale — vi for Vietnamese, enUS for every other tag unless the provider is given a dateFnsLocale — and their own locale prop still overrides the dates of one instance. The typed format follows as well: MM/dd/yyyy in English, dd/MM/yyyy in Vietnamese.

Icons are components, not elements

Every icon prop in this tier — EmptyState, PageHeader, StatCard, FormSection, ConfirmDialog, Combobox, FilterChips, CommandPalette — is typed IconComponent and takes the component itself. A Lucide icon is a forwardRef object rather than a function, so the typeof check a component would reach for cannot tell it apart from an element that has already been rendered — both answer 'object'. Guessing would fail silently, so the type refuses the guess. When you need arbitrary content there is a separate slot for it — media on EmptyState, sparkline on StatCard.

tsx
import { Inbox } from 'lucide-react'
import { EmptyState } from '@comitor/ui'

// ✓ the component itself — the composite renders it and sets aria-hidden
<EmptyState icon={Inbox} title="No invoices yet" />

// ✗ an already-rendered element — a type error, on purpose
<EmptyState icon={<Inbox />} title="No invoices yet" />

Server or client

Eight composites carry no 'use client' of their own and render straight from a server component; everything else on this page, the eighteen 2.1.0 additions included, is a client component a server page can still render — it just cannot hand it a function. That is a deliberate cost decision, not an accident: a two-hundred-row list rendered with the Radix Avatar would be two hundred client islands, so LetterAvatar is built from scratch instead.

PageHeaderPageContainerRouteTabsStatCardStatusPillEmptyStateLetterAvatarIconAvatar

The constants are the other half of this. Default labels, date presets, STATUS_TONES, SELECT_EMPTY_VALUE and the text helpers all live in modules that deliberately have no 'use client' directive, even though the component beside them does. Everything exported from a client module arrives at a server component as a client-reference proxy that reads as undefined with no throw and no warning — and constants are precisely what server code reaches for, to spread over a default or build a list. Keeping them outside the boundary is what makes { ...DEFAULT_DATA_TABLE_LABELS } safe on a server page.

Two names to keep straight

  • FormField from @comitor/ui is the composite on this tier: a grid cell with a label, a description and an error, which hands your control its id and aria attributes. FormField from @comitor/ui/form is a different component with the same name — the shadcn wrapper around react-hook-form's Controller. They can be used together, never imported together.
  • Toaster and toast() from @comitor/ui are the everyday pair, with Comitor status icons and no next-themes dependency. SonnerToaster in @comitor/ui/shell is the thin theme-aware one that belongs in a root layout. Both are documented on the Toast page.

Accessibility across the tier

  • Roles describe what actually happens. RouteTabs is a <nav> of links marked with aria-current="page" rather than a tablist, because there is no tabpanel on the page for aria-controls to point at, and a roving tablist would swallow the arrow keys a list of links should leave alone. FilterChips uses aria-pressed on real buttons for the same reason: it toggles a condition, it does not swap a panel.
  • Colour is never the only signal, and the coloured thing is measured against what sits behind it. Status text runs on the -ink step because the fill step gives 2.69:1 for success and 3.44:1 for info on their own tints — the first below even the 3:1 non-text floor, both well under the 4.5:1 a chip's text is held to; the ink step clears 4.5:1 in both themes, 4.86:1 at its narrowest. Chip borders take the ink colour at full strength, since in the outline variant the border is the only boundary the chip has.
  • Icons are decoration until proven otherwise. IconComponent receives aria-hidden from the composite that renders it, and the meaning is carried by adjacent text. Where a control is icon-only — the pagination arrows, the clear button in the search box, the calendar trigger — the name lives in an sr-only span that the labels bag can translate.
  • LetterAvatar always emits the full name in an sr-only span. The initials themselves are aria-hidden — “NT” read aloud is noise — and an image branch uses alt="" so the name is not announced twice.
  • State that takes time says so. ConfirmDialog and InlineEdit stay open and busy while a returned promise settles, so the control cannot be fired twice. SearchFilterBar puts its result count in a role="status" region with a reserved height, so filtering announces the new count without the layout jumping underneath the pointer.
  • RelativeTime renders a real <time> with a machine-readable dateTime and an absolute timestamp in title — and falls back to a plain span for an invalid date, rather than publishing Invalid Date as structured data.
  • useIsMac returns false on the first render even on a Mac. The server cannot know the platform, and a mismatch makes React discard the subtree; showing “Ctrl” for one frame before it becomes “⌘” is the cheaper trade.

Next tier up

Composites fill a page. The frame around it — sidebar, header, workspace switcher, app launcher, and the four independent display axes — is Tier 3, which needs Next and next-themes and therefore lives behind its own entry.

Shell@comitor/ui/shell