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.