Skip to content
onsystem

Docs

Configuration

Most projects need no config: onsystem finds the design system the way your app imports it. Other layouts take a few globs.

Zero config

Without a config file (or with one that leaves components unset), the server looks for the design system in this order, and inspect prints what it found on its detected line:

  1. `components.json` (shadcn/ui): the ui alias, resolved through tsconfig paths (@/registry/new-york-v4/ui) or a workspace package's exports (@workspace/ui/components in shadcn's monorepo templates), and tailwind.css for tokens.

  2. The root is a design-system package (when it has no components.json): its package.json exports lead to three or more component files ("./button": "./src/components/button.tsx" or "./components/*": "./src/components/*.tsx"). An export that points at a barrel ("./components/button": "./components/button/index.ts", as @calcom/ui has them) stands for the .tsx files the barrel re-exports, through export *, export { … } from and imported bindings exported again. An export built to dist/ is read from its source: the entry of a tsup, tsdown or Vite config (read as text, never run), else the same path under src/, and . from src/index.*, so Dub's @dub/ui ("import": "./dist/index.mjs") is read from src/index.tsx and what it re-exports. Exported stylesheets that exist are read as tokens, else src/globals.css and the like.

  3. A dependency named like a design system (@acme/ui, @acme/ui-kit, @acme/design-system, acme-ui) that resolves to workspace sources, through a node_modules link or the workspace's package globs (pnpm, npm, Yarn and Bun). It is read the same way, or through its own components.json, and added to what components.json found: Cal.com's app has @coss/ui in its components.json and imports @calcom/ui too, and both are read. Without a components.json, the app's own components/ui is kept alongside it. A package without exports that apps import by path, as Documenso imports @documenso/ui/primitives/button, is read from the files the app's code imports, each suggested with the specifier it uses, with a stylesheet imported from it as the theme.

  4. A flat `src/` of components that wrap a primitives library, the way React Aria's Tailwind starter ships them (src/Button.tsx, src/Checkbox.tsx). It takes all of: a dependency on react-aria-components, react-aria, Radix, Base UI, Headless UI or Ark UI; no app entry (main.tsx, App.tsx, index.tsx) or app, pages or routes folder in src/; and at least five PascalCase .tsx files at its top level, four in five of which import that library. An app's src/ of pages and features does not qualify. A stylesheet such as src/index.css is read as the theme.

With more than one design system, the one the app's code imports most is the primary: its components win where two share a name (Button) and are the ones suggested for native elements, while the others are still known, so an import from either is checked against its own component. inspect shows them on its detected line, with their import counts: primary: workspace package @calcom/ui (993 imports); also: components.json (ui: @coss/ui/components → …) (140 imports).

A candidate whose files cannot be found is skipped. Components found through exports are suggested with the specifier apps use (import { Button } from "@acme/ui/button", or @acme/ui for a package that exports a barrel). Detected stylesheets replace the stylesheet guesses below, while *.tokens.json files are still read, and Markdown next to detected components counts as docs. While serving, edits to components.json, package.json or the tsconfig re-run detection, and workspace packages outside the root are watched like local folders.

Monorepo roots

A workspace root (a pnpm-workspace.yaml, workspaces in package.json for npm, Yarn and Bun, a lerna.json, or an nx.json, so Turborepo and Nx repositories too) with no design system of its own is read as the monorepo it is. Every workspace package (and with Nx, every folder with a project.json) goes through zero config, or its own config file, and each one where components are found is a project: the apps that use a design system, and the design-system packages themselves. inspect at the root lists each project with what was found in it, and the packages without one.

Each file is checked by its own project: the nearest folder at or above it with a config, a components.json, a package.json or a project.json, the same folder the Claude Code hook runs the check from. check . at the root reports exactly what check . in each of those folders would, file for file; the corpus checks this on Documenso, Dub, openstatus and Cal.com. Each folder's own design-system files are skipped, and one baseline at the root covers every project. The MCP server started there takes a path to pick the project (tools), and without one lists every project's components together, each with its package.

The root stays one project, as before, when it has a design system of its own (a components.json, for one), when its config sets components, or when --config, --components, --tokens or --docs is passed. A config file at the root that does not set components applies to every project that has no config of its own, with its paths and overrides globs still relative to the root ("files": ["apps/admin/**"]) and exclude patterns that start with **/ or with the project's folder. The list of projects is read when the server starts.

Config file

onsystem.config.json (or .mjs, .js, or .ts on Node.js 22.18 or later) in the project root. Every field is optional; the JSON Schema gives editor completion. npx onsystem init writes one: the folders zero config found, or the default components/ui folders when they hold the components, spelled out (it leaves components to zero config when a package's exports name more than a few files or import paths a config cannot spell), or your answers to where the components, tokens and import path are when it found nothing. It never replaces an existing config without --force. At a monorepo root it writes a config with only $schema, for rules all projects share.

{  "$schema": "https://unpkg.com/onsystem/schema.json",  "components": ["src/components/**/*.tsx"],  "exclude": ["**/*.stories.tsx", "**/*.test.tsx"],  "tokens": ["src/styles/globals.css", { "path": "tokens/*.tokens.json", "prefix": "acme" }],  "docs": ["docs/components/**/*.mdx"],  "importPath": "@acme/ui",  "elements": { "a": "Link" },  "rules": {    "no-hardcoded-spacing": "error",    "no-hardcoded-color": ["error", { "allow": ["#fff"] }],    "icon-button-accessible-name": "off"  },  "overrides": [    { "files": ["app/legacy/**", "**/*.generated.tsx"], "rules": { "no-hardcoded-color": "warn" } }  ]}
  • components

    Default
    Detected, else components/ui/**/*.{tsx,jsx}, src/components/ui/**/*.{tsx,jsx}
  • tokens

    Default
    Detected, else app/globals.css, src/app/globals.css, styles/globals.css, src/styles/globals.css, src/index.css, app/app.css, **/*.tokens.json
  • docs

    Default
    docs/components/**/*.{md,mdx} and .md/.mdx files next to the components
  • importPath

    Default
    Inferred from package exports (@acme/ui/button), then tsconfig paths (@/components/ui/button). @acme/ui/{path} suggests each file by its path in its package
  • includeDesignSystem

    Default
    false: check skips the component files themselves (CI)
  • tsconfig

    Default
    tsconfig.json in the root
  • overrides

    Default
    None. Each entry's rules apply over rules to the files its files globs match, relative to the root; later entries win. Unknown keys and rules are named with the closest valid one

Paths and globs are relative to the root and use forward slashes. Windows-style backslashes (components\ui\**\*.tsx, .\tsconfig.app.json) are read as separators, except in a pattern that already uses /, where \ escapes glob syntax (app/\(marketing\)/**). A tsconfig that does not exist is a config error rather than a silent fallback.

Tokens can be W3C DTCG JSON ($type inheritance, aliases, object color and dimension values, $deprecated, modes under $extensions.modes) or CSS custom properties: :root values, .dark / [data-theme] / prefers-color-scheme / @variant dark modes, and Tailwind v4 @theme mappings, with calc() evaluated and Tailwind's own palette resolved where a token builds on it (--primary: var(--color-neutral-800)). Tailwind v3 works too: bare HSL channels (--border: 214.3 31.8% 91.4%) are colors, and class names come from the colors in tailwind.config.* (the one components.json names, else the root's) and the presets it imports from the project, read without running it. When those colors cannot be read, shadcn/ui's names are used (--sidebar-background is bg-sidebar). Token stylesheets are read as one theme, so .dark can live in its own file; without a :root block the light mode is the base, and a dark mode never is. A DTCG file and the CSS generated from it are merged by custom property.

elements maps a native element to the component that replaces it ({ "a": "Link" }, or a key such as input[type=checkbox] for a non-text input type); a mapped component is treated as a drop-in, so the rename is auto-fixed.

Docs are Markdown or MDX, one file per component, matched by component: frontmatter, the first heading or the file name. Fenced tsx/jsx blocks become examples (title="..." in the fence names them); JSDoc @example tags work too.

CLI flags override the file: --root, --config, --components, --tokens, --docs (repeatable), --no-cache, --no-watch. Run onsystem --help for the rest.

Generated from docs/configuration.md when the site is built.