Get started
Installation
The whole design system is one public npm package. Install it in any React 19 + Tailwind v4 project and every component on this site is available — no registry, no licence key, no account.
1. Install
The optional peers are per entry point — skip the ones you will not import.
# the package
pnpm add @comitor/ui
# required peers
pnpm add react react-dom lucide-react
pnpm add -D tailwindcss @tailwindcss/postcss
# optional peers — only for the entry you use
pnpm add next next-themes # @comitor/ui/shell
pnpm add recharts # @comitor/ui/chart
pnpm add react-hook-form # @comitor/ui/form
pnpm add @uppy/core @uppy/xhr-upload # @comitor/ui/uploader| Prop | Type | Default | Description |
|---|---|---|---|
react, react-dom | ^19.0.0 | required | Always. |
tailwindcss | ^4.1.0 | required | Always — styles.css only means anything once Tailwind v4 compiles it. |
lucide-react | >=0.540.0 | required | Always. Shared so the bundle never carries two icon sets. Version 1.x dropped brand icons. |
next | >=15 | optional | Only for @comitor/ui/shell. |
next-themes | >=0.4 | optional | Only for @comitor/ui/shell. Context singleton — app and package must resolve to one copy. |
recharts | >=2.15.0 | optional | Only for @comitor/ui/chart. Typed for both 2.x and 3.x. |
react-hook-form | ^7.54.0 | optional | Only for @comitor/ui/form. Context singleton, same caveat as next-themes. |
@uppy/core, @uppy/xhr-upload | ^6.0.0 | optional | Only for @comitor/ui/uploader. Uppy is driven headless — the markup stays ours. |
2. Load the stylesheet
This is the step that goes wrong most often, and it fails in a way that looks like something else entirely.
/* app/globals.css — exactly these two lines, in this order */
@import "tailwindcss";
@import "@comitor/ui/styles.css";Order matters, and the file goes nowhere else.
- Tailwind must be imported first. The package stylesheet is mostly
@theme,@custom-variantand@source— at-rules that mean nothing unless Tailwind compiles them. Reverse the two lines and every utility disappears. - Never import it from
layout.tsx. The CSS variables would still land, so the colours look roughly right, but no utility gets generated and the layout breaks. - Do not add tokens, hex values or utilities of your own to this file. The package is the single source of truth for all of them.
/* postcss.config.mjs — Tailwind v4 needs one plugin and nothing else.
No autoprefixer, no postcss-import; v4 absorbed both. */
const config = {
plugins: {
"@tailwindcss/postcss": {}
}
}
export default configWhat that import does to your markup
Everything above is about getting the file loaded. Once it is, its @layer base block styles your elements too, not only the ones that come out of the package — four rules that no class of yours asked for and none of which announce themselves.
/* @comitor/ui/styles.css — the whole @layer base block */
@layer base {
/* Default border colour for EVERY element. */
* { @apply border-border outline-ring/50; }
body { @apply bg-background text-foreground font-sans antialiased; }
/* Tailwind v4 dropped this from preflight; the package puts it back. */
button:not(:disabled):not([aria-disabled="true"]),
[role="button"]:not(:disabled):not([aria-disabled="true"]),
label[for] { cursor: pointer; }
/* The focus signature: 2px, offset 2px, --ring. */
:focus-visible { @apply outline-2 outline-offset-2 outline-ring; }
}- The
*rule is the one that bites.--borderis the decorative rule colour, 1.16:1 and deliberately under every contrast threshold — so a control that writesborderwithout naming a colour gets a border you cannot see, and nothing reports it. Controls always name one:border-input, orborder-choice-edgefor a checkbox or radio. See Colours. - Focus is a page-wide signature. Every focusable element gets a 2px
--ringoutline at 2px offset, with no class on your side. Components that follow a different convention opt out explicitly — menu and command rows swap it for an--accentbackground, because focus there moves under the arrow keys. bodycarries the background, text colour, font and antialiasing, so a page that renders nothing but text is already on-palette.
The cursor rule, as a worked example (1.7.1)
Tailwind v3’s preflight carried button, [role="button"] { cursor: pointer }. Tailwind v4 dropped it to follow the browser default, so on v4 every <button> computes cursor: default — no build error, no warning, nothing to grep for. On one real toolbar the <a> had the hand and the two <button>s beside it did not, though all three looked identical; a ghost button on a flat background then reads as a label. The package restores it in the base layer rather than in Button, so the raw <button>s you write are covered by the same rule — sprinkling cursor-pointer by hand only holds until the next component someone writes.
| Markup | Computed cursor |
|---|---|
<button> | pointer |
<button disabled> | default |
<button aria-disabled="true">1.7.0 gave pointer | default |
<button role="button" disabled>1.7.0 gave pointer | default |
<div role="button"> | pointer |
<div role="button" aria-disabled="true"> | auto |
<label for="email"> | pointer |
<label> (no for) | default |
<fieldset disabled> <button> | default |
Disabled is excluded twice because that is two tests, not one — and both tests sit on both selectors. :not(:disabled) covers native controls; :not([aria-disabled="true"]) covers the Radix ones, which stay focusable on purpose and therefore never carry the attribute the first test looks for. The off values differ only because the elements have different user-agent defaults — default for a button, auto for a div; neither is a hand. A control inside <fieldset disabled> matches :disabled per the HTML spec, so a read-only screen built that way invites nothing.
1.7.0 paired one test per selector, and that was the bug. It shipped button:not(:disabled), [role="button"]:not([aria-disabled="true"]) — so aria-disabled only ever excluded the [role="button"] branch, and a plain <button aria-disabled="true"> — the exact Radix shape the second test was written for — kept the hand. 1.7.1 puts both tests on both selectors.
Worth knowing why it survived a measurement pass: the six cases checked at the time used <div role="button" aria-disabled="true"> for the aria branch — the one selector that did carry the guard. Every case chosen was a case that passed. The table above is nine cases for that reason, and the real-world one it missed was Calendar: react-day-picker marks the month arrows aria-disabled with no disabled attribute, so at the ends of a bounded range they invited a click that did nothing.
label[for] is in because a label with a target is a control by proxy: clicking it focuses or toggles the thing it names. A bare <label> points at nothing and does nothing, so it deliberately gets no rule. This page is the proof: it loads the same stylesheet, so hovering any button here already gives you the hand.
3. Load the font
The package points --font-sans at --font-inter and expects your app to define it. Leave it undefined and the whole declaration is invalid at computed-value time — the fallbacks after it are never reached.
// app/layout.tsx
import { Inter } from 'next/font/google'
// The package consumes --font-inter; it never loads a font itself.
// The vietnamese subset is not optional — without it Vietnamese
// diacritics fall back to a system font and the text visibly shifts.
const inter = Inter({
subsets: ['latin', 'vietnamese'],
weight: ['400', '500', '600', '700', '800'],
display: 'swap',
variable: '--font-inter',
})4. Wire the providers
Four independent display axes, all at the root layout, all per-user and per-device. Each renders its own anti-flash script — you add nothing to <head>. Only the theme axis is strictly required; drop the other three if you do not want to offer them.
// app/layout.tsx
import { ContrastProvider, DensityProvider, FontSizeProvider, ThemeProvider } from '@comitor/ui/shell'
import './globals.css'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
// suppressHydrationWarning is required: all four scripts touch <html>
// before React hydrates, so server and client HTML never match.
<html lang="en" suppressHydrationWarning className={inter.variable}>
<body className="min-h-dvh bg-background font-sans text-foreground antialiased">
<ThemeProvider>
<ContrastProvider>
<DensityProvider>
<FontSizeProvider>{children}</FontSizeProvider>
</DensityProvider>
</ContrastProvider>
</ThemeProvider>
</body>
</html>
)
}Not on Next? Skip this step and the /shell entry entirely — the main entry has no framework dependency. See Display Axes for what each one does.
5. Render something
That is the whole setup. Every component comes from the same import.
Invite a teammate
They will get access to this workspace.
The six entry points
The split is not tidiness. The package builds one ES module per source file, so a barrel that re-exported a file importing next would force every consumer to install Next or fail at resolve time. Each optional peer therefore sits behind its own door.
| Prop | Type | Default | Description |
|---|---|---|---|
@comitor/ui | Tier 1 + Tier 2 | — | Primitives, composites, cn, and the design tokens. This is the one you import from most. |
@comitor/ui/shell | Tier 3 | next, next-themes | AppShell, Sidebar, AppHeader, the four display-axis providers, and the brand marks. |
@comitor/ui/chart | charts | recharts | Chart* (shadcn) and Comitor*Chart plus CHART_COLORS. |
@comitor/ui/form | forms | react-hook-form | The shadcn binding: Form, FormField (a Controller wrapper), FormMessage… |
@comitor/ui/uploader | uploads | @uppy/core, @uppy/xhr-upload | FileUpload, ImageUploadField and the useFileUpload hook — Uppy driven headless, so the markup is ours. |
@comitor/ui/tokens | tokens | — | Token values as plain TS objects, for canvas, PDF and email. Never for Tailwind code — use the class. |
// Tier 3 — the whole application frame in one component.
import { AppShell } from '@comitor/ui/shell'
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<AppShell nav={NAV} workspaces={WORKSPACES} apps={APPS} user={USER}>
{children}
</AppShell>
)
}Three things worth knowing early
Twenty names appear at more than one entry
export * only errors on a duplicate name within the same entry. A name exported from two different entries is completely silent — no build error, no warning, and the two imports read identically at the call site. So the only way to answer “is this one binding or two?” is to look it up. Twenty names are in that position, and exactly two of them are a real fork.
| Name | At @comitor/ui | Also at | Same binding? |
|---|---|---|---|
FormFieldFormFieldProps | The Tier 2 composite — a 12-column grid holding label, control, description and error. | @comitor/ui/formThe wrapper around react-hook-form’s Controller, which is the shadcn API. | No — two different components |
normalizeVigetVietnameseInitials | Defined once, in the Tier 2 text utilities. | @comitor/ui/shellRe-exports that exact function. The shell keeps no copy of its own. | Yes — one function body |
goldashbrandNeutralaccentaccentInkaccentForegroundsemantichighContraststatuschartlayouttypographyThemeModegetAvatarToneColorsgetAvatarToneIndexAvatarToneColors | The main entry re-exports the token module whole. | @comitor/ui/tokensThe same module, narrowed to just the constants for places that have no Tailwind. | Yes — one module, two doors |
The rule for using it: take the binding at @comitor/ui day to day, and reach for /form only once the app already uses react-hook-form. Only FormField and FormFieldProps are two implementations — never import both. Every other name in the table is one binding seen through two doors, so which door you use changes nothing but your import line. That is worth keeping: getVietnameseInitials once had two bodies that upper-cased differently, and the same name quietly returned different initials depending on where you imported it from.
Two near-misses that are not on the list. Toaster and SonnerToaster are two different components with two deliberately different names, so they can never collide — the first takes a theme prop, the second reads next-themes itself. And useIsMac ships from @comitor/ui only; /shell uses it internally but does not re-export it.
Default strings are Vietnamese
Including aria-label and sr-only text, which is the half you will not notice by looking. Override through labels.
import { TablePagination } from '@comitor/ui'
// Every default string in the package is Vietnamese, sr-only text included.
// Override per key — the component always spreads over its defaults, and keys
// that interpolate a number are FUNCTIONS, because word order differs by language.
const EN = {
region: 'Pagination',
unitLabel: 'items',
pageSizePrefix: 'Show',
pageSizeSelect: (unit) => `${unit} per page`,
range: (start, end, total, unit) => `${start}–${end} of ${total} ${unit}`,
pageStatus: (page, count) => `Page ${page} of ${count}`,
firstPage: 'First page',
previousPage: 'Previous page',
nextPage: 'Next page',
lastPage: 'Last page',
}
<TablePagination {...paging} labels={EN} unitLabel="invoices" />All six entries load under plain Node ESM
Not just inside a bundler. Every entry resolves through the package’s exports map under Node on its own, which is what you are standing on any time the package is used outside a build: a script that renders an email or a PDF from /tokens, a test runner in a node environment, a codegen step. Import by package name rather than reaching into dist/ — the deep path skips the exports map and is not a supported entry.
// A plain Node script — no bundler, no transpile step, no framework.
// Import by package NAME so resolution goes through the exports map;
// reaching into dist/ bypasses it and is not supported.
import { semantic, gold } from '@comitor/ui/tokens'
import { getVietnameseInitials } from '@comitor/ui'
// /shell resolves too, and it is the one you would bet against:
// it reaches for next/*. Every internal specifier in the package
// carries its .js extension, which is what Node demands and what
// a bundler would have papered over.
import { isNavItemActive } from '@comitor/ui/shell'
const cell = `<td style="background:${semantic.light.card};color:${gold[500]}">`
console.log(getVietnameseInitials('Nguyễn Văn An')) // NAThis is a property worth stating because it is so easy to lose. A bundler supplies a missing file extension; Node does not, so a single internal specifier written as next/link instead of next/link.js is invisible in every Next app and fatal in a script — ERR_MODULE_NOT_FOUND at import, before any of your code runs. A publish gate loads all six entries by name and fails the release if any of them will not come up, so this holds for the version you install rather than only for the one that was tested.