DEV Community

abtahi-tajwar
abtahi-tajwar

Posted on

Type-Safe Theming in Tailwind CSS Using CSS Variables and TypeScript

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', } } } 
Enter fullscreen mode Exit fullscreen mode

But in my experience, this introduces several limitations:

  1. 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.
  2. 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.
  3. Theme values scattered across multiple places

    • You may have tailwind.config.js, SASS/SCSS maps, and JS constants — causing desynchronization and maintenance headaches.

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; 
Enter fullscreen mode Exit fullscreen mode

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'); 
Enter fullscreen mode Exit fullscreen mode

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"; } 
Enter fullscreen mode Exit fullscreen mode

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> 
Enter fullscreen mode Exit fullscreen mode

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 to theme.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)