The Tailwind Trap
You love Tailwind. It's fast, intuitive, and you can build UIs without leaving your HTML.
<button className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg">
Click me
</button>
Ship. Done. Beautiful.
But after 6 months, you have:
- 23 different shades of blue
- Buttons with
px-4 py-2,px-5 py-2.5,px-6 py-3, andpx-4 py-1.5 - Forms using
gap-4, cards usinggap-6, grids usinggap-3
You thought Tailwind WAS your design system. It's not.
Tailwind is a utility toolkit. A design system is a set of decisions. This article shows you how to layer decisions on top of Tailwind without losing its speed.
TL;DR
- Tailwind provides utilities, not design constraints
- Without tokens, teams drift into inconsistency (even with Tailwind)
- Design tokens layer brand decisions on top of Tailwind's utility classes
- FramingUI connects tokens to Tailwind's config, giving you both structure and speed
- You keep writing
bg-primary,px-md—but now they're YOUR decisions, not Tailwind's defaults
Why Tailwind Alone Isn't a Design System
Problem 1: Too Many Choices
Tailwind gives you 22 shades of each color:
colors: {
blue: {
50: '#eff6ff',
100: '#dbeafe',
// ...
900: '#1e3a8a',
950: '#172554'
}
}
Question: When do you use blue-600 vs. blue-700?
If your answer is "whatever looks good," you don't have a design system. You have chaos with pretty gradients.
Problem 2: The Copy-Paste Lottery
Developer 1 builds a primary button:
<button className="bg-blue-600 hover:bg-blue-700 ...">
Developer 2, in a different file, needs a primary button:
<button className="bg-blue-500 hover:bg-blue-600 ...">
Both look "fine" in isolation. But side-by-side? They're different buttons with the same purpose.
Problem 3: Changing Your Brand Color is a Nightmare
Your designer says: "Let's switch from blue to indigo."
With raw Tailwind classes, you're doing a find-and-replace:
blue-600→indigo-600blue-700→indigo-700blue-500→indigo-500(wait, should this be 500 or 600?)
Time spent: 2-4 hours
Risk of missing instances: High
Chance of breaking something: Moderate
Problem 4: Dark Mode Becomes a Disaster
// Light mode uses blue-600
<div className="bg-blue-600 text-white">
// Dark mode... what color should this be?
<div className="bg-blue-600 dark:bg-blue-400 text-white dark:text-black">
// Wait, should text be black? Or gray-900? Or white?
Without semantic tokens, you're guessing contrast ratios and visual hierarchy every time.
What Design Tokens Actually Solve
Design tokens are named decisions that map to values:
// Instead of:
bg-blue-600
// You write:
bg-brand-primary
// Which maps to:
colors.brand.primary = '#2563EB' // (or blue-600, indigo-700, whatever)
The magic: When you change colors.brand.primary, every component updates automatically.
Semantic Naming = Design Intent
// ❌ Implementation-focused (Tailwind default)
colors: {
blue: { 600: '#2563EB' }
}
// ✅ Intent-focused (Design Tokens)
colors: {
brand: {
primary: '#2563EB', // Main CTA, links
primaryHover: '#1D4ED8', // Hover states
},
semantic: {
success: '#10B981',
error: '#EF4444',
warning: '#F59E0B',
},
surface: {
base: '#FFFFFF', // Card backgrounds
elevated: '#F9FAFB', // Modals, popovers
}
}
Now when you see bg-surface-elevated, you know why that color exists, not just what it is.
The Missing Link: Tailwind + Tokens
You don't have to choose between Tailwind's speed and a design system's consistency. You can have both.
Architecture
Design Tokens (source of truth)
↓
Tailwind Config (utilities)
↓
Your Components (classes)
Step 1: Define Tokens (YAML, JSON, or TypeScript)
// tokens/colors.ts
export const colorTokens = {
brand: {
primary: '#4338CA',
primaryHover: '#3730A3',
secondary: '#7C3AED',
},
neutral: {
50: '#F9FAFB',
100: '#F3F4F6',
200: '#E5E7EB',
// ... full scale
900: '#111827',
},
semantic: {
success: '#10B981',
successBg: '#D1FAE5',
error: '#EF4444',
errorBg: '#FEE2E2',
}
}
Step 2: Inject Tokens into Tailwind Config
// tailwind.config.js
import { colorTokens } from './tokens/colors'
export default {
theme: {
extend: {
colors: {
brand: colorTokens.brand,
neutral: colorTokens.neutral,
semantic: colorTokens.semantic,
}
}
}
}
Step 3: Use Semantic Classes
// ❌ Before (raw Tailwind)
<button className="bg-blue-600 hover:bg-blue-700 text-white">
Save
</button>
// ✅ After (Token-powered Tailwind)
<button className="bg-brand-primary hover:bg-brand-primaryHover text-white">
Save
</button>
Now changing your brand color is a one-line edit:
// tokens/colors.ts
export const colorTokens = {
brand: {
- primary: '#4338CA',
+ primary: '#7C3AED', // Switch to purple
}
}
All buttons, links, and CTAs update instantly. Zero find-and-replace.
Spacing, Typography, and Beyond
Colors are just the start. Tokens work for every design decision.
Spacing Tokens
// tokens/spacing.ts
export const spacingTokens = {
'3xs': '0.125rem', // 2px
'2xs': '0.25rem', // 4px
'xs': '0.5rem', // 8px
'sm': '0.75rem', // 12px
'md': '1rem', // 16px
'lg': '1.5rem', // 24px
'xl': '2rem', // 32px
'2xl': '3rem', // 48px
'3xl': '4rem', // 64px
}
// tailwind.config.js
export default {
theme: {
spacing: spacingTokens,
}
}
Usage:
// ❌ Before: arbitrary values
<div className="px-4 py-3 gap-5">
// ✅ After: named decisions
<div className="px-md py-sm gap-lg">
Typography Tokens
// tokens/typography.ts
export const typographyTokens = {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
fontSize: {
'xs': ['0.75rem', { lineHeight: '1rem' }],
'sm': ['0.875rem', { lineHeight: '1.25rem' }],
'base': ['1rem', { lineHeight: '1.5rem' }],
'lg': ['1.125rem', { lineHeight: '1.75rem' }],
'xl': ['1.25rem', { lineHeight: '1.75rem' }],
'2xl': ['1.5rem', { lineHeight: '2rem' }],
},
fontWeight: {
normal: '400',
medium: '500',
semibold: '600',
bold: '700',
}
}
// tailwind.config.js
export default {
theme: {
extend: {
fontFamily: typographyTokens.fontFamily,
fontSize: typographyTokens.fontSize,
fontWeight: typographyTokens.fontWeight,
}
}
}
Usage:
<h1 className="font-sans text-2xl font-bold">
Heading
</h1>
<p className="font-sans text-base font-normal text-neutral-700">
Body text with consistent line height.
</p>
Dark Mode with Tokens
This is where tokens truly shine. Instead of guessing dark mode colors, you define semantic roles:
// tokens/colors.ts
export const colorTokens = {
surface: {
base: {
light: '#FFFFFF',
dark: '#111827',
},
elevated: {
light: '#F9FAFB',
dark: '#1F2937',
}
},
text: {
primary: {
light: '#111827',
dark: '#F9FAFB',
},
secondary: {
light: '#6B7280',
dark: '#9CA3AF',
}
}
}
Tailwind config with CSS variables:
// tailwind.config.js
export default {
theme: {
extend: {
colors: {
surface: {
base: 'var(--color-surface-base)',
elevated: 'var(--color-surface-elevated)',
},
text: {
primary: 'var(--color-text-primary)',
secondary: 'var(--color-text-secondary)',
}
}
}
}
}
CSS:
:root {
--color-surface-base: #FFFFFF;
--color-surface-elevated: #F9FAFB;
--color-text-primary: #111827;
--color-text-secondary: #6B7280;
}
.dark {
--color-surface-base: #111827;
--color-surface-elevated: #1F2937;
--color-text-primary: #F9FAFB;
--color-text-secondary: #9CA3AF;
}
Component:
<div className="bg-surface-base text-text-primary">
This automatically adapts to light/dark mode
</div>
No dark:bg-gray-900 dark:text-white needed. The token handles it.
The FramingUI Approach
FramingUI automates this entire workflow.
1. Define Tokens Once
// framingui.config.ts
import { defineConfig } from '@framingui/core'
export default defineConfig({
tokens: {
colors: {
brand: {
primary: '#4338CA',
secondary: '#7C3AED',
},
// ... your colors
},
spacing: {
sm: '0.5rem',
md: '1rem',
lg: '1.5rem',
},
typography: {
fontFamily: {
sans: 'Inter, system-ui',
}
}
}
})
2. Auto-Generate Tailwind Config
npx framingui generate
This outputs:
tailwind.config.jswith your tokens- CSS variables for dark mode
- TypeScript types for autocomplete
3. Use in Components
import { tokens } from '@/framingui'
// Tailwind classes (autocomplete works)
<button className="bg-brand-primary px-md py-sm">
// Or programmatic access
<button style={{ backgroundColor: tokens.colors.brand.primary }}>
4. Change Tokens, Rebuild Instantly
// framingui.config.ts
colors: {
brand: {
- primary: '#4338CA',
+ primary: '#DC2626', // Red rebrand
}
}
npx framingui generate
Every component using bg-brand-primary updates. No manual find-and-replace.
Real-World Example: Rebrand in 5 Minutes
Scenario: Your startup gets acquired. New brand colors, new typography, new spacing system.
Before (Raw Tailwind):
- Find all instances of
blue-600→ 2 hours - Find all instances of
px-4and decide which to keep → 3 hours - Update typography classes → 1 hour
- QA for missed instances → 2 hours Total: 8 hours
After (Tailwind + Tokens):
- Update
framingui.config.ts(10 minutes) - Run
npx framingui generate(5 seconds) - QA visually (20 minutes) Total: 30 minutes
Time saved: 7.5 hours
Common Objections
"Tailwind classes are already semantic enough"
bg-blue-600 tells you what (a shade of blue), not why (primary brand color? link color? decorative accent?).
bg-brand-primary tells you intent. When you see it in code, you know its role.
"This adds extra abstraction"
True, but the right kind. You're abstracting decisions, not implementation.
With tokens, changing your mind is cheap. Without tokens, changing your mind is expensive.
"I like Tailwind's arbitrary values"
Keep them for one-offs:
<div className="w-[47.5%]"> // Fine for unique layouts
But for repeated decisions (brand colors, spacing scale), use tokens.
"Our team is too small for this"
Small teams benefit the most. You're the perfect size to set a foundation without bureaucracy.
2-person team: Prevents you and your co-founder from drifting apart 5-person team: Onboards new hires instantly 10-person team: Essential for consistency
Migration Strategy: Add Tokens Without Rewriting Everything
You don't have to refactor your entire codebase overnight.
Phase 1: Define Tokens (1 hour)
Extract your most-used colors, spacing, and typography into tokens.
Phase 2: Extend Tailwind Config (30 minutes)
Add your tokens to Tailwind's theme. Old classes still work.
Phase 3: Adopt Gradually
New components: Use tokens (bg-brand-primary)
Old components: Leave as-is (bg-blue-600)
Both work simultaneously. No breaking changes.
Phase 4: Refactor Hot Paths (ongoing)
When you touch a file for another reason, update its classes to use tokens.
Over 2-3 months, your codebase converges naturally.
Measuring Success
After adopting tokens, track:
| Metric | Before | After |
|---|---|---|
| Unique color values in codebase | 47 | 12 |
| Unique spacing values | 23 | 8 |
| Time to rebrand | 8 hours | 30 minutes |
| New developer onboarding | "just match existing components" | "use these token classes" |
The Future: AI-Powered Token Management
With FramingUI + MCP, AI tools (Cursor, Claude Code) can:
- Query your tokens before generating code
- Suggest token additions when you use a value repeatedly
- Audit your codebase for hardcoded values and propose migrations
Example:
Cursor AI: "I noticed you've used #F59E0B in 5 places.
Should I add it as colors.semantic.warning?"
Tailwind becomes even faster when AI knows your design system.
The Bottom Line
Tailwind gives you speed.
Design tokens give you consistency.
Together, you get both.
You don't have to choose between moving fast and building a coherent design system.
Layer semantic tokens on top of Tailwind's utility classes. Keep the DX you love, add the structure you need.
Next Steps
- Audit your Tailwind usage: How many shades of blue do you have?
- Extract repeated values: Colors, spacing, typography
- Install FramingUI:
npm install @framingui/core - Define tokens: Start with colors, add spacing and typography next
- Generate Tailwind config:
npx framingui generate
Get started now: FramingUI + Tailwind Setup Guide →
Have questions about integrating design tokens with Tailwind? Join our Discord or tweet at us.