Design & engineering (solo) · 2026
nef-ui: a component library built like a product
- React
- TypeScript
- Radix Primitives
- CSS Modules
- Storybook
- Chromatic
- Vitest
- vitest-axe
- Components
- 14
- Visual regression
- Every push and PR, via Chromatic
- Accessibility
- vitest-axe on every component
Context
Most of what I know about design systems, I learned maintaining one inside someone else's product — the tokens were already decided, the primitive library was already chosen, and the migrations I care about most (swapping what a component is built on, without breaking who consumes it) had already happened before I joined. nef-ui exists to close that gap: a real, published package where I own every layer, from the raw color scale to the npm release.
It's fourteen components — Button, TextField, Textarea, Select,
Switch, Dialog, DropdownMenu, Tooltip, Tabs, Toast, Card,
HStack, VStack, Skeleton — built on
Radix Primitives, styled with CSS
Modules, and themed entirely through CSS custom properties.
Constraints I set myself
A design system's hardest decisions aren't the ones you make — they're the ones you write down so a future reader doesn't assume you forgot them. Three are recorded as ADRs in the repo:
Radix now, Base UI later — on purpose. Base UI is quickly becoming the default primitive layer, which makes starting on Radix the non-obvious choice. I did it anyway, because the thing I actually want practice at is migrating a whole primitive layer under a design system without breaking consumers — and I don't build an abstraction layer to make that migration cheaper later. A migration I'm merely prepared for teaches me nothing; the Component boundary that already exists is what contains the blast radius.
CSS Modules, not Tailwind, for the published package. Tailwind is my
default everywhere else, which is what makes this the surprising call. But
utility classes shipped in dist only work if the consumer's own Tailwind
build scans this package — and Next.js and Nx setups each break that
differently. CSS Modules compile to one plain stylesheet that works with zero
consumer configuration; theming happens by overriding semantic tokens, no
build step required.
No runtime animation dependency in the library. Radix exposes
data-state attributes that CSS keyframes can drive directly, so enter/exit
animation needs no JavaScript at all. I still reach for Motion — but in the
portfolio and demo apps that consume this library, where layout animation and
shared-element transitions actually need it, not inside a package that would
force that dependency on everyone who installs it.
The token architecture
Every value in the system sits in one of four tiers, and a component is only ever allowed to reference one of them:
- Primitive Token — a raw, meaningless value in a scale (
--nef-gray-900,--nef-space-md). Never referenced by a component directly. - Semantic Token — a named role that resolves to a primitive
(
--nef-bg,--nef-accent). The only tier a component may reference, and the only tier a consumer overrides to theme. - Theme — a complete set of semantic token values, selected by a
data-themeattribute. - Recipe — the CSS Module owned by a single component, holding its variants and states.
Keeping those layers separate is what makes a one-line theme override
possible: setting --nef-accent on :root re-themes every component in the
library, light or dark, with zero JavaScript. This portfolio site is itself a
consumer — the accent color and every gray you're looking at right now
resolves through these same tokens.
Evidence, not claims
- Chromatic on every push and PR. A GitHub Actions workflow diffs
Storybook snapshots against the last accepted baseline (
onlyChanged: true, so it only fires on component changes — doc or config commits don't burn a build). - Accessibility tests alongside behavior tests. Every component's test
file asserts on more than rendering — labels, ARIA attributes, keyboard
interaction — and runs
vitest-axeagainst its error and disabled states to catch violations before Chromatic ever sees them. - A public, versioned release. The package ships to npm with a real changelog via Changesets, so consumers (this site included) upgrade deliberately instead of pulling from a private monorepo path.
Try it
This is the actual npm package, not a screenshot. Switch the accent or the theme and the Actions and Forms tabs below re-render through the same semantic tokens — nothing here is a one-off recreation.
The Overlays tab is the one honest exception: Radix portals Dialog,
Tooltip, and DropdownMenu content to document.body, outside this
scoped demo, so they render this site's own default theme rather than
whatever you've picked above — the same constraint any consumer hits
without a portal-container prop, which the library doesn't expose yet.
Outcome
This site imports @neftaliaguilar/ui too, and themes through the same
tokens the playground above rewrites — the Button in the hero is the same
component, not a lookalike.