Building a Design System with Tailwind CSS v4 and CSS Variables
A practical guide to building a design system using Tailwind v4's CSS-first theming: semantic tokens, oklch colors, dark mode, and component patterns.
By Ninna UI Team
A design system is more than a component library — it's a set of tokens, patterns, and conventions that keep your UI consistent. Tailwind CSS v4's CSS-first model makes building one simpler than ever. Here's how.
1. Define semantic tokens
Start with semantic color tokens — names that describe purpose, not appearance. Instead of blue-500, use primary. Instead of red-500, use danger. This decouples meaning from color:
/* tokens.css */:root { --color-primary: oklch(0.55 0.22 280); --color-primary-content: oklch(0.98 0.01 280); --color-success: oklch(0.60 0.18 145); --color-success-content: oklch(0.98 0.01 145); --color-warning: oklch(0.70 0.18 85); --color-warning-content: oklch(0.20 0.02 85); --color-danger: oklch(0.55 0.22 25); --color-danger-content: oklch(0.98 0.01 25); --color-base-100: oklch(0.98 0.005 280); --color-base-200: oklch(0.95 0.008 280); --color-base-content: oklch(0.25 0.02 280);}Each semantic color has a content pair — the text color that sits on top of it. This ensures contrast ratios are always correct.
2. Dark mode overrides
.dark { --color-primary: oklch(0.72 0.18 280); --color-primary-content: oklch(0.15 0.02 280); --color-base-100: oklch(0.18 0.02 280); --color-base-200: oklch(0.22 0.02 280); --color-base-content: oklch(0.92 0.01 280);}Dark mode is just different values for the same custom properties. The browser handles the switch — no JavaScript needed.
3. Map to Tailwind utilities
Tailwind v4's @theme directive maps CSS variables to utility classes:
@import "tailwindcss";
@theme { --color-primary: var(--color-primary); --color-primary-content: var(--color-primary-content); --color-success: var(--color-success); /* ... */}Now you can use bg-primary, text-primary-content, border-success, etc. in your components.
4. Component patterns
With tokens defined, build components that reference them. The key principle: components should never hardcode colors. Always use semantic tokens:
// Good: uses semantic tokens<button className="bg-primary text-primary-content hover:bg-primary/90"> Click me</button>
// Bad: hardcodes colors<button className="bg-blue-600 text-white hover:bg-blue-500"> Click me</button>5. Theme presets
A theme preset is just a different set of values for the same custom properties. Create multiple preset files and swap the import to change the entire theme:
/* ocean.css */:root { --color-primary: oklch(0.55 0.15 240); --color-primary-content: oklch(0.98 0.01 240);}
/* sunset.css */:root { --color-primary: oklch(0.62 0.20 30); --color-primary-content: oklch(0.98 0.01 30);}Ninna UI ships 5 theme presets using exactly this pattern: default, forest, sunset, ocean, and minimal. Each is under 50 lines of CSS.
6. Spacing and typography tokens
Beyond colors, define spacing and typography tokens. Tailwind v4's default spacing scale (1 = 0.25rem) is a good starting point. For typography, define font-family, font-size, and line-height as custom properties:
:root { --font-sans: "Inter", system-ui, sans-serif; --font-mono: "JetBrains Mono", monospace; --text-sm: 0.875rem; --text-base: 1rem; --text-lg: 1.125rem;}The result
You now have a design system: semantic color tokens with oklch, dark mode without JavaScript, theme presets via CSS imports, and consistent component patterns. This is exactly how Ninna UI is built — and you can use it as a reference for your own system.