@ubuligan/ui-kit-v1
v0.1.0
Published
A publishable React UI kit — shadcn/Radix components, two runtime dependencies, token-driven theming, per-component subpath exports.
Readme
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.
pnpm install
pnpm dev # Storybook → http://localhost:6006npm 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 itspostinstallto place the platform binary. Under pnpm, skipping it makes the build fail; under npm you get a warning. Soesbuildand@parcel/watcherare pre-approved inpackage.json, once per manager:"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>andpnpm approve-buildswrite 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.
{
"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.
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` subpathOne 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:
- 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. - Every component file that uses React state, effects or event handlers starts with
'use client', including itsindex.ts. Without it the kit breaks inside Next.js Server Components. - The kit ships two runtime dependencies and no more:
radix-uiandtailwind-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 lintfails 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:
# 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:
formis the react-hook-form binding.react-hook-formis an optional peer dependency, soformis 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,fieldgives you the same layout without the library.toasttakes athemeprop, defaulting to'inherit'. Toasts portal ontodocument.body, which sits inside<html>, so adarkclass on the root element already reaches them; force'light'/'dark'only if your app scopes dark mode to a wrapper the portal escapes.date-pickerexportsDatePickerandDateRangePickerfrom 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.iconslives at<pkg>/iconsrather than in the root barrel: names likeCheckIconcollide 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 (ISC); the attribution lives inNOTICE, which is part of the published tarball — keep it there.icons.test.tsxcompares each icon againstlucide-react(a devDependency, pinned exact) so a bump tells you when upstream has redrawn something.
5. Adding a component
# Claude Code — scaffolds the folder, story, test, index.ts and regenerates exports
/ui-kit-component <name>Or by hand:
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#exportsshadcn add writes a flat file; the kit uses folders. Moving the file and running exports:gen is the whole difference.
6. Build
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 budgetpnpm 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 |
7. Publishing
pnpm changeset # describe the change, pick major/minor/patch
pnpm changeset version # applies the bump + writes CHANGELOG.md
pnpm release # guided publishpnpm 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:
// 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:
@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.
- <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.
- <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.
- <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,cvaandVariantPropsfrom<pkg>/lib, for building your own variants.src/styles/rc.cssis gone; every component now styles itself withdata-slotand utility classes.
