# UI Kit

A publishable React component library. Restyle it, publish it to npm or a private registry, and your teammates import components one at a time.

**Two runtime dependencies.** Installing this kit adds `radix-ui` and `tailwind-merge` to your teammates' lock file, and nothing else — no icon library, no date library, no command palette, no toast package. Every component is code in this repo that you can read, change and own. The two that stayed earned it: `radix-ui` carries the accessibility contract for dialogs, selects and popovers (rewriting it is a project, not a file), and `tailwind-merge` is what lets a consumer's `className` override a built-in utility at all.

```bash
pnpm install
pnpm dev          # Storybook → http://localhost:6006
```

npm works just as well — `npm install`, then `npm run dev`. Every script in this README has an `npm run <script>` equivalent, and `pnpm release` shells out to `npm` internally, so publishing never needs pnpm installed.

> **Install scripts are allowlisted.** Both modern package managers refuse to run a dependency's install script unless you approve it, and `esbuild` — the engine behind tsup, Vite and Vitest — needs its `postinstall` to place the platform binary. Under pnpm, skipping it makes the *build* fail; under npm you get a warning. So `esbuild` and `@parcel/watcher` are pre-approved in `package.json`, once per manager:
>
> ```jsonc
> "allowScripts": { "esbuild": true, "@parcel/watcher": true },   // npm 11+
> "pnpm": { "onlyBuiltDependencies": ["esbuild", "@parcel/watcher"] }  // pnpm 10+
> ```
>
> **If you add a dependency that ships an install/postinstall script, add it to both lists** — `npm approve-scripts <pkg>` and `pnpm approve-builds` write them for you. Otherwise the install exits 0 and something breaks much later, with no obvious cause.

---

## 1. Make it yours

Three edits before anything else:

**`package.json`** — set the real package name. Scoped names are strongly preferred; an unscoped name can collide with anything on the public registry.

```jsonc
{
  "name": "@your-team/ui",
  "version": "0.1.0"
}
```

**`src/styles/tokens.css`** — this is where every colour, radius, space and font in the kit comes from. Change `--ui-color-brand-500` and the whole library rebrands. No component hardcodes a value.

**`LICENSE`** — replace `__COPYRIGHT_HOLDER__` with your name or your organisation. The year is already filled in. This kit is yours, so the copyright is too; the scaffolder cannot know whose name belongs there. npm packs a root `LICENSE` into every tarball whether or not `files` lists it, so this is the licence your consumers actually receive — `pnpm release` refuses to publish while the placeholder is still there.

That is the entire rebranding surface. If you want to drive it from Figma instead of by hand, see [Design tokens](#3-design-tokens).

---

## 2. Architecture

```
src/
  index.ts                  root barrel
  styles/
    tokens.css              primitives  (--ui-*)          ← machine-owned
    theme.css               semantic roles + @theme inline ← hand-owned
    globals.css             entry: tailwind + tokens + base layer
  lib/                      cn(), cx(), cva(), shared variant fragments
  icons/                    the kit's own icon set  → `<pkg>/icons`
  hooks/
  components/
    button/
      button.tsx
      button.stories.tsx
      button.test.tsx
      index.ts              ← this file is what creates the `/button` subpath
```

**One folder per component.** The folder boundary is not cosmetic: `scripts/gen-exports.mjs` scans `src/components/*/index.ts` and generates the `exports` map from it. That is what gives every component its own import path.

**Three rules that keep the kit coherent:**

1. Components use only the semantic tokens (`bg-background`, `text-primary-foreground`, `rounded-md`). Never reference a `--ui-*` primitive directly — that is the indirection that makes a Figma re-sync restyle everything at once.
2. Every component file that uses React state, effects or event handlers starts with `'use client'`, including its `index.ts`. Without it the kit breaks inside Next.js Server Components.
3. The kit ships **two runtime dependencies** and no more: `radix-ui` and `tailwind-merge`. Everything else — icons, variants, the command palette, toasts, the calendar, the table, the tree — is kit-local code. Reach for a component before reaching for a package; `pnpm lint` fails on the imports that would undo this.

---

## 3. Design tokens

`tokens.css` holds raw values in oklch, grouped into ramps (`neutral`, `brand`, `success`, `warning`, `danger`), plus radius, spacing, type, elevation, motion and z-index scales.

`theme.css` maps those primitives onto roles (`--background`, `--primary`, `--muted-foreground`, …), defines the `.dark` overrides, and exposes everything to Tailwind via `@theme inline`.

The split exists so a design-tool sync can replace the primitives wholesale without touching the semantic mapping:

```bash
# with the Figma Dev Mode MCP server connected
/figma-sync <figma-file-url>
```

The `figma-sync` skill (in `.claude/skills/`) reads your Figma Variables, rewrites `tokens.css`, shows you the diff before writing, and updates the `theme.css` mapping only when a variable name has no counterpart. Without the MCP server it falls back to a Tokens Studio JSON export.

---

## 4. What ships

39 components, each with its own import path, story and — where there is behaviour to test — a test suite.

| Group | Components |
| --- | --- |
| **Form** | `button` `input` `textarea` `label` `checkbox` `radio-group` `switch` `select` `combobox` `field` `form` |
| **Layout & feedback** | `card` `badge` `avatar` `alert` `separator` `skeleton` `spinner` `progress` |
| **Overlay** | `dialog` `sheet` `drawer` `dropdown-menu` `popover` `tooltip` `command` |
| **Navigation** | `tabs` `accordion` `breadcrumb` `pagination` `toast` |
| **Data & input-heavy** | `data-table` `virtual-list` `tree` `tree-select` `date-picker` `range-slider` `upload` `input-number` |

The last row is where Radix stops, and it is written from scratch here: a table with pinned columns and expandable rows, a windowed list, a tree with tri-state checkboxes, a calendar that parses loose text input, an abortable upload. Each was a third-party package once; each is now kit code you can read and change.

Notes on the edges of that list:

- **`form`** is the react-hook-form binding. `react-hook-form` is an *optional* peer dependency, so `form` is reachable only at `<pkg>/form` — it is not in the root barrel, which would otherwise break consumers who do not use it. Everything else works without it. If you are not on react-hook-form, `field` gives you the same layout without the library.
- **`toast`** takes a `theme` prop, defaulting to `'inherit'`. Toasts portal onto `document.body`, which sits inside `<html>`, so a `dark` class on the root element already reaches them; force `'light'`/`'dark'` only if your app scopes dark mode to a wrapper the portal escapes.
- **`date-picker`** exports `DatePicker` and `DateRangePicker` from one path, and its date maths (`formatDate`, `parseDate`, `addMonths`, …) with them — format the picker's value elsewhere in your app without installing a date library.
- **`icons`** lives at `<pkg>/icons` rather than in the root barrel: names like `CheckIcon` collide easily with an app's own set. It covers what the components draw and nothing more — install a full icon library alongside it if you need one. The geometry is inlined from [Lucide](https://lucide.dev) (ISC); the attribution lives in `NOTICE`, which is part of the published tarball — keep it there. `icons.test.tsx` compares each icon against `lucide-react` (a devDependency, pinned exact) so a bump tells you when upstream has redrawn something.

## 5. Adding a component

```bash
# Claude Code — scaffolds the folder, story, test, index.ts and regenerates exports
/ui-kit-component <name>
```

Or by hand:

```bash
npx shadcn@latest add dialog        # drops src/components/dialog.tsx
mkdir src/components/dialog && mv src/components/dialog.tsx src/components/dialog/
# add index.ts (with 'use client'), a .stories.tsx and a .test.tsx
pnpm exports:gen                    # regenerate package.json#exports
```

`shadcn add` writes a flat file; the kit uses folders. Moving the file and running `exports:gen` is the whole difference.

---

## 6. Build

```bash
pnpm build       # exports:gen → tsup → tailwind CLI
pnpm verify      # export map is current, every target exists, publint clean
pnpm test        # vitest, including the jest-axe smoke suite
pnpm typecheck
pnpm lint        # also fails on hardcoded colours and dependency creep
pnpm size        # per-subpath bundle budget
```

`pnpm lint` is where rules 1 and 3 above stop being conventions. Across everything under `src/` that ships, a `--ui-color-*` reference or a literal `#hex`/`rgb()`/`oklch()` is an error, and so is importing any of the packages the kit replaced — `clsx`, `class-variance-authority`, `lucide-react`, `cmdk`, `sonner`, `vaul`, `rc-*`, and any date library (`date-fns`, `dayjs`, `moment`, `luxon`). The motion and z-index primitives (`--ui-duration-*`, `--ui-ease-*`, `--ui-z-*`) are allowed directly — nothing re-maps them per theme, so there is no semantic layer for them to skip.

`pnpm test` runs the per-component tests plus `test/a11y.test.tsx`, which puts jest-axe over every component in a realistic composition — a control with its label, a table with its header — since the violations that matter (missing accessible name, orphaned label) only appear once things are put together. Contrast is not checked there: jsdom has no layout engine, so that job belongs to `@storybook/addon-a11y`, which runs in a real browser and is configured to fail the story.

`pnpm build` produces:

| Output | What it is |
| --- | --- |
| `dist/index.js` / `.cjs` / `.d.ts` | root barrel, ESM + CJS + types |
| `dist/components/<name>/index.*` | one entry per component |
| `dist/lib/index.*`, `dist/hooks/index.*`, `dist/icons/index.*` | `cn`/`cx`/`cva` + presets, the hooks, the icon set |
| `dist/styles.css` | compiled Tailwind — consumers need no Tailwind setup |
| `dist/tokens.css`, `dist/theme.css` | raw token layers for apps that do run Tailwind |

tsup is invoked through `scripts/run-tsup.mjs`, which only exists to raise the V8 heap ceiling. The `.d.ts` bundle is built in a worker thread that keeps the whole type graph — every entry plus Radix's type surface — in memory at once, and on a machine where Node sizes the default old-space below that (most CI runners do) the worker dies with `ERR_WORKER_OUT_OF_MEMORY` right after `CJS ⚡️ Build success`. It looks like a tsup crash and is only the heap. Set `--max-old-space-size` in `NODE_OPTIONS` yourself and the script leaves it alone.

---

## 7. Publishing

```bash
pnpm changeset          # describe the change, pick major/minor/patch
pnpm changeset version  # applies the bump + writes CHANGELOG.md
pnpm release            # guided publish
```

`pnpm release` checks the package name, the `LICENSE` placeholder, the working tree, and whether that version already exists on the target registry; then builds, verifies, prints the tarball contents, and asks you to type the version number before it publishes. Pass `--dry-run` to run every check and stop short of publishing.

For a private registry, uncomment the matching block in `.npmrc`. Never hardcode a token there — use the `${NPM_TOKEN}` form so the secret comes from the environment.

---

## 8. Consuming the published kit

**No Tailwind in the consuming app** — import the compiled stylesheet once:

```tsx
// app entry
import '@your-team/ui/styles.css';

// anywhere
import { Button } from '@your-team/ui/button';
```

**Tailwind v4 already in the app** — take the tokens instead and let your own build generate the utilities:

```css
@import 'tailwindcss';
@import '@your-team/ui/tokens.css';
@import '@your-team/ui/theme.css';
@source '../node_modules/@your-team/ui/dist';
```

Either way, importing from `@your-team/ui/button` pulls in Button and nothing else. The barrel (`@your-team/ui`) works too, but subpath imports keep cold builds and type-checking noticeably faster.

**Dark mode** is class-driven — put `class="dark"` on `<html>` (or let `next-themes` do it).

---

## 9. Migrating from a kit scaffolded before the dependency cut

Skip this section on a fresh scaffold. It matters only if you already published a
kit from an older version of this template and are pulling the new one in.

The kit went from **17 runtime dependencies to 2**. Only `radix-ui` (the
accessibility contract for dialogs, selects and popovers) and `tailwind-merge`
(what lets a consumer's `className` override a built-in utility at all) survived.
Everything else is now kit-local code under `src/` that you can read and change:
icons, the variant builder, the class composer, the command palette, toasts, the
drawer, the calendar, the data table, the tree, uploads, the number input.

Removed: `class-variance-authority`, `clsx`, `cmdk`, `date-fns`, `lucide-react`,
`rc-input-number`, `rc-picker`, `rc-slider`, `rc-table`, `rc-tree`,
`rc-tree-select`, `rc-upload`, `rc-virtual-list`, `sonner`, `vaul`.

### Breaking changes

**`RangeSlider`** — rebuilt on Radix's Slider, which derives the thumb count
from the value, so `range` is gone.

```diff
- <RangeSlider range defaultValue={[20, 70]} onChange={next} marks={{ 0: '0' }}
-   ariaLabelForHandle={['Minimum', 'Maximum']} />
+ <RangeSlider defaultValue={[20, 70]} onValueChange={next}
+   marks={[{ value: 0, label: '0' }]} thumbLabels={['Minimum', 'Maximum']} />
```

Single values are arrays too: `defaultValue={[40]}`. The `classNames` slots are
now `track` / `range` / `thumb` / `mark`.

**`CommandItem`** — `value` is required. cmdk inferred it from the rendered
text, which hid what filtering actually matched on. `keywords` covers synonyms
the label does not contain.

```diff
- <CommandItem onSelect={run}>Sign out</CommandItem>
+ <CommandItem value="Sign out" keywords={['logout']} onSelect={run}>Sign out</CommandItem>
```

**`Toaster`** — `theme` now defaults to `'inherit'`: toasts portal onto
`document.body`, which is inside `<html>`, so a `dark` class on the root element
already reaches them. `expand` and `richColors` are gone — toasts render as a
plain vertical list rather than a collapsed stack.

**`Drawer`** — built on Radix's Dialog. `snapPoints`, `nested` and
`shouldScaleBackground` are gone; `dismissible` and `showHandle` are new.

**`DatePicker` / `DateRangePicker`** — `picker` covers `date`, `month` and
`year`; `quarter`, `showTime`, `presets` and `panelRender` are gone. Values are
plain `Date` objects and locale is a BCP 47 string.

```diff
- <DatePicker onChange={(d) => set(Array.isArray(d) ? d[0] : d)} locale={deDE} />
+ <DatePicker onChange={set} locale="de-DE" />
```

The date maths ships with it — `formatDate`, `parseDate`, `addMonths` and the
rest are exported from `<pkg>/date-picker`, so formatting the value elsewhere in
your app needs no date library.

**`TreeSelect`** — nodes are keyed by `value` (`Tree` still uses `key`), which
separates the option's identity from the node's. rc-specific props
(`treeNodeFilterProp`, `showCheckedStrategy`) are gone.

**`Tree`** — `onCheck` always reports an array of checked leaf keys, plus
`halfCheckedKeys` in its info argument.

`DataTable`, `VirtualList`, `Upload` and `InputNumber` keep their APIs.

### New

- `<pkg>/icons` — the kit's own icon set, matching Lucide's geometry.
- `cx`, `cva` and `VariantProps` from `<pkg>/lib`, for building your own variants.
- `src/styles/rc.css` is gone; every component now styles itself with `data-slot`
  and utility classes.
