Composites
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.
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.
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.
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ànhOpen
Draft the Q3 handover note
Nguyễn Đức Thành · updated
- Trần Minh AnhBlocked
Reconcile August invoices
Trần Minh Anh · updated
- Lê Thu HàDone
Ship the payroll export
Lê Thu Hà · updated
- Phạm Quốc BảoOpen
Review the vendor contract
Phạm Quốc Bảo · updated
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.
Breadcrumb, title, description and an actions slot; owns the page h1. PageContainer is the matching content frame in four widths.
PageHeader · PageContainer
A tab strip whose items are real routes: a <nav> of links carrying aria-current, not a Radix tablist. Looks identical to Tabs because it reads the same class table.
RouteTabs
An in-page table of contents that follows the reader. Links carry aria-current, and it watches whichever scroll container you give it — AppShell’s <main> included, not only the window.
ScrollSpy · ScrollSpyNav · ScrollSpyLink · ScrollSpySection
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.
Sorting, row selection, pinned columns, skeleton loading and a built-in empty state. Every string, aria-label included, is overridable.
DataTable · DataTableColumnToggle
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 · …
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
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
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 · …
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
Activity feeds and record history, vertical or horizontal, each item completed, active or pending.
Timeline · TimelineItem · TimelineDot · TimelineContent · …
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 · …
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 · …
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
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
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
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
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.
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
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 · …
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
Crop an avatar or a logo before it is uploaded — by pointer, wheel, touch or keyboard, with + and − to zoom.
Cropper · CropperImage · CropperArea · useCropper
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 · …
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
Free-form values as removable chips — labels, recipient lists. Every delete button is named after the tag it removes.
TagsInput · TagsInputItem · TagsInputInput · …
@-mentions in a composer or a comment box, with a named suggestion list driven from the keyboard.
Mention · MentionInput · MentionContent · MentionItem
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
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
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
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
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 · …
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
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 · …
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
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.
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
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 · …
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 · …
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
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 · …
The Cmd/Ctrl+K surface: grouped commands, a recents group, per-item shortcut hints, and an async mode that turns off local filtering.
CommandPalette
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 · …
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.
Initials taken the Vietnamese way (first letter of the family name plus first letter of the given name) over a tone derived from the name itself.
LetterAvatar · AvatarGroup · getAvatarToneClasses
An app or workspace icon in a coloured identity tile — the sibling of LetterAvatar. The tone is derived from the key, so a new product has a colour with no configuration, and tone overrides it by hand.
IconAvatar · AVATAR_TONE_NAMES
Copy to clipboard with a confirmed state, and a textarea fallback for the contexts where navigator.clipboard is simply absent.
CopyButton
A self-updating <time> element. Pass a locale for anything but Vietnamese, and a now to freeze the reading for fixtures and snapshots.
RelativeTime
Click text, get an input, Enter to save, Escape to cancel. An async onSave keeps the field in place until it resolves instead of flashing the old value.
InlineEdit
Renders "mod+k" as ⌘K or Ctrl+K. useIsMac reports false on the first render on purpose — the server cannot know, and guessing costs you the hydration. With useKeySequences and the ? sheet, KeyboardShortcutsDialog.
KeyboardHint · useIsMac
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.
The generic status contract. Your app declares its own states; the package supplies the type and a tone table of token classes. Documented with StatusPill.
STATUS_TONES · getStatusConfig · getStatusClasses · getStatusLabel · sortByStatusPriority
One normalisation rule for the whole package: strip Vietnamese tones, lowercase, trim. Every search box in the ecosystem matches the same way.
removeVietnameseTones · normalizeVi · matchesSearch · getVietnameseInitials · truncate
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">
labelsis aPartial<>. The component spreads it over the provider’s table, so overriding one key keeps the rest in the active language. TheDEFAULT_*_LABELSconstants are still exported — as the English tables, in modules with no'use client', readable from server code — and the Vietnamese one for any component iscomitorMessagesVi.<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.resultSummarygoes further and returns aReactNode: the number needs its own element to holdtabular-numsso 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,labelandsearchPlaceholderstay 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,CommandPaletteandAvatarGrouptake 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” underlocale="vi".FilterChipsgets 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”), andallLabel, the reset chip thatshowAlladds (“All”) — come from the table. - Dates follow the same provider.
DatePicker,RelativeTimeandCalendarformat with the provider’s date-fns locale —vifor Vietnamese,enUSfor every other tag unless the provider is given adateFnsLocale— and their ownlocaleprop still overrides the dates of one instance. The typed format follows as well:MM/dd/yyyyin English,dd/MM/yyyyin 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.
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.
PageHeaderPageContainerRouteTabsStatCardStatusPillEmptyStateLetterAvatarIconAvatarThe 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
FormFieldfrom@comitor/uiis 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.FormFieldfrom@comitor/ui/formis a different component with the same name — the shadcn wrapper around react-hook-form'sController. They can be used together, never imported together.Toasterandtoast()from@comitor/uiare the everyday pair, with Comitor status icons and no next-themes dependency.SonnerToasterin@comitor/ui/shellis 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.
RouteTabsis a<nav>of links marked witharia-current="page"rather than a tablist, because there is no tabpanel on the page foraria-controlsto point at, and a roving tablist would swallow the arrow keys a list of links should leave alone.FilterChipsusesaria-pressedon 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
-inkstep 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.
IconComponentreceivesaria-hiddenfrom 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 ansr-onlyspan that thelabelsbag can translate. LetterAvataralways emits the full name in ansr-onlyspan. The initials themselves arearia-hidden— “NT” read aloud is noise — and an image branch usesalt=""so the name is not announced twice.- State that takes time says so.
ConfirmDialogandInlineEditstay open and busy while a returned promise settles, so the control cannot be fired twice.SearchFilterBarputs its result count in arole="status"region with a reserved height, so filtering announces the new count without the layout jumping underneath the pointer. RelativeTimerenders a real<time>with a machine-readabledateTimeand an absolute timestamp intitle— and falls back to a plain span for an invalid date, rather than publishingInvalid Dateas structured data.useIsMacreturnsfalseon 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