Skip to main content
All articles
Story July 30, 2026 8 min read

The Tech Behind Ninna UI: Building a CSS-First Design System

How Ninna UI is built: the stack, the architecture decisions, and the engineering challenges of building a zero-runtime React component library with Tailwind CSS v4.

By Ninna UI Team


Building a React component library in 2026 is as much about tooling and infrastructure as it is about components. Here's a look under the hood of Ninna UI — the stack, the architecture, and the engineering decisions.

The monorepo

Ninna UI is a pnpm workspace monorepo with 12 component packages plus a core package. Each package is independently versioned and published to npm. The core package (@ninna-ui/core) contains the theme system and shared tokens; component packages depend on core but not on each other.

We chose pnpm over npm/yarn for its strict node_modules layout (prevents phantom dependencies) and workspace protocol for local development. During development, packages symlink to each other; when published, they reference specific npm versions.

The build pipeline

Each package builds with tsup (esbuild-based bundler) to produce ESM output. The build generates three artifacts: the component JS (tree-shakeable ESM), the type definitions (.d.ts files), and the safelist (a CSS file that tells Tailwind which classes to include when scanning node_modules).

The safelist is critical: Tailwind v4's filesystem scanner can't detect classes inside node_modules. We auto-generate a safelist file that uses @source inline() to register all classes used by our components.

The theming system

Theme presets are plain CSS files. Each defines 8 semantic colors (primary, secondary, success, warning, danger, info, neutral, base) as oklch custom properties, plus their content (text-on-color) pairs. Dark mode overrides are in the same file, scoped to .dark.

Components reference these variables through Tailwind v4's theme system. When you write <Button color="primary">, the component applies bg-primary and text-primary-content classes, which resolve to the CSS custom properties.

Radix UI integration

Complex widgets are built on Radix UI primitives. We wrap Radix through an internal package (@ninna-ui/react-internal) that re-exports Radix components with our styling. This keeps Radix as an implementation detail — consumers never see @radix-ui/* in their dependency tree.

The wrapping pattern is consistent: for each Radix primitive, we create a Ninna UI component that renders the Radix component with our data-slot attributes and Tailwind classes. The API is designed to match our conventions (compound components, consistent prop names), not Radix's API.

Auto-generated documentation

The docs website (ninna-ui-web) generates llms.txt, component API references, and prop tables automatically from the package source code. A Node script (scripts/generate-seo.js) parses barrel exports, extracts TypeScript interfaces, and generates structured documentation.

This ensures the docs are never out of sync. When we add a component or change a prop, the next build regenerates everything. The same script generates sitemap.xml, llms-full.txt, and AGENTS.md.

Accessibility testing

We run 708 vitest-axe tests across 51 test files in CI. Every component has accessibility tests that check: keyboard navigation, ARIA correctness, focus management, and color contrast. These tests run on every pull request — accessibility regressions don't ship.

The website

The docs website is built with React Router v7 (framework mode), Tailwind CSS v4, and Ninna UI components (dogfooded). It features: 67 component doc pages, 54 blocks, 17 comparison pages, 5 use case guides, a blog, FAQ, cheatsheet, and framework-specific landing pages. All content is registry-driven, so the sitemap and structured data auto-generate.

What we'd do differently

If we started today, we'd make the same core decisions (CSS-first theming, Radix internals, pnpm monorepo). The one thing we'd change: starting with auto-generated docs from day one. We hand-wrote llms.txt for the first few months, and it drifted. Auto-generation was the right call — it just took too long to get there.


Build it with Ninna UI

Accessible React components, CSS-only theming, Tailwind v4 native.

Get Started

Keep reading