Design Tokens and Theming with Tailwind CSS
Build a robust theming system using custom design tokens, CSS variables, theme extension, and multi-brand support in Tailwind CSS.
What you'll learn
- ✓What design tokens are and how they map to Tailwind config
- ✓How to define semantic color tokens for consistent theming
- ✓How to use CSS custom properties for runtime theme switching
- ✓How to support multiple brands from a single codebase
- ✓How to keep design and development in sync with token files
Prerequisites
- •Solid understanding of Tailwind CSS configuration
- •Familiarity with CSS custom properties (variables)
A design token is a named value that represents a design decision. color.brand.primary is a token. #2563eb is its value. The power of tokens is that the name stays the same across every component, every page, and every brand — only the value changes. Tailwind’s configuration file is a natural home for design tokens, and CSS variables let you swap them at runtime.
Why Tokens Matter
Without tokens, teams make ad-hoc color decisions. One developer uses bg-blue-600, another uses bg-blue-500, and a third hard-codes #3b82f6 in inline styles. The result is visual inconsistency and painful refactoring when the brand color changes.
Without tokens:
Component A: bg-blue-600
Component B: bg-blue-500
Component C: style={{ background: '#3b82f6' }}
-> Three different blues. Which is "correct"?
With tokens:
Component A: bg-brand
Component B: bg-brand
Component C: bg-brand
-> One decision. Change it once, it updates everywhere. Defining Tokens in Tailwind Config
The simplest approach replaces raw Tailwind colors with semantic names:
// tailwind.config.js
export default {
theme: {
extend: {
colors: {
brand: {
DEFAULT: '#2563eb',
light: '#60a5fa',
dark: '#1d4ed8',
},
surface: {
DEFAULT: '#ffffff',
muted: '#f8fafc',
raised: '#ffffff',
},
content: {
DEFAULT: '#0f172a',
muted: '#64748b',
inverse: '#ffffff',
},
border: {
DEFAULT: '#e2e8f0',
strong: '#cbd5e1',
},
accent: {
DEFAULT: '#8b5cf6',
light: '#c4b5fd',
},
success: '#16a34a',
warning: '#d97706',
error: '#dc2626',
},
spacing: {
'page-x': '1.5rem',
'page-y': '2rem',
'section': '4rem',
'card': '1.5rem',
},
borderRadius: {
card: '0.75rem',
button: '0.5rem',
input: '0.375rem',
badge: '9999px',
},
fontFamily: {
heading: ['Cal Sans', 'Inter', 'sans-serif'],
body: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
fontSize: {
'display': ['3.5rem', { lineHeight: '1.1', fontWeight: '700' }],
'heading-1': ['2.25rem', { lineHeight: '1.2', fontWeight: '700' }],
'heading-2': ['1.5rem', { lineHeight: '1.3', fontWeight: '600' }],
'heading-3': ['1.25rem', { lineHeight: '1.4', fontWeight: '600' }],
'body-lg': ['1.125rem', { lineHeight: '1.6' }],
'body': ['1rem', { lineHeight: '1.6' }],
'body-sm': ['0.875rem', { lineHeight: '1.5' }],
'caption': ['0.75rem', { lineHeight: '1.4' }],
},
},
},
};
Now your markup reads like a design specification:
<div class="bg-surface p-card rounded-card border border-border">
<h2 class="font-heading text-heading-2 text-content">Dashboard</h2>
<p class="text-body text-content-muted mt-2">Your weekly summary.</p>
<span class="bg-success/10 text-success text-caption px-2 py-1 rounded-badge">
Active
</span>
</div>
CSS Variables for Runtime Theming
Static config tokens work well for single-theme projects. For runtime theme switching (dark mode, multi-brand), you need CSS custom properties.
Step 1: Define Variables
/* globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* Colors as RGB channels for alpha support */
--color-brand: 37 99 235;
--color-brand-light: 96 165 250;
--color-surface: 255 255 255;
--color-surface-muted: 248 250 252;
--color-content: 15 23 42;
--color-content-muted: 100 116 139;
--color-border: 226 232 240;
--color-accent: 139 92 246;
/* Non-color tokens */
--radius-card: 0.75rem;
--radius-button: 0.5rem;
--spacing-page: 1.5rem;
}
.dark {
--color-brand: 96 165 250;
--color-brand-light: 147 197 253;
--color-surface: 15 23 42;
--color-surface-muted: 30 41 59;
--color-content: 241 245 249;
--color-content-muted: 148 163 184;
--color-border: 51 65 85;
--color-accent: 167 139 250;
}
}
Step 2: Wire Variables to Tailwind
// tailwind.config.js
export default {
darkMode: 'class',
theme: {
extend: {
colors: {
brand: {
DEFAULT: 'rgb(var(--color-brand) / <alpha-value>)',
light: 'rgb(var(--color-brand-light) / <alpha-value>)',
},
surface: {
DEFAULT: 'rgb(var(--color-surface) / <alpha-value>)',
muted: 'rgb(var(--color-surface-muted) / <alpha-value>)',
},
content: {
DEFAULT: 'rgb(var(--color-content) / <alpha-value>)',
muted: 'rgb(var(--color-content-muted) / <alpha-value>)',
},
border: 'rgb(var(--color-border) / <alpha-value>)',
accent: 'rgb(var(--color-accent) / <alpha-value>)',
},
borderRadius: {
card: 'var(--radius-card)',
button: 'var(--radius-button)',
},
},
},
};
Step 3: Use Token Classes
<body class="bg-surface text-content">
<nav class="border-b border-border bg-surface px-[var(--spacing-page)]">
<a href="/" class="text-brand font-semibold">Brand</a>
</nav>
<main class="px-[var(--spacing-page)] py-8">
<div class="bg-surface-muted rounded-card p-6 border border-border">
<h2 class="text-xl font-semibold">Welcome</h2>
<p class="text-content-muted mt-2">This adapts to light and dark themes automatically.</p>
<button class="mt-4 bg-brand text-white px-4 py-2 rounded-button hover:bg-brand/90">
Get started
</button>
</div>
</main>
</body>
No dark: variants in your templates. Toggle the .dark class on <html> and everything updates through the CSS variables.
Multi-Brand Theming
For SaaS products that white-label for different clients, use data attributes to swap entire color palettes:
@layer base {
:root,
[data-brand="default"] {
--color-brand: 37 99 235;
--color-accent: 139 92 246;
}
[data-brand="acme"] {
--color-brand: 220 38 38;
--color-accent: 245 158 11;
}
[data-brand="globex"] {
--color-brand: 5 150 105;
--color-accent: 14 165 233;
}
}
<!-- Switch brands by changing one attribute -->
<html data-brand="acme">
<body class="bg-surface text-content">
<button class="bg-brand text-white px-4 py-2 rounded-button">
Brand Button
</button>
<!-- This button is red for Acme, blue for default, green for Globex -->
</body>
</html>
The same components, same classes, completely different visual identity.
Token Organization
Structure your tokens by category, not by implementation:
Global tokens (raw values)
blue-600: #2563eb
gray-50: #f8fafc
radius-md: 0.5rem
|
v
Alias tokens (semantic meaning)
brand: -> blue-600
surface: -> white
content: -> gray-900
card-radius: -> radius-md
|
v
Component tokens (specific usage)
button-bg: -> brand
button-radius -> card-radius
card-bg: -> surface In practice, most Tailwind projects use two levels: global tokens (Tailwind’s default scale) and alias/semantic tokens (your custom theme). Component tokens are usually overkill unless you are building a design system package consumed by multiple teams.
Syncing with Design Tools
If your design team uses Figma, keep tokens in a shared format that both tools can consume.
JSON Token File
{
"color": {
"brand": {
"value": "#2563eb",
"description": "Primary brand color"
},
"surface": {
"value": "#ffffff",
"description": "Default background"
},
"content": {
"value": "#0f172a",
"description": "Default text color"
}
},
"spacing": {
"page": { "value": "1.5rem" },
"section": { "value": "4rem" },
"card": { "value": "1.5rem" }
},
"radius": {
"card": { "value": "0.75rem" },
"button": { "value": "0.5rem" }
}
}
Build Script
Transform the JSON into Tailwind config and CSS variables:
// scripts/build-tokens.js
const tokens = require('../tokens/design-tokens.json');
const fs = require('fs');
// Generate CSS variables
let css = ':root {\n';
for (const [category, values] of Object.entries(tokens)) {
for (const [name, token] of Object.entries(values)) {
css += ` --${category}-${name}: ${token.value};\n`;
}
}
css += '}\n';
fs.writeFileSync('src/styles/tokens.css', css);
console.log('Tokens generated.');
This keeps the JSON file as the single source of truth. Designers update the JSON (or it is exported from Figma), and the build script generates the CSS and config.
Typography Tokens
Font sizes deserve special attention because they combine size, line-height, weight, and tracking:
fontSize: {
'display': ['3.5rem', {
lineHeight: '1.1',
fontWeight: '700',
letterSpacing: '-0.02em',
}],
'h1': ['2.25rem', {
lineHeight: '1.2',
fontWeight: '700',
letterSpacing: '-0.01em',
}],
'h2': ['1.5rem', {
lineHeight: '1.3',
fontWeight: '600',
}],
'body': ['1rem', {
lineHeight: '1.6',
}],
'small': ['0.875rem', {
lineHeight: '1.5',
}],
},
Usage:
<h1 class="text-display text-content">Page Title</h1>
<h2 class="text-h2 text-content">Section Heading</h2>
<p class="text-body text-content-muted">Body text with comfortable line height.</p>
Shadow and Elevation Tokens
boxShadow: {
'elevation-1': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
'elevation-2': '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
'elevation-3': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
'elevation-4': '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
},
<div class="shadow-elevation-1">Subtle</div>
<div class="shadow-elevation-3">Raised card</div>
<div class="shadow-elevation-4">Modal or dropdown</div>
Tailwind v4 and @theme
Tailwind v4 moves theme configuration into CSS with the @theme directive:
@import "tailwindcss";
@theme {
--color-brand: #2563eb;
--color-surface: #ffffff;
--color-content: #0f172a;
--radius-card: 0.75rem;
--font-heading: "Cal Sans", "Inter", sans-serif;
}
This eliminates the JavaScript config for most use cases and makes tokens natively CSS-based. The mental model is the same: define semantic names, use them in utilities, swap values for themes.
Common Mistakes
Naming tokens by appearance. color-blue is not a token, it is a description of the value. color-brand is a token because the name describes the role. When the brand color changes from blue to green, color-brand still makes sense.
Too many tokens. Not every value needs to be a token. If a value is used once in one component, a raw Tailwind utility is fine. Tokenize values that represent design decisions shared across components.
Forgetting alpha support. If you define tokens as hex values (--color-brand: #2563eb), you cannot use bg-brand/50 for opacity. Store colors as RGB channels (--color-brand: 37 99 235) and use the rgb(var(...) / <alpha-value>) pattern.
Not documenting tokens. A token without documentation is a magic string. Maintain a living reference (a Storybook page, a docs page, or at minimum comments in the config) that shows each token with its purpose and visual example.
Wrap-Up
Design tokens give your Tailwind project a single source of truth. Define semantic names in your config, use CSS variables for runtime switching, and organize tokens by role rather than appearance. For multi-brand support, swap entire palettes with data attributes. Keep typography tokens bundled with their line-height and weight. And remember: the goal is not to tokenize everything, but to tokenize the decisions that matter across your entire UI.
Related articles
- Tailwind Customizing Tailwind: Theme and Design Tokens
Extend Tailwind's theme with custom colors, spacing, and design tokens that scale across a real product without fighting the framework.
- Tailwind Dark Mode Strategies in Tailwind CSS
Master dark mode in Tailwind CSS with class strategy, media strategy, custom toggles, CSS variable theming, and flash-free server-side approaches.
- Tailwind Tailwind Dark Mode Strategies: Class, Media, and CSS Variables
Compare the class-based, media-based, and variable-driven approaches to dark mode in Tailwind, with code and the trade-offs of each.
- Tailwind Responsive Design Patterns with Tailwind CSS
Master mobile-first breakpoints, container queries, responsive grids, and real-world layout patterns using Tailwind CSS utilities.