Building a Design System With Tailwind CSS That Scales
Create a maintainable design system using Tailwind CSS — tokens, component patterns, theming, and strategies that keep your UI consistent as your team grows.
Strategic Systems Architect & Enterprise Software Developer
Tailwind CSS and design systems seem like they pull in opposite directions. Design systems want consistency and constraint. Tailwind gives you hundreds of utility classes and near-total freedom. But that tension is exactly why Tailwind works well as a design system foundation — you define the constraints in your configuration, and the utility classes become the vocabulary that enforces them.
The design systems I have built with Tailwind are more maintainable than the ones I built with traditional CSS, because the configuration file is the single source of truth for every visual decision.
Design Tokens as Configuration
The tailwind.config.ts file is your design token registry. Every color, spacing value, font size, border radius, and shadow should be defined there. The key discipline is removing the default scale for values you want to control and replacing it with your own.
import type { Config } from 'tailwindcss'
Export default {
theme: {
colors: {
transparent: 'transparent',
current: 'currentColor',
white: '#ffffff',
black: '#0f0f0f',
brand: {
50: '#f0f4ff',
100: '#dbe4ff',
500: '#4c6ef5',
600: '#3b5bdb',
700: '#364fc7',
900: '#1b2a6b',
},
neutral: {
50: '#fafafa',
100: '#f5f5f5',
200: '#e5e5e5',
500: '#737373',
700: '#404040',
900: '#171717',
},
success: { 500: '#22c55e', 700: '#15803d' },
warning: { 500: '#eab308', 700: '#a16207' },
error: { 500: '#ef4444', 700: '#b91c1c' },
},
spacing: {
0: '0px',
1: '4px',
2: '8px',
3: '12px',
4: '16px',
5: '20px',
6: '24px',
8: '32px',
10: '40px',
12: '48px',
16: '64px',
20: '80px',
24: '96px',
},
borderRadius: {
none: '0',
sm: '4px',
DEFAULT: '8px',
lg: '12px',
full: '9999px',
},
},
} satisfies Config
By overriding the entire colors key rather than extending it, you prevent developers from using Tailwind's default palette. The only available colors are the ones your design system defines. This is the most important constraint you can set.
For teams coming from a design tool like Figma, map your Tailwind tokens directly to Figma's design token names. If the designer calls a color "brand-500" and the developer uses bg-brand-500, the translation cost drops to zero. I covered the initial Tailwind and Nuxt integration in a separate article that handles the tooling setup.
Component Patterns Without a Library
You do not need a full component library to get consistency. The simplest approach is creating Vue or React components that wrap common patterns and expose props for the allowed variations:
<script setup lang="ts">
interface Props {
variant?: 'primary' | 'secondary' | 'ghost'
size?: 'sm' | 'md' | 'lg'
}
Const props = withDefaults(defineProps<Props>(), {
variant: 'primary',
size: 'md',
})
Const classes = computed(() => {
const base = 'inline-flex items-center justify-center font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:opacity-50'
const variants = {
primary: 'bg-brand-600 text-white hover:bg-brand-700',
secondary: 'bg-neutral-100 text-neutral-900 hover:bg-neutral-200',
ghost: 'text-neutral-700 hover:bg-neutral-100',
}
const sizes = {
sm: 'h-8 px-3 text-sm rounded-sm',
md: 'h-10 px-4 text-sm rounded',
lg: 'h-12 px-6 text-base rounded-lg',
}
return [base, variants[props.variant], sizes[props.size]].join(' ')
})
</script>
<template>
<button :class="classes">
<slot />
</button>
</template>
This is essentially what shadcn/ui does — unstyled component logic with Tailwind classes applied through a variant system. Whether you use an existing library or build your own depends on how custom your design needs to be. For most projects, starting with a library and customizing is faster than building from scratch.
The critical rule is that raw utility classes for visual patterns should not appear in page-level templates. If you find yourself typing bg-brand-600 text-white hover:bg-brand-700 rounded px-4 h-10 in multiple places, that pattern needs to be a component. The class string itself is the implementation detail. The component name is the interface.
Theming and Dark Mode
Tailwind's dark mode support through the class strategy gives you full control over theme switching. The design system's responsibility is defining what each token means in each theme:
@layer base {
:root {
--color-surface: 255 255 255;
--color-text: 15 15 15;
--color-border: 229 229 229;
}
.dark {
--color-surface: 23 23 23;
--color-text: 250 250 250;
--color-border: 64 64 64;
}
}
Then reference these variables in your Tailwind config using the rgb function pattern. This keeps your component code theme-agnostic — you use bg-surface and text-primary regardless of the active theme, and the CSS custom properties handle the switch.
The design system should define semantic color names alongside raw palette values. brand-600 is a raw value. button-primary is a semantic name. Semantic names let you change what "primary button" means across the entire application by updating one token, without touching any component code.
Documentation and Adoption
A design system without documentation is a suggestion. The minimum viable documentation is a living page in your application that renders every component variant. Storybook is the industry standard for this, but a simple Nuxt page that imports each component and renders its variants works fine for smaller teams.
What matters more than the tool is that the documentation updates automatically when components change. If the docs require manual updates, they will drift from reality within a month.
Include usage guidelines alongside the visual examples — when to use a ghost button versus a secondary button, what spacing scale to use for card padding versus section margins. These decisions are the actual value of a design system. The code just enforces them.