EmpireUI
Get Pro
← Blog8 min read#tailwind colors#css variables#oklch

Tailwind Custom Colors: CSS Variables, OKLCH, and Design Tokens

Master Tailwind custom colors using CSS variables, OKLCH color space, and design tokens — build a scalable palette that survives dark mode, theming, and real production codebases.

Developer writing code on a laptop with colorful CSS on screen

Why Default Tailwind Colors Break Down at Scale

Out of the box, Tailwind's color palette is genuinely good. Fifty shades of slate, sky, rose — it gets you moving fast. But the moment a designer hands you a Figma file with a custom brand palette, or you need dark mode that doesn't just invert everything, the default config starts fighting you.

The core problem is hardcoded hex values. When brand-500 is #6D28D9 burned directly into your config, you can't change it at runtime. You can't toggle themes without shipping a second stylesheet. You definitely can't let users pick an accent color.

That's where CSS variables come in. They're the missing piece between Tailwind's utility-first approach and a genuinely flexible design system. Introduced properly in Tailwind v3 and now first-class in v4, CSS variable–backed colors are how serious projects handle theming.

Wiring CSS Variables Into Your Tailwind Config

The setup is straightforward but easy to mess up if you skip a step. You define your variables in CSS, then point Tailwind at them. Here's the pattern that actually works in production:

/* globals.css */
:root {
  --color-brand: 109 40 217;
  --color-surface: 255 255 255;
  --color-on-surface: 15 15 15;
}

[data-theme="dark"] {
  --color-surface: 18 18 18;
  --color-on-surface: 245 245 245;
}

Notice the values are space-separated RGB channels, not hex. That's intentional. Tailwind's color opacity modifier (bg-brand/50) works by injecting rgb(var(--color-brand) / 0.5) — it needs the channels separate. If you write full hex, you lose opacity support entirely.

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        brand: 'rgb(var(--color-brand) / <alpha-value>)',
        surface: 'rgb(var(--color-surface) / <alpha-value>)',
        'on-surface': 'rgb(var(--color-on-surface) / <alpha-value>)',
      },
    },
  },
};

Now bg-brand, text-brand/80, border-surface/20 all work exactly like built-in colors. Switching themes is just toggling a data-theme attribute on <html>. Worth noting: this approach works with any framework — Next.js, Vite, Remix, doesn't matter.

OKLCH: The Color Space Worth Actually Learning

Here's the thing about RGB and HSL — they lie to you. A 50% lightness in HSL produces wildly different perceived brightness depending on the hue. Your yellow at hsl(60, 100%, 50%) looks nothing like the same "lightness" in purple. Design systems built on HSL end up with janky contrast inconsistencies you spend weeks chasing.

OKLCH fixes this. It's a perceptually uniform color space, meaning equal numeric steps produce equal perceived changes. Lightness in OKLCH actually means lightness. That matters enormously when you're building accessible color scales.

Tailwind v4 (released in early 2025) uses OKLCH as its default color format internally. If you're still on v3, you can start using it manually right now — modern browsers have supported oklch() since Chrome 111 in 2023. The syntax is oklch(L C H) where L is 0–1, C is chroma (0–0.4ish), and H is hue angle 0–360.

:root {
  /* A purple that's genuinely the same lightness as the blue */
  --color-primary: oklch(0.55 0.22 280);
  --color-secondary: oklch(0.55 0.18 220);
  
  /* Generate a tint/shade by only adjusting L */
  --color-primary-light: oklch(0.75 0.22 280);
  --color-primary-dark: oklch(0.35 0.22 280);
}

Honestly, once you build a palette in OKLCH you can't go back. The predictability alone is worth the learning curve. And when you're building something like the glassmorphism components on Empire UI — where background blur interacts directly with color saturation — perceptual uniformity makes a massive difference in how the final result looks.

Design Tokens: The Layer Between Figma and Code

Design tokens are named values that sit above raw color definitions. Think --color-brand is a primitive token. --color-action-primary is a semantic token that *references* the primitive. This distinction is what separates a color system from a color list.

The semantic layer is what makes theming work cleanly. Your button doesn't care about blue-500. It cares about action-primary. When the brand pivots from blue to green, you change one variable, not 47 component files.

/* Layer 1: primitives */
:root {
  --palette-blue-500: oklch(0.55 0.22 250);
  --palette-green-500: oklch(0.55 0.20 145);
  --palette-neutral-900: oklch(0.18 0.01 260);
}

/* Layer 2: semantic tokens */
:root {
  --color-action-primary: var(--palette-blue-500);
  --color-text-base: var(--palette-neutral-900);
}

[data-brand="green"] {
  --color-action-primary: var(--palette-green-500);
}

Then in Tailwind config you only expose semantic tokens — not primitives. Your teammates can't accidentally use palette-blue-500 directly in a component, because it's not a Tailwind class. That's a guardrail worth having.

Quick aside: the W3C Design Tokens spec is still evolving, but tools like Style Dictionary have been stable since 2021 and translate JSON token files to CSS variables, JS objects, iOS/Android formats simultaneously. Worth adding to any serious design system workflow.

Dark Mode Without the Headaches

Most dark mode implementations are hacks. You end up with dark:bg-gray-900 dark:text-white dark:border-gray-700 on literally every element. It scales terribly and it's impossible to hand off to someone else.

The CSS variable approach sidesteps all of this. Your component just says bg-surface text-on-surface. Dark mode is handled entirely in CSS by redefining variables under a [data-theme="dark"] selector or @media (prefers-color-scheme: dark). Zero Tailwind dark: prefixes required.

This pairs extremely well with components that have complex visual states. If you're building something like a card from the Empire UI component library, you want the background, border, shadow, and text colors to all flip simultaneously without touching JSX. One attribute swap on the root element handles everything.

One more thing — you can do this progressively. Start with just --color-surface and --color-on-surface. Add more semantic tokens as you refactor components. You don't need to rearchitect the whole project on day one.

Practical Tailwind v4 Upgrade Notes

Tailwind v4 changes the game meaningfully here. CSS-first configuration means you define your theme *in CSS*, not tailwind.config.js. Your entire color system lives in a single @theme block:

@import "tailwindcss";

@theme {
  --color-brand: oklch(0.55 0.22 280);
  --color-brand-light: oklch(0.75 0.22 280);
  --color-surface: oklch(0.99 0 0);
  --color-surface-elevated: oklch(0.96 0 0);
}

That's it. No config file. Tailwind auto-generates bg-brand, text-brand, border-brand-light, and every opacity variant. The <alpha-value> wiring happens automatically in v4 — you don't have to write the rgb(var(--color-brand) / <alpha-value>) pattern anymore.

In practice, v4 makes the jump to a proper token system so much lower-friction that there's little reason to stay on v3 for new projects. That said, migration from v3 is non-trivial if you've got a large codebase — the official migration guide is thorough and worth reading before you start. Also check the tailwind-css-animations article here if you're rebuilding animations as part of a v4 migration.

Putting It All Together: A Minimal Token System

Here's a pattern you can drop into a real project today. It's not theoretical — this is close to what's running in production on several Empire UI templates:

/* design-tokens.css */
@layer base {
  :root {
    /* Primitives */
    --palette-purple-500: oklch(0.52 0.25 280);
    --palette-purple-400: oklch(0.66 0.22 280);
    --palette-neutral-50: oklch(0.98 0 0);
    --palette-neutral-950: oklch(0.14 0.01 260);

    /* Semantic */
    --color-brand: var(--palette-purple-500);
    --color-brand-hover: var(--palette-purple-400);
    --color-bg: var(--palette-neutral-50);
    --color-fg: var(--palette-neutral-950);
  }

  [data-theme="dark"] {
    --color-bg: var(--palette-neutral-950);
    --color-fg: var(--palette-neutral-50);
  }
}
// tailwind.config.js (v3 compatible)
module.exports = {
  theme: {
    extend: {
      colors: {
        brand: 'oklch(var(--color-brand) / <alpha-value>)',
        bg: 'var(--color-bg)',
        fg: 'var(--color-fg)',
      },
    },
  },
};

Does this add some upfront complexity? Yes. Is it worth it on any project you expect to maintain for longer than six months? Absolutely. The gradient generator tool on Empire UI, for instance, uses exactly this kind of semantic token structure to handle its theme-aware preview rendering.

Look, you don't need to implement every layer on day one. Start with a handful of semantic tokens for your primary color and surfaces. Build the full primitive layer when the design system actually needs it. The infrastructure is there when you're ready.

FAQ

Can I use OKLCH values directly in Tailwind CSS variables?

Yes, and you should. Modern browsers (Chrome 111+, Firefox 113+, Safari 15.4+) all support oklch() natively. Just set --color-brand: oklch(0.55 0.22 280) and reference it in your config or @theme block.

Do Tailwind opacity modifiers work with CSS variable–based colors?

They do, but only if you use the rgb(var(--color) / <alpha-value>) pattern in v3. In Tailwind v4 with @theme, opacity modifiers work automatically with any color format including OKLCH.

What's the difference between primitive and semantic design tokens?

Primitives are raw values like --palette-blue-500. Semantic tokens reference primitives with meaningful names like --color-action-primary. Components use semantic tokens so retheming only requires changing one reference point, not hunting through every file.

Should I migrate to Tailwind v4 for better color support?

For new projects, yes — v4's CSS-first @theme block makes token-based color systems much cleaner. For existing v3 projects, weigh the migration cost; the CSS variable pattern described here works fine on v3 too.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Tailwind CSS OKLCH Colors: Perceptually Uniform Palettes in v4Tailwind Shadow System: Custom Shadows, Colored Drop Shadows, BlurDesign Tokens in 2026: From Figma Variables to CSS Custom PropertiesMotion Design Tokens: Systematising Easing, Duration and Delay