A design system for everything we build.

@comitor/ui is the one package behind every Comitor product: primitives, composites, the application shell and whole screens to copy from, in English and Vietnamese.

pnpm add @comitor/ui

React 19 · Tailwind v4 · TypeScript strict · ESM only · MIT licensed

Three screens built from @comitor/ui components: a CRM pipeline in the application shell, with stat cards and their trend lines, and a deals table with status pills and owner avatars; a Tasks board column with priority pills and assignees; and a phone running a field-service app, with today’s visits on a timeline.

Three tiers, one package, nine doors

Each tier is built only from the ones below it, and the site follows the same split. The package is cut by what each part needs installed, not by topic: the main entry has no optional peer at all, so an app only installs what it imports.

Each count is the number of pages in that section of this site, read when the site is built.

  • @comitor/ui

    Tier 1 and Tier 2, ComitorLocaleProvider with the English and Vietnamese tables, cn and the tokens. Runs anywhere React does — Vite, Storybook, a test runner.

    Needs installed: No optional peer

  • @comitor/ui/shell

    Tier 3: AppShell and its parts, the theme and display-axis providers, and the brand marks.

    Needs installed: nextnext-themes

  • @comitor/ui/chart

    shadcn’s Chart primitives, plus ready-made line, bar and area charts and CHART_COLORS.

    Needs installed: recharts

  • @comitor/ui/data-grid

    DataGrid: virtualised rows, keyboard range selection, copy and paste with Excel, pinned columns, filters, sorting, undo.

    Needs installed: @tanstack/react-table

  • @comitor/ui/uploader

    FileUpload, ImageUploadField and useFileUpload — Uppy driven headless, so the markup stays the package’s.

    Needs installed: @uppy/core@uppy/xhr-upload

  • @comitor/ui/editor

    MarkdownEditor, which stores Markdown with a Linear-style bubble, and RichTextEditor, which stores HTML with a pinned toolbar — one Tiptap core underneath, and all thirteen @tiptap packages at one version.

    Needs installed: @tiptap/*lowlight

  • @comitor/ui/chat

    The frame of an AI assistant conversation: a thread that sticks to the bottom, messages drawn by the read-only MarkdownEditor with an “AI-generated” label, an agent's steps, sources, and a composer where Enter sends.

    Needs installed: @tiptap/*lowlight

  • @comitor/ui/form

    The react-hook-form binding: Form, FormField, FormMessage and the rest of the shadcn API.

    Needs installed: react-hook-form

  • @comitor/ui/tokens

    Token values as plain objects, for canvas, PDF and HTML email. Never for Tailwind code — use the class.

    Needs installed: No optional peer

Nine entries, plus styles.css. The installation guide has the peers for each, and the two names that really do mean different things at different doors. Installation

The real components, running on this page

Nothing below is a screenshot. Every tile imports from the published @comitor/ui 3.17.3 — edit the grid, drag the cards, hover the chart, tab to the avatars.

A spreadsheet for bulk entry. Double-click a cell to edit it, drag across cells to select a range, copy it, and paste straight from Excel.

Line, bar and area charts on the chart tokens, with tooltips in the reader’s number format.

Every tone in all three variants. Text and borders use each colour’s -ink step, measured at 4.5:1 or better in both themes.

soft
DraftFeaturedPaidDue soonSevereOverdueSent
outline
DraftFeaturedPaidDue soonSevereOverdueSent
dot
DraftFeaturedPaidDue soonSevereOverdueSent

Drag a card with a mouse or a finger — or focus it, press Space, and move it with the arrow keys. Every move is announced to screen readers.

To do2 cards

Quote the Lotus Office Park chargers

HighQuân Đặng

Book the pole inspection crane

LowLinh Võ
In progress2 cards

Onboard Linh Võ to the Field app

MediumHà Lê

Renew the portal’s TLS certificate

HighĐức Nguyễn
Done1 card

Publish the Q4 tariff sheet

MediumMai Trần

Initials take the family and the given name — Nguyễn Văn An is NA, not NV — and the tone comes from the name, so one person is one colour on every screen.

Nguyễn Văn AnNguyễn Văn AnNguyễn Văn AnNguyễn Văn AnNguyễn Văn Anxs to xl
TasksCRMFieldChatIconAvatar, one tone per app key

More than a component kit

The expensive part of a design system is not a button. It is language, colour, accessibility and the server boundary — decided once, written down, and checked before every release.

One provider for language and format

English is the default since 3.0.0, and Vietnamese is built in. One ComitorLocaleProvider at the root switches every string the package renders — visible text, accessible names, drag-and-drop announcements — and every format behind them: dates, numbers, relative time, the 12- or 24-hour clock.

Any other language starts from the English table and passes messages. Region stays separate: the reader’s language never decides a workspace’s currency or country.

Invoice INV-0431

Created

Colour is a contract

Every hue ships as three tokens, and the suffix says where each may appear. Components never name a raw colour, so light, dark and the high-contrast palette all follow from one set of classes.

--x
Fills a background, solid or as a tint.
--x-foreground
Text and icons sitting on that solid fill.
--x-ink
The same colour on the page: text, lone icons, borders.

Measured, not eyeballed: on white, text-success reads 3.13:1, under AA; text-success-ink reads 6.52:1. The default palette’s few known gaps are listed on the Colors page, and the high-contrast palette is the one the build fails over.

Five display axes, set by each user

Light or dark, contrast, the density of rows, the size of text and the brand colour — independent of each other, one attribute on <html> each, remembered per device. Try them: they apply to this whole site.

Theme

Darkens borders, switches and secondary text. Helpful on dim screens or outdoors.

Reduces vertical spacing between rows in tables, lists and menus.

Font size
Theme color

Nothing ships ungated

Every control works from a keyboard, every name a screen reader speaks can be translated, and where a component knowingly falls short its page says so. Before a version reaches npm, 15 steps have to pass:

  1. typecheckStrict TypeScript over the whole source.
  2. test:i18nNo Vietnamese text outside the Vietnamese tables.
  3. buildOne ES module per source file, so each keeps its own directive.
  4. verify:directivesEvery “use client” survives the build; no plain constant sits in a client file.
  5. smoke:entriesAll nine entries load in plain Node, through the exports map — and resolve under its require condition too, the path webpack takes for next/dynamic.
  6. check:exportspublint and Are the Types Wrong on the published shape.
  7. test:nav-activeThe sidebar lights the right row, queries included.
  8. test:grid-contractNo layout class that silently breaks a grid it is dropped into.
  9. test:grid-renderDataGrid rendered for real with react-dom/server.
  10. test:editor-markdownDocument → Markdown → document comes back unchanged, and the HTML the editors write keeps stored values, not drawn ones.
  11. test:gantt-treeGantt's day arithmetic, dependency rules and arrow routes, TreeView's move contract, roll-ups and numbering, and both rendered on the server in two languages.
  12. test:dom-renderMounted and re-rendered with react-dom/client on happy-dom — what is right on first render and wrong after a prop changes, like an avatar losing its image.
  13. test:a11y-gateThe contrast gate still catches every case that once slipped past it.
  14. lint:a11yContrast measured on compiled CSS, in both palettes and both themes.
  15. lint:palettes

Built for Server Components

The package ships one module per source file, so "use client" sits only in the files that need it and every constant lives in a module a Server Component can read. What cannot cross the boundary fails loudly instead of arriving as undefined.

This page is one: the counts near the top come from StatCard, rendered on the server. A prop that carries a function — a formatter, a date-fns locale — goes through a small client file of your own.

app/products/[id]/page.tsx
// app/products/[id]/page.tsx — a Server Component
import { Rating } from '@comitor/ui'

// ✓ strings, numbers and booleans cross to a client component
<Rating value={4.5} step={0.5} readOnly aria-label="Rated 4.5 out of 5" />

// ✗ a function does not — the page fails to render with
//   "Functions cannot be passed directly to Client Components…"
<Rating value={4.5} readOnly itemLabel={(value) => `${value} étoiles`} />

Whole screens to copy from

Ten product screens assembled from the package, live, with the code beside each one. Start from the one closest to yours.

  • Web App

    The whole frame: shell, workspace notices and a first-run tour, KPI cards, a members table with bulk actions, create and edit flows, setup and settings.

    AppShellDataTableTourStepper

  • CRM

    A contact grid that filters, sorts and edits in place, the record behind the focused row, the deal board and the numbers it rolls up to.

    DataGridKanbanKeyValuePhoneInput

  • Tasks

    A task tracker: an editable task grid with a bulk-action bar, details edited in place, @mentions, a drag-and-drop board and a roadmap.

    DataGridActionBarKanbanMention

  • HR

    Nine screens of people management: dashboard, directory and profile, recruiting board and scorecard, onboarding, leave, attendance and payroll.

    KanbanStepperDateRangePickerTimePicker

  • Commerce

    A storefront and its back office: product page, reviews, cart and a checkout that validates each step, then orders, inventory and a product editor.

    StepperRatingDataGridCropper

  • Email

    A three-pane mail client: folders, a filterable list whose labels fold into +N, a collapsible thread, bulk actions and a composer with send-later.

    BadgeOverflowActionBarTagsInputTimePicker

  • Chat

    A team inbox: filtered conversations, a grouped transcript, a composer with @mentions and send-later, presence, typing and delivery.

    MentionTimePickerSearchFilterBar

  • Social

    An invite-only network: a composer that names its audience, a feed, a member directory, private spaces, an activity inbox and privacy controls.

    MentionCropperTagsInput

  • Mobile

    A dispatch app at 375px: the sidebar handing over to MobileMenu, lists in two densities, detail in a drawer, sign-in, and a long form cut into steps.

    MobileMenuResponsiveDialogStepperInputOTP

  • Landing Page

    Fourteen bands that stack into a public website, from the navbar to the footer — mostly layout on purpose, reaching for the package where it has the part.

    BannerNavigationMenuAccordionRating

From an empty Next app to the first component

Three steps take an empty Next app to its first rendered component. The installation guide has the rest — the optional peers, the PostCSS config, the language in a real product — and the mistake each step prevents.

  1. 01

    Install the package and its peers

    React 19 and Tailwind v4 are required; next, recharts and the rest only for the entry that needs them.

    terminal
    pnpm add @comitor/ui
    pnpm add react react-dom lucide-react
    pnpm add -D tailwindcss @tailwindcss/postcss
    
    # optional peers — only for the entries you import (see Installation)

    02

    Load the stylesheet

    Tailwind first, then the package. The tokens and both palettes arrive with it.

    app/globals.css
    /* app/globals.css — exactly these two lines, in this order */
    @import "tailwindcss";
    @import "@comitor/ui/styles.css";
  2. 03

    Wire the root layout

    The font, the language, and the display axes — independent providers, so the nesting order does not matter.

    app/layout.tsx
    // app/layout.tsx — a Server Component
    import { ComitorLocaleProvider } from '@comitor/ui'
    import { ContrastProvider, DensityProvider, FontSizeProvider, ThemeProvider } from '@comitor/ui/shell'
    import { Inter } from 'next/font/google'
    import './globals.css'
    
    // The package consumes --font-inter; it never loads a font itself.
    const inter = Inter({
      subsets: ['latin', 'vietnamese'],
      weight: ['400', '500', '600', '700', '800'],
      display: 'swap',
      variable: '--font-inter',
    })
    
    export default function RootLayout({ children }: { children: React.ReactNode }) {
      return (
        // suppressHydrationWarning is required: the display providers touch
        // <html> before React hydrates, so server and client HTML never match.
        <html lang="vi" suppressHydrationWarning className={inter.variable}>
          <body className="min-h-dvh bg-background font-sans text-foreground antialiased">
            {/* No provider renders English. A product passes the user's own
                language here rather than pinning one. */}
            <ComitorLocaleProvider locale="vi">
              <ThemeProvider>
                <ContrastProvider>
                  <DensityProvider>
                    <FontSizeProvider>{children}</FontSizeProvider>
                  </DensityProvider>
                </ContrastProvider>
              </ThemeProvider>
            </ComitorLocaleProvider>
          </body>
        </html>
      )
    }

@comitor/ui 3.17.3

An Avatar whose image is taken away shows its initials again.

1 fix · nothing breaks

Read the changelog