Tailwind Theming
Nic Jensen
Published on July 24, 2024
I've been using Tailwind for years and absolutely love it. But there's one problem I keep running into: building components that work with multiple colors, and then having to hunt through templates updating classes in multiple places whenever I need to change or add a color schemes to existing components. Every time I create a button or card component, I end up with dozens of color-specific class combinations to maintain.
This post walks through how I built a Tailwind plugin that finally solved this for me, letting components work with any color without knowing about colors at all by adding css variable based theming utilities.
Table of Contents
- What You'll Get
- The Problems
- The Solution
- Building the First Version
- Real-World Examples
- Runtime Theming
- LCH: The Perceptual Color Space
- Reverse Engineering Tailwind's Palettes
- The Technical Architecture
- Implementation Challenges
- Testing the Plugin
- Performance Considerations
- Get Started
What You'll Get
Transform hundreds of color-specific component mappings into a single, elegant API:
Before: Maintaining 50+ class combinations per component
// 10 colors × 5 variants = 50 combinations per component
function Button({ color = 'primary', variant = 'solid', ...props }) {
const colorVariantClasses = {
primary: {
solid: 'bg-purple-500 text-white hover:bg-purple-600',
outline: 'border-2 border-purple-500 text-purple-500 hover:bg-purple-50',
ghost: 'text-purple-600 hover:bg-purple-100',
// ... more variants
},
success: {
solid: 'bg-green-500 text-white hover:bg-green-600',
outline: 'border-2 border-green-500 text-green-500 hover:bg-green-50',
ghost: 'text-green-600 hover:bg-green-100',
// ... more variants
},
error: { /* ... all variants repeated */ },
warning: { /* ... all variants repeated */ },
info: { /* ... all variants repeated */ },
// ... endless repetition
};
return <button className={colorVariantClasses[color][variant]} {...props} />;
}
After: One component definition, infinite colors
// Write once, use with any color
function Button({ color = 'primary', variant = 'solid', ...props }) {
// Define variants without color knowledge
const variantClasses = {
solid: 'bg-theme-500 text-white hover:bg-theme-600',
outline: 'border-2 border-theme-500 text-theme-500 hover:bg-theme-50',
ghost: 'text-theme-600 hover:bg-theme-100',
// ... same variants work for all colors
};
// Map semantic names to colors
const colorClasses = {
primary: 'theme-purple',
success: 'theme-green',
error: 'theme-red',
warning: 'theme-yellow',
info: 'theme-blue',
};
return (
<button className={`${variantClasses[variant]} ${colorClasses[color]}`} {...props} />
);
}
// Use with any color
<Button color="primary">Save</Button>
<Button color="success">Complete</Button>
<Button color="error" variant="outline">Delete</Button>
The plugin handles everything: CSS variable generation, shade calculations, and even constructs palettes from arbitrary colors that look similiar to the default Tailwind palettes.
The Problems
I was building an internal component library for my SaaS application. Things like buttons, alerts, cards, form inputs. Each component needed to work in multiple color contexts: blue for primary actions, red for errors, green for success, and so on.
Here's what I started with:
function Button({ color = 'blue', variant = 'solid', children }) {
const colorVariantClasses = {
blue: {
solid: 'bg-blue-500 text-white hover:bg-blue-600',
outline: 'border-2 border-blue-500 text-blue-500 hover:bg-blue-50',
/* ... */
},
red: {
solid: 'bg-red-500 text-white hover:bg-red-600',
outline: 'border-2 border-red-500 text-red-500 hover:bg-red-50',
/* ... */
},
// ... 8 more colors × 5 variants each
};
return (
<button className={colorVariantClasses[color][variant]}>
{children}
</button>
);
}
With 10 colors and 5 variants, I had 50 class combinations per component. Multiply that across 15 components and I was maintaining 750 class strings. Every hover state adjustment meant updating code in 10+ places.
As you might expect, the problems with this approach compounded quickly:
Maintenance nightmare: Want to adjust the hover opacity for ghost buttons? Update it in 10 different color objects. Need consistent focus rings? Copy-paste the same classes everywhere.
No runtime flexibility: Everything was compiled at build time. When I asked myself "can customers set their own brand color?", the answer was no not without shipping all possible color combinations.
Code duplication: Every new component repeated the same pattern. Alert components, cards, badges they all had their own color mapping objects with nearly identical class strings.
After trying various different approaches, I kept coming back to one question: why do components need to know about specific colors at all?
The Solution
The breakthrough was realizing CSS custom properties could bridge Tailwind's color utilities with classes that define color contexts. Instead of components knowing about specific colors, they could use generic theme utilities that adapt to their context.
The architecture:
- theme-{color}
classes set CSS variables (--theme-50
, --theme-100
, etc.)
- {utility}-theme-{shade}
classes consume those variables
- Components use theme utilities instead of color-specific ones
Here's what this looks like in practice:
<!-- Set color context on a parent -->
<div class="theme-blue">
<button class="text-white bg-theme-500 hover:bg-theme-600">
I'm blue!
</button>
</div>
<!-- Same component, different color -->
<div class="theme-red">
<button class="text-white bg-theme-500 hover:bg-theme-600">
I'm red!
</button>
</div>
In practice, this meant components could be completely color-agnostic:
function Button({ variant = 'solid', color = 'primary', ...props }) {
const variantClasses = {
solid: 'bg-theme-500 text-white hover:bg-theme-600 active:bg-theme-700',
outline: 'border-2 border-theme-500 text-theme-500 hover:bg-theme-50',
ghost: 'text-theme-600 hover:bg-theme-100 active:bg-theme-200',
};
const colorClasses = {
primary: 'theme-purple',
success: 'theme-green',
error: 'theme-red',
warning: 'theme-yellow',
info: 'theme-blue',
};
return (
<button className={`${variantClasses[variant]} ${colorClasses[color]}`} {...props} />
);
}
Now the same component works with any color without duplicating variant definitions:
<!-- Works with any Tailwind color -->
<Button color="success">Save changes</Button>
<Button color="error">Delete</Button>
<!-- Works with nested contexts -->
<Card className="theme-slate">
<Button color="primary" variant="ghost">Nested theme</Button>
</Card>
No more massive class dictionaries. No more updating ten places when you tweak a hover state. Just clean, maintainable components.
Building the first version
With the concept clear, it was time to see if it would actually work.
Starting with a Proof of Concept
I started with the simplest possible proof of concept. Could I make CSS variables work with Tailwind's utility system?
// tailwind.config.js
module.exports = {
plugins: [
function({ addUtilities }) {
addUtilities({
'.theme-blue': {
'--theme-500': '59 130 246', // Tailwind's blue-500 in RGB
},
'.bg-theme-500': {
'background-color': 'rgb(var(--theme-500))',
},
});
},
],
};
Twenty lines of code. One theme setter, one utility consumer. And it worked—the proof of concept confirmed the approach was viable.
The Key Architectural Insight
The key insight was using CSS variables as a communication channel between two separate Tailwind classes. One class (theme-blue
) could set variables that another class (bg-theme-500
) could consume, without either knowing about the other. This broke the direct coupling between colors and utilities.
Expanding to Full Feature Set
From there, the plugin expanded to: - Generate theme setters for all Tailwind colors - Create consumers for every color utility (bg, text, border, ring, etc.) - Support all shades (50-950) - Handle hover, focus, and other state modifiers
The architecture remained simple:
.theme-blue {
--theme-50: 239 246 255
--theme-100: 219 234 254
--theme-500: 59 130 246
/* ... (all shades) */
}
.bg-theme-500 {
background-color: rgb(var(--theme-500))
}
Immediately I was able to replace hundreds of component color mappings with theme utilities and the maintenance burden dropped to near zero.
Runtime theming
The plugin worked great for Tailwind's built-in colors, but I realized it had 2 major limitations: - What if someone wanted to use a color that wasn't in Tailwind's palette through JIT? - What if I wanted to allow customers to set their own brand color?
This opened up a whole new set of challenges: - Accept any hex color as input - Generate a full shade palette (50-950) from a single color - Make it look as good as Tailwind's hand-crafted palettes - Do it all in the browser, at runtime
The plugin supports two approaches for custom colors. You can use Tailwind's JIT system with theme-[#FF5733]
, which generates all the shades at build time. Or for truly dynamic colors (like user preferences loaded from a database), you can use className="theme" style={{ '--tw-theme-base': userColor }}
. Both approaches use the same shade generation system.
For dynamic colors though, you can't compile CSS for colors that don't exist yet. I needed runtime shade generation.
The naive approach was to first take a hex color and linearly lighten/darken it. But anyone who's tried this knows the results look terrible. Colors become muddy, washed out, or shift in unexpected ways. Tailwind's palettes are carefully crafted, each shade is fine-tuned to look good.
I needed a way to match Tailwind's color quality programmatically.
LCH: The perceptual color space
My search for better color manipulation led me to LCH, a perceptual color space designed for how humans actually see color.
LCH provides three channels: - L (Lightness): 0 to 1, where 0 is black and 1 is white - C (Chroma): 0 to ~0.4, essentially the "colorfulness" - H (Hue): 0 to 360, the actual color on the color wheel
Unlike RGB, HSB, or HSL, LCH maintains perceptual uniformity. What that means is that if you take any two different hues, for example green (hue 150) and blue (hue 240), and assign them the same lightness and chroma, the shades will appear equally bright and saturated to our eyes. This is because the LCH color space was designed based on how humans actually perceive color.
/* Generate a shade dynamically from any base color */
.shade-200 {
background: oklch(from var(--base-color) 0.89 calc(c * 0.5) h);
}
This meant I could generate all shades at runtime in pure CSS. No JavaScript calculations needed. The Tailwind plugin could output CSS that generates shades on the fly when someone uses theme-[#FF5733]
.
<!-- TODO: Change this to generate the Lightness, Chroma, and Hue values from the base in the JIT javascript, this only matters for customer set colors -->
The real breakthrough came with CSS's relative color syntax using the from
keyword. Since July 2024, all major browsers support this feature, and as of today there is 91% browser support. This allows deriving new colors from existing ones directly in CSS.
Reverse engineering Tailwind's palettes
But knowing about OKLCH was only half the solution. Tailwind's color palettes aren't just mathematically derived. They're carefully designed to look good. I needed to understand the secret sauce.
I spent a day analyzing every Tailwind color: 1. Convert each shade (50, 100, 200... 950) to OKLCH 2. Plot lightness, chroma, and hue values for each color 3. Look for patterns
Lightness followed a non-linear curve. The jumps between shades weren't equal. They were larger in the middle ranges and compressed at the extremes. This created better visual distinction between commonly-used shades.
Chroma peaked in the middle shades (400-600) and dropped off at both ends. Light shades (50-200) had low chroma to avoid looking artificially bright. Dark shades (700-950) also reduced chroma to prevent muddiness.
Hue actually shifted across shades. Blues became slightly more purple in darker shades. Yellows shifted toward orange. This subtle hue shifting made colors feel more natural and avoided the "same color but darker" look.
I encoded these patterns directly into the CSS the plugin generates:
/* Example of how theme-[#FF5733] generates shades */
.theme-\[#FF5733\] {
--theme-base: #FF5733;
--theme-50: oklch(from var(--theme-base) 0.98 0.02 h);
--theme-100: oklch(from var(--theme-base) 0.95 0.05 h);
--theme-200: oklch(from var(--theme-base) 0.89 0.1 h);
/* ... and so on for all shades */
}
After dozens of iterations, comparing generated palettes against Tailwind's originals, the results were surprisingly good. Colors maintained their character across all shades without looking washed out or muddy.
The Technical Architecture
With the color generation working, I focused on making the plugin feel native to Tailwind. I studied existing plugins to understand common patterns. @tailwindcss/container-queries
was particularly helpful in showing the modifier pattern. The plugin works by:
- Reading all colors from your Tailwind config to know which theme setters and consumers to generate
- Creating theme setter utilities that set CSS variables
- Creating theme consumer utilities that use those variables
- Supporting name modifiers for using multiple themes simultaneously
Theme Setters
The plugin reads your Tailwind color config and generates theme utilities for each color:
// If your config has colors.blue, colors.brand, etc.
// The plugin generates:
.theme-blue { /* sets --theme-50 through --theme-950 */ }
.theme-brand { /* sets --theme-50 through --theme-950 */ }
// For arbitrary colors
.theme-\[#FF5733\] {
--theme-base: #FF5733;
--theme-50: oklch(from #FF5733 0.98 calc(c * 0.1) h);
// ... generates all shades using OKLCH
}
Theme Consumers
For each color utility Tailwind provides, the plugin creates theme-aware versions:
.bg-theme-500 {
background-color: rgb(var(--theme-500) / var(--tw-bg-opacity, 1));
}
.text-theme-700 {
color: rgb(var(--theme-700) / var(--tw-text-opacity, 1));
}
Name Modifiers
One powerful feature is the ability to use multiple named themes simultaneously:
<div class="theme-red/base theme-white/on">
<button class="bg-theme-base-500 text-theme-on">
Red background, white text
</button>
</div>
This works by namespacing the CSS variables:
.theme-red\/base {
--theme-base-50: 254 242 242;
--theme-base-100: 254 226 226;
--theme-base-500: 239 68 68;
/* ... all shades 50-950 */
}
.theme-white\/on {
--theme-on: 255 255 255;
}
/* Consumers use the named variables */
.bg-theme-base-500 {
background-color: rgb(var(--theme-base-500) / var(--tw-bg-opacity, 1));
}
.text-theme-on {
color: rgb(var(--theme-on) / var(--tw-text-opacity, 1));
}
This allows you to define semantic color roles (base, on, accent, etc.) and assign different colors to each role.
Real-World Examples
The plugin really shines when building all UIs with theming in mind. Here's how I've been using the plugin in production across different scenarios:
Basic Color Context
The simplest pattern - applying a theme to a single component or section:
// Single component
<button className="theme-purple bg-theme-500 text-white hover:bg-theme-600">
Save Changes
</button>
// Wrapping multiple elements
<div className="theme-emerald">
<Card />
<Button variant="outline">Matches the card</Button>
<Link>Also themed</Link>
</div>
Multi-Brand White Labeling
function CustomerPortal({ brandColor }) {
return (
<div className="theme" style={{ '--tw-theme-base': brandColor }}>
<Navigation />
<DashboardContent />
</div>
);
}
Form Validation States
function FormField({ error, success, children }) {
const themeClass = error ? 'theme-red' : success ? 'theme-green' : 'theme-slate';
return (
<div className={themeClass}>
<label className="text-theme-700 font-medium">{children}</label>
<input className="border-theme-300 focus:border-theme-500 focus:ring-theme-500" />
{error && <p className="text-theme-600 text-sm">{error}</p>}
</div>
);
}
Dark Mode with Semantic Colors
function Card() {
return (
<div className="theme-slate/base theme-white/on dark:theme-zinc/base dark:theme-slate/on">
<div className="bg-theme-base-100 dark:bg-theme-base-900 rounded-lg p-6">
<h3 className="text-theme-on-900 dark:text-theme-on-100">
Title
</h3>
<p className="text-theme-on-700 dark:text-theme-on-300">
Content that adapts to both light and dark modes
</p>
</div>
</div>
);
}
Complex Dashboard Theming
function Dashboard() {
return (
<div className="theme-slate">
{/* Main UI in slate */}
<Header />
<Sidebar />
<main>
{/* Success section */}
<section className="theme-emerald">
<MetricCard title="Revenue" status="up" />
</section>
{/* Warning section */}
<section className="theme-amber">
<AlertBanner message="Approaching limits" />
</section>
{/* Nested brand theming */}
<section className="theme-[#FF6B6B]">
<BrandedWidget />
</section>
</main>
</div>
);
}
These patterns allowed me to eliminate thousands of conditional classes, while at the same time making the code more readable, maintainable, and extensible.
Implementation Challenges
However building the plugin wasn't without its challenges.
Tailwind v3 vs v4 Compatibility
Tailwind v4 changed the internal plugin API significantly. The plugin detects the version and adapts:
// v3 uses addUtilities
addUtilities({ '.theme-blue': { /* ... */ } });
// v4 requires matchUtilities
matchUtilities({
theme: (value) => ({ /* ... */ })
});
Nested Theme Contexts
CSS variables naturally cascade, making nested themes work automatically:
<div class="theme-blue">
<button class="bg-theme-500">I'm blue</button>
<div class="theme-red">
<button class="bg-theme-500">I'm red</button>
</div>
</div>
The nearest theme context wins, allowing for powerful composition patterns.
Supporting Arbitrary Colors
Making theme-[#FF5733]
work required leveraging Tailwind's arbitrary value system. The plugin receives the hex value and generates all shades using the OKLCH formulas. This happens at build time for colors in your templates, keeping the CSS output optimized.
Testing the Plugin
Testing a Tailwind plugin presented some unique challenges. I essentially needed tests to ensure that the correct CSS classes were generated and that the correct styles for those classes were generated. To simplify the testing proccess and suite I built ended up building a comprehensive Jest test suite with custom matchers:
// Custom matchers for testing Tailwind plugins
expect.extend({
toGenerateCSS(received, expected) {
// Tests if plugin generates expected CSS classes
},
toDefineUtility(received, utilityName) {
// Tests if plugin defines expected utilities
},
toDefineCSSVariable(received, varName) {
// Tests if CSS variables are properly defined
},
toGenerateCSSRule(received, expected) {
// Tests specific CSS rules are generated
}
});
I was then able to easily write tests to verify all aspects of the plugin:
// Test theme setter generation
it('should generate theme utility classes', async () => {
await expect({
plugin: plugin(),
content: '<div class="theme-red"></div>'
}).toGenerateCSS('.theme-red')
})
// Test arbitrary color support
it('should support arbitrary color values', async () => {
await expect({
plugin: plugin(),
content: '<div class="theme-[#00ff00] bg-theme-500"></div>'
}).toGenerateCSS(['.theme-\\[\\#00ff00\\]', '.bg-theme-500'])
})
// Test CSS variable generation with OKLCH
it('should generate palette for custom colors', () => {
expect({
plugin: plugin(),
utility: 'theme',
value: 'custom'
}).toGenerateCSSRule({
'--tw-color-theme-50': /oklch/,
'--tw-color-theme-950': /oklch/
})
})
This test infrastructure was an incredible help and gave me confidence to refactor the entire plugin from a functional approach to a class-based architecture and easily find areas of the code that broke or subtely changed functionality.
Get Started
If you're tired of maintaining color-specific component code, the plugin is open source and ready to use:
- GitHub: github.com/your-username/tailwind-theme-colors
- npm:
npm install tailwind-theme-colors
- Documentation: Full API docs and examples in the repo
Try it out and let me know what you think! I'm especially curious about: - How you're handling theme switching in your apps - Any performance impacts with large component libraries - Edge cases where the approach breaks down
Feel free to open issues, submit PRs, or reach out on Twitter @your-handle.
If this solved your color theming headaches, give the repo a star. And if you end up using it in production, I'd love to hear about it!