Modern frontend development demands scalable theming, responsive design tokens, and runtime flexibility. With the recent enhancements in Tailwind CSS v3.4+, namely @theme
and @custom-variant
, developers can now implement fully dynamic, type-safe theming using CSS variables, TypeScript, and zero runtime JS logic. This article outlines a theming strategy I came up with, which I believe is a clean and scalable approach built around those tools. It enables easy support for light/dark modes and beyond (daylight, retro, etc.) with minimal complexity.
Why I Think tailwind.config.js
Isn't Enough for Theming
Traditionally, developers define colors like primary
, accent
, or background
directly in tailwind.config.js
:
module.exports = { theme: { colors: { primary: '#571089', accent: '#ea698b', } } }
But in my experience, this introduces several limitations:
-
No runtime access from JavaScript/TypeScript
- If you want to use the same
primary
color in a charting library, animation logic, or conditional rendering, you must hardcode or duplicate it.
- If you want to use the same
-
No direct support for runtime theming
- You can't change colors dynamically (e.g., toggle between light/dark) without rebuilding Tailwind or using custom hacks.
-
Theme values scattered across multiple places
- You may have
tailwind.config.js
, SASS/SCSS maps, and JS constants — causing desynchronization and maintenance headaches.
- You may have
My Solution: Centralized Theme + CSS Variable Generation
I addressed these issues by creating a single, typed theme.ts
file like this:
export const theme = { mode: 'light', light: { color: { primary: '#571089', primary_light: ['#6411ad', '#6d23b6'], primary_deep: ['#47126b'], background: '#ffffff', }, font: { primary: 'ui-sans-serif, system-ui, sans-serif', } }, dark: { color: { primary: '#571089', primary_light: ['#6411ad', '#6d23b6'], primary_deep: ['#47126b'], background: '#0a0f1c', }, font: { primary: 'ui-sans-serif, system-ui, sans-serif', } } } as const;
This provides TypeScript type safety, autocompletion, and full control over the structure.
Then, using a simple build script with esbuild
, I extract this object and generate a tailwind.css
file with native Tailwind CSS support:
// theme-generator.ts import { buildSync } from 'esbuild'; import fs from 'fs'; import path from 'path'; const result = buildSync({ entryPoints: ['src/config/theme/theme.ts'], bundle: true, platform: 'node', write: false, format: 'cjs', }); const compiled = result.outputFiles[0].text; const module = { exports: {} }; const func = new Function('module', 'exports', compiled); func(module, module.exports); const theme = module.exports.default || module.exports.theme || module.exports; if (!theme || !theme.light || !theme.dark) { console.error('Theme format invalid or missing colors'); process.exit(1); } let css = '/* Do not edit this file, this will be automatically replaced by system */\n@import "tailwindcss";\n@custom-variant dark (&:where(.dark, .dark *));\n\n@theme {\n'; const flatten = (prefix, value, isDark = false) => { const convertedPrefix = prefix.split("_").join("-"); if (Array.isArray(value)) { value.forEach((v, i) => { css += ` --${convertedPrefix}-${i}${isDark ? '-dark' : ''}: ${v};\n`; }); } else if (typeof value === 'object') { Object.entries(value).forEach(([k, v]) => { flatten(`${convertedPrefix}-${k}`, v, isDark); }); } else { css += ` --${convertedPrefix}${isDark ? '-dark' : ''}: ${value};\n`; } }; Object.entries(theme.light).forEach(([key, value]) => flatten(key, value)); Object.entries(theme.dark).forEach(([key, value]) => flatten(key, value, true)); css += '}'; fs.mkdirSync(path.join('src'), { recursive: true }); fs.writeFileSync(path.join('src', 'tailwind.css'), css); console.log('✅ Tailwind theme generated at: src/tailwind.css');
Generated tailwind.css file
/* Do not edit this file, this will be automatically replaced by system */ @import "tailwindcss"; @custom-variant dark (&:where(.dark, .dark *)); @theme { --color-primary: #571089; --color-primary-light-0: #6411ad; --color-primary-light-1: #6d23b6; --color-primary-light-2: #822faf; --color-primary-light-3: #973aa8; --color-primary-light-4: #ac46a1; --color-primary-deep-0: #47126b; --color-accent: #ea698b; --color-accent-deep-0: #d55d92; --color-accent-deep-1: #c05299; --color-accent-deep-2: #c05299; --color-background: #ffffff; --font-primary: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; --color-primary-dark: #571089; --color-primary-light-0-dark: #6411ad; --color-primary-light-1-dark: #6d23b6; --color-primary-light-2-dark: #822faf; --color-primary-light-3-dark: #973aa8; --color-primary-light-4-dark: #ac46a1; --color-primary-deep-0-dark: #47126b; --color-accent-dark: #ea698b; --color-accent-deep-0-dark: #d55d92; --color-accent-deep-1-dark: #c05299; --color-accent-deep-2-dark: #c05299; --color-background-dark: #0a0f1c; --font-primary-dark: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; }
This file is auto-generated and imported into the app.
Tailwind + Runtime CSS Variables
With this setup, usage becomes extremely clean:
<div class="bg-primary dark:bg-primary-deep text-accent dark:text-accent-deep"> Hello themed world! </div>
No need to hardcode Tailwind color names or rebuild config — everything is centralized and flexible.
Easy Dark Mode and Beyond
Thanks to @custom-variant dark
, this structure supports .dark
selectors out of the box. But more importantly, it’s easy to add other modes:
- Add
daylight
,retro
,solarized
, or any custom modes totheme.ts
- Extend the script to generate
--color-*--retro
variables - Use
class="retro"
or similar to toggle themes
Dark/light modes are simple and future-friendly.
Why Keeping Theme Centralized Matters
I think it’s extremely important to define all theme values in one place — not scattered across Tailwind config, SCSS files, and JS constants. This setup ensures:
- Type safety in app logic
- No duplication or drift
- Theme switching at runtime with zero JS updates
- Full visibility into how your design system behaves
In Summary
- I avoid embedding colors directly in
tailwind.config.js
- I define a typed
theme.ts
file and generate CSS variables from it - I use Tailwind’s native
@theme
and@custom-variant
support - This allows scaling to many theme modes (dark, daylight, retro, etc.) effortlessly
✅ Bonus: Where This Helps
- Dashboard-driven theme customization
- Runtime A/B testing of styles
- Building Figma-style token systems with Tailwind
I’ve found this setup modern, scalable, and type-safe. If you're using Tailwind 3.4+, I highly recommend trying it.
Let me know if you want a GitHub repo or CLI template for this approach!
Top comments (0)