Back to CSS Magic

Semantic Color Tokens with CSS Variables

Themeable UI colors without teaching every component your domain model. No JavaScript. No token pipeline.

In Ward #4, the rendering kernel learned what nurse shift colors meant and the architecture got softer. Same problem can arrive through your CSS, one well-meaning class at a time.

The problem

A designer hands you three colors: day is blue, evening is orange, night is purple. Reasonable. Three weeks later your stylesheet looks like this:

The smearcss
.shift-card.day {
  border-left-color: blue;
  background: rgba(0, 100, 255, 0.08);
}

.shift-card.evening {
  border-left-color: orange;
  background: rgba(255, 160, 0, 0.08);
}

.shift-card.night {
  border-left-color: purple;
  background: rgba(130, 80, 220, 0.08);
}

.shift-badge.night       { color: purple; }
.legend-item.night::before { background: purple; }

It works. It keeps working until any of these arrive:

  • dark mode
  • a brand palette change
  • contrast failures in WCAG testing
  • a second component that wants the same color
  • a question like “is night purple everywhere, or only in this view?”

By that point your domain model lives in twelve slightly different purples scattered across the codebase. The fix is small and unglamorous.

The solution: tokens at the boundary, variables inside the component

Define domain colors once, in tokens. Inside the component, use a single local variable. Map states to that variable.

Tokenscss
:root {
  --color-shift-day:     hsl(212 84% 55%);
  --color-shift-evening: hsl(32 92% 55%);
  --color-shift-night:   hsl(270 65% 60%);

  --color-surface: hsl(222 47% 11%);
  --color-text:    hsl(210 40% 98%);
}
Componentcss
.shift-card {
  --accent: hsl(215 20% 45%);
  --accent-soft: color-mix(in oklab, var(--accent) 16%, transparent);

  border-left: 4px solid var(--accent);
  background:
    linear-gradient(90deg, var(--accent-soft), transparent 60%),
    var(--color-surface);
  color: var(--color-text);
}

.shift-card[data-shift="day"]     { --accent: var(--color-shift-day); }
.shift-card[data-shift="evening"] { --accent: var(--color-shift-evening); }
.shift-card[data-shift="night"]   { --accent: var(--color-shift-night); }

The card knows it has an accent. It does not know why purple means night. That is the whole trick.

Live demo

Three states. One component. Switch the theme and watch the same --accent token resolve to different values without teaching the card what “night shift” means.

Day shift
Ward 4
07:00 – 15:00
Evening shift
Ward 4
15:00 – 22:00
Night shift
Ward 4
22:00 – 07:00
--color-shift-day
--color-shift-evening
--color-shift-night

Why the local variable matters

--accent is the component's internal API. Every piece of the card refers to it: border, background, label, icon. When the state changes, you change one value and everything else follows.

One variable, many usescss
.shift-card        { border-left-color: var(--accent); }
.shift-card__label { background: color-mix(in oklab, var(--accent) 22%, transparent); }
.shift-card__icon  { color: var(--accent); }

Without it, every state needs its own ruleset for every property. With it, one selector covers the whole card.

Same boundary lesson, in CSS form

The component owns how it looks. The token owns what it means. The data attribute owns which state applies. Don't let those leak across.

Dark mode

Override the tokens, leave the component alone. The component reads --accent and does not care which scheme is active.

Theme overridescss
@media (prefers-color-scheme: light) {
  :root {
    --color-surface:       hsl(0 0% 100%);
    --color-text:          hsl(222 47% 11%);

    --color-shift-day:     hsl(212 84% 42%);
    --color-shift-evening: hsl(32 88% 42%);
    --color-shift-night:   hsl(270 55% 48%);
  }
}

Accessibility

Color is a hint, not a label. Each card carries a visible shift-card__label with the shift name. A user who cannot tell the three accent colors apart still reads “Night shift” in plain text.

Two things to watch:

  • Test contrast in both themes separately. The same token resolves to two values; only one of them is on screen at a time.
  • color-mix() is not a contrast checker. Verify the mixed colors against text and background with a real tool.

Browser support and fallback

CSS custom properties are supported everywhere modern. color-mix() is in current Chrome, Edge, Safari, and Firefox. If you support an older runtime, pre-compute the soft variants and progressively enhance.

Progressive enhancementcss
.shift-card {
  --accent: hsl(270 65% 60%);
  --accent-soft: hsl(270 65% 60% / 0.16);
}

@supports (background: color-mix(in oklab, red, blue)) {
  .shift-card {
    --accent-soft: color-mix(in oklab, var(--accent) 16%, transparent);
  }
}

Common pitfalls

Hardcoding domain colors inside components

Fine for a prototype. Painful the first time night needs to be lavender in dark mode and plum in high-contrast mode.

Using palette names where meaning belongs

--purple-500 is a palette token. It tells you what the color is. --color-shift-night is a semantic token. It tells you what the color does. Keep both layers. Components use the semantic one.

Inventing tokens nobody understands

If --color-state-contextual-secondary-muted-emphasis shows up, the abstraction has stopped helping. Tokens should make a stylesheet smaller, not longer.

When not to use this

  • The UI is one-off. A throwaway landing page does not need a token layer.
  • There is no shared meaning. A decorative gradient does not need a name.
  • You already have a design system. Use that. Do not introduce a parallel set of tokens in one stylesheet.

Use semantic tokens where they reduce duplication, clarify meaning, or protect a boundary you actually care about. Everywhere else, plain CSS is fine.