EmpireUI
Get Pro
← Blog8 min read#dark mode#light mode#tokens

Dark/Light Token System: Semantic Values, Not Color Names

Stop naming tokens after colors. Here's how semantic design tokens actually work across dark and light themes — with real CSS and practical examples.

Dark and light mode UI interface showing token-based color system

The Problem With Color Names

You've seen it. --blue-500. --gray-200. --white. These names feel fine until the second week, when your design lead decides the "blue" primary button should actually be indigo in dark mode. Now you're grep-ing across 60 files wondering how many components hardcoded that value.

Color-named tokens are raw values pretending to be a system. They tell you *what* something looks like, not *what it means*. And the moment you add a second theme — whether it's dark mode, a high-contrast variant, or a customer's brand skin — that naming falls apart completely.

The fix isn't complicated. It just requires a mindset shift: your token layer should describe *intent*, not appearance. --color-surface-primary means something. --gray-100 means nothing beyond the gray it happens to be today.

Worth noting: this isn't a Tailwind problem or a CSS-in-JS problem. It's a naming problem. Every styling tool gives you this trap and every styling tool lets you avoid it — the discipline is yours to apply.

The Two-Layer Token Architecture

The right model is two layers, not one. Your first layer is raw (or 'primitive') tokens. These are your full palette — every shade, every opacity, every value in your design system. Call them --palette-blue-600, --palette-slate-900, whatever. These tokens never go into components directly.

Your second layer is semantic tokens. These reference the palette, and *these* are what your components consume. The semantic layer is what you swap at the theme boundary.

/* Layer 1 — primitive palette */
:root {
  --palette-slate-900: #0f172a;
  --palette-slate-100: #f1f5f9;
  --palette-indigo-600: #4f46e5;
  --palette-indigo-400: #818cf8;
  --palette-white: #ffffff;
  --palette-black: #000000;
}

/* Layer 2 — semantic tokens, light theme */
:root {
  --color-bg-base:         var(--palette-white);
  --color-bg-surface:      var(--palette-slate-100);
  --color-text-primary:    var(--palette-slate-900);
  --color-text-secondary:  oklch(40% 0.02 240);
  --color-accent:          var(--palette-indigo-600);
  --color-accent-subtle:   oklch(95% 0.05 265);
}

/* Dark theme override — only semantic tokens change */
[data-theme="dark"] {
  --color-bg-base:         var(--palette-slate-900);
  --color-bg-surface:      oklch(18% 0.01 240);
  --color-text-primary:    var(--palette-white);
  --color-text-secondary:  oklch(70% 0.02 240);
  --color-accent:          var(--palette-indigo-400);
  --color-accent-subtle:   oklch(22% 0.08 265);
}

Notice how switching themes is a one-stop override. You're not hunting through component styles — you're flipping the semantic layer. Components stay identical. Their meaning changes based on context, exactly as intended.

Honestly, this is where most teams skip a step and regret it. They implement dark mode by pasting a ton of per-component overrides under a .dark class. It works for month one. By month six you've got contradictions everywhere and nobody wants to touch the theming code.

Naming Semantic Tokens That Don't Lie

The most common mistake after adopting semantic tokens is naming them badly anyway. --color-primary is a semantic token — but primary *what*? Primary text? Primary button? Primary border? That ambiguity kills you when the primary button color and primary text color diverge (and they always do).

Good semantic token names answer: what is this used for? A reliable taxonomy breaks down by role: bg (backgrounds), text, border, accent, feedback, icon. Then by prominence: base, subtle, strong, inverse. Combine them and you get names that are immediately legible.

--color-bg-base          → page background
--color-bg-surface       → card, panel, popover
--color-bg-overlay       → modal backdrop, tooltip
--color-text-primary     → body copy, headings
--color-text-secondary   → captions, labels, muted text
--color-text-disabled    → inactive states
--color-border-default   → standard rule, divider
--color-border-focus     → keyboard focus ring (usually 2px offset)
--color-accent           → CTAs, links, selections
--color-feedback-error   → form errors, destructive actions
--color-feedback-success → confirmation states

In practice, you'll need more granularity than this over time, but this set covers 80% of a typical product UI. Start here and add as real needs surface — don't pre-generate 200 tokens for cases you'll never hit.

One more thing — interactive states deserve their own tokens too. Don't compute hover states by mixing colors at render time. Define --color-accent-hover and --color-accent-active explicitly. That way dark mode gets its own hover treatment without any logic in component code.

Wiring Tokens to CSS and Components

Once your semantic layer exists, using it is almost mechanical. Every component references semantic tokens only — never the palette, never hardcoded hex values. If you're using vanilla CSS, this looks like:

.button-primary {
  background: var(--color-accent);
  color: var(--color-text-primary);
  border: 1px solid var(--color-border-default);
}

.button-primary:hover {
  background: var(--color-accent-hover);
}

.card {
  background: var(--color-bg-surface);
  color: var(--color-text-primary);
  border: 1px solid var(--color-border-default);
  border-radius: 12px;
}

If you're in Tailwind 4 (released early 2025), you can register CSS custom properties and expose them as utilities in your @theme block, which gives you that same token-driven approach inside Tailwind's utility class syntax. Either way, the semantic token is the source of truth.

Quick aside: the color-scheme property matters here. Set color-scheme: dark on your dark theme root and browsers will handle native UI elements — scrollbars, form inputs, date pickers — in dark mode automatically. Without it, you'd manually override every single browser default.

[data-theme="dark"] {
  color-scheme: dark;
  /* ... your semantic token overrides ... */
}

For React, you'd typically read a theme state from context or a data-theme attribute on the <html> element and let CSS do the heavy lifting. No JavaScript color computation. No inline styles. The component just uses the class names, and the tokens do the rest. You can see this pattern in action across most components in Empire UI — the dark/light switching is entirely CSS-driven.

Tokens in Practice: OKLCH and Perceptual Consistency

Here's something most token guides skip. When you define raw palette values, using oklch() instead of hex gives you perceptually uniform steps. Two colors at 60% lightness in OKLCH look equally bright to your eye — two colors at #60... hex values almost certainly don't.

For 2026 projects this matters more than it used to, because oklch has had solid browser support since 2023 and Figma added native oklch in version 116. Your design and dev environments can agree on the same perceptual model instead of converting across color spaces and losing accuracy.

/* Palette defined in OKLCH — perceptually uniform steps */
:root {
  --palette-neutral-100: oklch(97% 0.005 240);
  --palette-neutral-200: oklch(92% 0.008 240);
  --palette-neutral-500: oklch(60% 0.01 240);
  --palette-neutral-800: oklch(28% 0.015 240);
  --palette-neutral-900: oklch(16% 0.02 240);

  --palette-accent-400:  oklch(68% 0.18 265);
  --palette-accent-600:  oklch(52% 0.22 265);
}

In practice, this approach eliminates the "that blue looks washed out in dark mode" complaints you'd otherwise spend half a sprint debugging. Equal lightness in OKLCH means equal perceived contrast. That's what you want.

The glassmorphism generator on Empire UI uses this kind of perceptual color thinking for its blur and opacity calculations — worth opening it just to see how the values behave at different lightness levels when you drag the controls.

Exporting Tokens: Style Dictionary and Friends

If you're building a design system that needs to export tokens to multiple platforms — React web, React Native, iOS, Android — Style Dictionary (now at v4, released 2024) is the standard tool. You define tokens once in JSON or YAML and generate CSS custom properties, Sass variables, JS constants, Swift colors, all from the same source.

{
  "color": {
    "bg": {
      "base": {
        "$value": "{palette.white}",
        "$type": "color",
        "$description": "Default page background"
      },
      "surface": {
        "$value": "{palette.slate.100}",
        "$type": "color"
      }
    },
    "accent": {
      "$value": "{palette.indigo.600}",
      "$type": "color"
    }
  }
}

Style Dictionary v4 supports the W3C Design Tokens Community Group format (the $value / $type schema above), which Figma's variables export also targets. That means you can pipe Figma tokens directly into Style Dictionary without a transformation step — assuming your designer named them semantically in Figma first.

Look, this pipeline is overkill for a solo project or a small SaaS. If you're just shipping a web app, vanilla CSS custom properties and a well-organized token file is all you need. Reach for Style Dictionary when you have multiple consumers of the same token set, or a dedicated design team who owns the Figma source of truth.

One more thing — document your token decisions. Not the values, the *reasons*. Why does --color-bg-overlay use 60% opacity instead of 50%? Write that down somewhere. Six months from now, nobody remembers. You can check out design-tokens-guide on the blog for a deeper primer on the token naming conventions that pair well with this system.

Common Pitfalls and How to Avoid Them

Token proliferation is real. Teams start with 30 tokens and end up with 400 within a year, most of which are one-off overrides for a single component. The fix: a strong naming convention enforced at PR review, and a rule that adding a new token requires removing or consolidating an old one unless the use case is genuinely new.

Don't mix token layers in components. If you catch a var(--palette-indigo-600) in a component file, that's a bug. Your primitive palette exists only to be referenced by semantic tokens. The component layer should never know what color anything actually is — it should only know what *role* a color plays.

/* Bad — component knows a raw color */
.badge {
  background: var(--palette-green-500);
}

/* Good — component knows a role */
.badge {
  background: var(--color-feedback-success);
}

Missing state tokens are the other common gap. Most teams define base, hover, and maybe active for interactive tokens. They forget focus-visible, disabled, and loading. Add those from day one. A 2px focus ring at --color-border-focus that's visible in both themes is non-negotiable for accessibility — WCAG 2.2 criterion 2.4.11 mandates a minimum 3:1 contrast for focus indicators.

Check out Empire UI's component library — most interactive elements there already show how a complete set of interactive state tokens works in a real component, dark and light. Much easier to reverse-engineer from working examples than to spec it from scratch.

The system you build here is boring by design. That's the point. Tokens should be invisible infrastructure — working correctly means nobody notices them. When the day comes to ship a high-contrast accessibility mode or a customer white-label theme, you'll be glad you did the dull naming work up front.

FAQ

Can I use semantic tokens with Tailwind CSS?

Yes. In Tailwind 4, you define custom properties inside an @theme block and Tailwind generates utilities from them. In Tailwind 3, you add them to the extend.colors config. Either way, your semantic tokens become the source, and Tailwind classes reference them — you don't hardcode palette values anywhere.

What's the difference between primitive and semantic tokens?

Primitive tokens are your raw palette — every shade in your color ramp. Semantic tokens reference those primitives and describe *intent*: --color-bg-surface, --color-text-primary. Only semantic tokens go into components. This split is what makes theme switching clean.

How many semantic tokens should a design system have?

Start with around 30-40. Cover backgrounds (3-4 levels), text (primary, secondary, disabled, inverse), borders, accents with their interactive states, and feedback colors. Add more only when a real use case demands it — 400-token systems are usually just poorly categorized 40-token systems.

Do semantic tokens work with CSS-in-JS libraries like styled-components?

Absolutely. You still define the tokens as CSS custom properties on :root and [data-theme="dark"], then reference them in your styled-components via background: var(--color-bg-surface). The token layer is pure CSS — it doesn't care what styling library reads it.

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

Read next

Dark Mode Color Tokens: Building a Theme That Doesn't Break EverythingSemantic Color Tokens: From Raw Palette to Intent-Based Design SystemDark Mode Color Palette System: Semantic Tokens That Actually WorkImplementing Dark Mode in React: CSS Variables, Tailwind, System Preference