Semantic Color Tokens: From Raw Palette to Intent-Based Design System
Stop naming colors after hex values. Semantic color tokens map intent to palette — here's how to build a token system that actually survives dark mode and rebrands.
Why Raw Palette Tokens Fail You
You've seen it before. A design system ships tokens named blue-500, gray-200, red-600. Looks clean in Figma. Then six months later the brand team decides the primary color is shifting to teal, dark mode ships in Q3, and suddenly you're grep-replacing hex values across 40 components. Every engineer hates tokens week.
The root problem is that raw palette tokens encode *value*, not *intent*. blue-500 tells you what color it is. It tells you nothing about *why* it's used or *where*. Is it a button background? A link? A badge? An icon? When you don't know the answer, you can't swap the color systematically — you're just doing find-and-replace and hoping nothing breaks.
Semantic tokens solve this by adding an indirection layer. Instead of components consuming blue-500 directly, they consume color.action.primary — which happens to resolve to blue-500 in light mode and blue-400 in dark mode, and teal-something after the rebrand. The component doesn't care. It just knows what the color is *for*.
Honestly, most teams skip the semantic layer because it feels like extra work up front. It is. But you're trading 2 hours of token architecture for potentially months of pain when product requirements change — and they always change.
The Two-Tier Token Model
The industry has largely converged on a two-tier model: primitive tokens (aka reference tokens) at the bottom, semantic tokens on top. Some teams add a third "component" tier. That's fine — but start with two and add the third only when you actually need it.
Primitive tokens are your full palette. They don't carry meaning, just values. You might define 11 steps per hue (50 through 950 in Tailwind's model), plus neutrals, and a handful of absolute values like white and black. These tokens *are* your design language's raw material — they should never be consumed directly by components.
``json
// primitives.json
{
"color": {
"blue": {
"400": { "value": "#60a5fa" },
"500": { "value": "#3b82f6" },
"600": { "value": "#2563eb" }
},
"neutral": {
"0": { "value": "#ffffff" },
"900": { "value": "#111827" }
}
}
}
``
Semantic tokens sit on top and reference primitives by name. They express intent in a product-language vocabulary — background.surface, text.primary, border.focus, action.primary.default. They're the tokens your components actually import.
``json
// semantic.light.json
{
"color": {
"background": {
"surface": { "value": "{color.neutral.0}" },
"sunken": { "value": "{color.neutral.50}" }
},
"text": {
"primary": { "value": "{color.neutral.900}" },
"muted": { "value": "{color.neutral.500}" }
},
"action": {
"primary": {
"default": { "value": "{color.blue.500}" },
"hover": { "value": "{color.blue.600}" }
}
}
}
}
``
Worth noting: the {color.blue.500} curly-brace syntax is the W3C Design Tokens spec format, supported by Style Dictionary, Tokens Studio, and most modern toolchains as of 2025. If your toolchain uses a different alias syntax, the concept is identical — you're just pointing semantic names at primitive values.
Dark mode is then just a second semantic file with different primitive references. text.primary points at neutral.900 in light, neutral.50 in dark. Your components never change. That's the whole win.
Naming Conventions That Actually Scale
Naming is where most token systems quietly fall apart. Teams either go too vague (primary, secondary) or too specific (card-header-title-color). Both extremes cause problems. Too vague and tokens get overloaded with multiple meanings. Too specific and you end up with 800 tokens that nobody can memorise.
A naming structure that works well in practice: {category}.{role}.{variant}.{state}. For color specifically: color.{category}.{role}.{state}. The categories I'd suggest are background, text, border, icon, action, and feedback. Under each, you define roles like primary, secondary, inverse, danger, warning, success. States are default, hover, active, disabled.
Here's what that looks like for a button in CSS custom properties:
``css
/* Generated from semantic tokens */
:root {
--color-action-primary-default: #3b82f6;
--color-action-primary-hover: #2563eb;
--color-action-primary-text: #ffffff;
--color-action-primary-border: transparent;
}
[data-theme="dark"] {
--color-action-primary-default: #60a5fa;
--color-action-primary-hover: #3b82f6;
--color-action-primary-text: #0f172a;
}
.btn-primary {
background: var(--color-action-primary-default);
color: var(--color-action-primary-text);
border: 1px solid var(--color-action-primary-border);
}
.btn-primary:hover {
background: var(--color-action-primary-hover);
}
``
In practice, the hardest naming call you'll make is what counts as a feedback color vs a general purpose color. Keep feedback.success, feedback.warning, feedback.error, and feedback.info strictly for status messaging — alerts, toasts, validation. Don't repurpose green from feedback.success as a general accent; pull a separate primitive instead. One token, one job.
Quick aside: if you're using Tailwind, you map semantic tokens into tailwind.config.js under theme.extend.colors. That way you get text-text-primary and bg-action-primary-default utility classes generated automatically — no custom CSS needed for basic consumption.
Wiring Up Dark Mode Without the Headache
Dark mode implementation comes down to *where* you apply the palette swap. There are three common approaches: CSS custom properties with a [data-theme] attribute, CSS prefers-color-scheme media queries, or JS-driven class toggling. The most flexible is the data-theme attribute approach — you get user-controlled override *and* OS-preference detection, and it's what most production systems in 2026 reach for.
Your build pipeline (Style Dictionary, Theo, or even a custom script) generates two CSS files — or two blocks in one file. The light values land on :root, the dark values land on [data-theme="dark"] or .dark. That's it. No JavaScript color logic, no runtime palette calculations. Just CSS doing what CSS is good at:
``css
:root {
--color-background-surface: #ffffff;
--color-text-primary: #111827;
--color-border-default: #e5e7eb;
}
[data-theme="dark"] {
--color-background-surface: #0f172a;
--color-text-primary: #f1f5f9;
--color-border-default: #1e293b;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-background-surface: #0f172a;
--color-text-primary: #f1f5f9;
--color-border-default: #1e293b;
}
}
``
The not([data-theme="light"]) selector is the important bit — it means OS dark mode applies automatically until the user explicitly picks light, at which point the attribute wins. That's the behavior users expect and it's surprisingly tricky to get right without this pattern.
One more thing — don't forget to audit contrast ratios across both modes separately. A token pair that hits WCAG AA at 4.5:1 in light mode can easily drop to 2.8:1 in dark mode if you just invert the palette naively. Tools like Token Studio's contrast checker or even a quick pass through the browser's accessibility panel will catch these before they ship.
If you're building within a design system that already handles a lot of this surface complexity — layered backgrounds, glassy overlays, themed interactive states — you'll find that a solid semantic token foundation makes everything click. It's exactly the kind of infrastructure that makes styles like those you'll find in the glassmorphism components actually maintainable at scale, since those effects depend on getting background and border colors exactly right across modes.
Tooling: Style Dictionary and Friends
Style Dictionary by Amazon is the industry standard token pipeline. You define tokens in JSON (or YAML), write transform and format configs, and it outputs CSS custom properties, Sass variables, iOS Swift files, Android XML — whatever you need. As of Style Dictionary v4 (released late 2024), it natively supports the W3C Design Tokens spec format, so the curly-brace alias syntax works out of the box.
A minimal Style Dictionary config that outputs both light and dark CSS:
``js
// config.js
import StyleDictionary from 'style-dictionary';
const sd = new StyleDictionary({
source: ['tokens/primitives.json'],
platforms: {
css: {
transformGroup: 'css',
files: [
{
destination: 'dist/tokens.light.css',
format: 'css/variables',
filter: token => token.filePath.includes('light'),
options: { selector: ':root' }
},
{
destination: 'dist/tokens.dark.css',
format: 'css/variables',
filter: token => token.filePath.includes('dark'),
options: { selector: '[data-theme="dark"]' }
}
]
}
}
});
await sd.buildAllPlatforms();
``
If your team lives in Figma, Tokens Studio (formerly Figma Tokens) is the companion plugin that lets designers manage tokens directly in Figma and sync them to a GitHub repo. The round-trip — designer updates a token in Figma, pushes to GitHub, CI runs Style Dictionary, CSS updates deploy — is genuinely smooth once it's set up. That's a workflow worth the afternoon it takes to configure.
That said, don't let tooling become the project. I've seen teams spend three weeks arguing about whether to use Style Dictionary or Theo while their engineers are still hardcoding hex values in component files. Pick something, ship the primitives and a starter set of semantics, iterate. Perfect is the enemy of shipped.
For Tailwind-first codebases, you might skip a build pipeline entirely in the early stages and just maintain your semantic tokens directly in tailwind.config.ts. It's not as portable, but it gets you the semantic naming benefits with zero extra infrastructure. You can migrate to a proper pipeline later when you actually need iOS output or multi-platform delivery.
Applying Tokens in a React Component System
Tokens are only useful if components actually use them. The discipline here is simple but requires constant enforcement: components should *never* reference a primitive token or a raw color value directly. Everything goes through semantic tokens. A linter rule (a custom ESLint rule or Stylelint rule checking for bare hex values in component files) enforces this at CI time so it doesn't rely on code review vigilance.
Here's a typed React component that consumes tokens purely through CSS custom properties — no JS color logic, no theme context juggling:
``tsx
// Badge.tsx
import styles from './Badge.module.css';
type Variant = 'success' | 'warning' | 'error' | 'info';
interface BadgeProps {
variant: Variant;
children: React.ReactNode;
}
export function Badge({ variant, children }: BadgeProps) {
return (
<span
className={styles.badge}
data-variant={variant}
>
{children}
</span>
);
}
`
`css
/* Badge.module.css */
.badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
background: var(--color-feedback-surface);
color: var(--color-feedback-text);
border: 1px solid var(--color-feedback-border);
}
.badge[data-variant="success"] {
--color-feedback-surface: var(--color-feedback-success-surface);
--color-feedback-text: var(--color-feedback-success-text);
--color-feedback-border: var(--color-feedback-success-border);
}
.badge[data-variant="error"] {
--color-feedback-surface: var(--color-feedback-error-surface);
--color-feedback-text: var(--color-feedback-error-text);
--color-feedback-border: var(--color-feedback-error-border);
}
``
Notice the local CSS variable scoping trick — --color-feedback-surface is a local alias that gets overridden per variant using data-variant. This pattern keeps the base styles generic while variant-specific values come from the semantic token layer. Dark mode just works because the underlying semantic tokens switch at the :root level.
Look, this approach does require a small mental shift from teams used to Tailwind utility-first workflows. You're reaching for var(--color-action-primary-default) instead of bg-blue-500. But it pays off the moment you need to theme a white-label version of your product or ship a high-contrast accessibility mode. Those scenarios suddenly become a one-token-file change instead of a 200-component audit.
If you're working with a style-heavy component library — lots of layered effects, gradient backgrounds, ambient shadows — connecting it to a semantic token system also makes your gradient generator output far more useful. Instead of one-off hardcoded gradients, you're defining gradient stops that reference color.brand.start and color.brand.end, so the whole gradient updates when the brand color changes. That's the kind of composability that makes design systems worth building.
Governance: Keeping Your Token System Alive
Token systems rot. Teams add one-off tokens under deadline pressure, someone renames a primitive without updating semantics, a new engineer doesn't know the rules and hardcodes #3b82f6 in a component. Six months later you're back to chaos. Governance isn't glamorous but it's the difference between a token system that ages well and one that becomes a millstone.
At minimum: document the token naming rules in your design system's contributing guide. Add a Stylelint rule that errors on bare hex values in component CSS. Require token changes to go through a design+engineering review (a 15-minute async PR review, not a committee). Run a token audit script quarterly that finds unused tokens and orphaned semantic references.
A simple audit script that finds tokens referenced in CSS but not defined in your token JSON:
``bash
#!/bin/bash
# Find all CSS custom property usages in components
grep -rh 'var(--color-' src/components --include='*.css' \
| grep -oP '(?<=var\()--[a-z-]+(?=\))' \
| sort -u > /tmp/used-tokens.txt
# Extract defined semantic tokens
jq -r 'path(.. | .value?) | join(".")' tokens/semantic.light.json \
| sed 's/\.value$//' \
| sed 's/\./-/g; s/^/--/' \
> /tmp/defined-tokens.txt
comm -23 /tmp/used-tokens.txt /tmp/defined-tokens.txt
# Output: tokens used in CSS but not in the token JSON
``
Beyond tooling, the social contract matters. When someone asks "can I just use #ef4444 here?", the answer is "no, use --color-feedback-error-default, and if that token doesn't exist yet, open a PR to add it." That friction is intentional — it's what keeps the system coherent.
Semantic color tokens aren't just a developer nicety. They're how design systems survive contact with real product roadmaps — rebrands, dark mode, accessibility modes, white-labeling, seasonal themes. Every hour you invest in the indirection layer pays back when those requirements land. And they will land. They always do.
FAQ
Primitive tokens are raw palette values with no meaning — blue-500: #3b82f6. Semantic tokens add intent — action.primary.default references blue-500 and tells you *why* that color is used. Components consume semantics, never primitives.
No. You can maintain semantic tokens as CSS custom properties directly in a single file and manage dark mode with [data-theme] selectors. Style Dictionary adds value when you need multi-platform output (iOS, Android, docs) or want to enforce the primitive→semantic reference graph at build time.
If engineers have to look up token names constantly, you have too many. A well-scoped system for a typical SaaS product sits around 60–120 semantic tokens. More than that usually means you've drifted into component-level tokens, which belong in component stylesheets, not the global token file.
Yes. Map your semantic tokens into theme.extend.colors in tailwind.config.ts and Tailwind generates utility classes for each one. You trade some verbosity (text-text-primary instead of text-gray-900) for the semantic indirection that makes dark mode and rebranding painless.