UI Design

Creating Scalable Color Tokens for Design Systems

By the colorPaletteFinder Team8 min read

The first design system I built shipped with exactly one mistake that haunted everything afterward: we used blue-500 directly in component code. Buttons were blue-500. Links were blue-500. The focus ring was blue-500. It felt clean and DRY. Then marketing rebranded from blue to teal, and we spent two sprints chasing every literal hex reference across forty repositories — and still missed a few, because some had been hardcoded as #3B82F6 rather than the variable. That whole episode could have been a one-line change. The reason it wasn't is the entire reason design system color tokens exist.

A color token is just a named reference to a color value. But the value of tokens isn't in the naming — it's in the layers of naming, and in being disciplined about which layer a component is allowed to touch. Get the layering right and a rebrand, a dark mode launch, or a contrast fix becomes a small, contained change. Get it wrong and you get the forty-repo cleanup.

Three layers: primitive, semantic, component

The mental model that has held up across every system I've worked on is a three-tier hierarchy. Each tier references the one below it, and components are only ever allowed to consume the top tier.

Here's the rule that makes the whole thing work, and the one most teams skip: components reference semantic or component tokens, never primitives. The moment a button reaches past the semantic layer to grab blue-500 directly, you've reintroduced the forty-repo problem one component at a time.

Why bother with the semantic middle layer at all? Because it's the seam where theming happens. When you flip to dark mode, you don't repaint every component — you repoint a few dozen semantic tokens at different primitives. The components don't know anything changed. That indirection is the single highest-leverage decision in the entire system.

Build the scale before you name anything

Before semantics, you need primitives worth pointing at — and that means a numeric scale, conventionally 50 through 950. The convention popularized by Tailwind (50, 100, 200 … 900, 950) has effectively become the industry default, and it's worth adopting just so new engineers feel at home. 50 is the lightest tint, 500 is roughly the pure brand hue, 950 is nearly black.

The mistake beginners make is generating a scale by naively lightening and darkening in sRGB — adding white for tints, black for shades. You get muddy mid-tones and steps that don't feel evenly spaced, because perceived lightness isn't linear in RGB. A 400 ends up looking almost identical to a 500, while 700 to 800 is a cliff. The fix is to space your steps in a perceptual model. Tools that work in HSL get you most of the way; tools that use OKLCH (now well-supported in CSS) get you the rest, because OKLCH keeps perceived lightness consistent as hue changes. If you want to eyeball harmonious tints and shades quickly while you're prototyping a scale, the color palette generator is a fast way to see the relationships between hues before you commit them to tokens.

A few hard-won rules for scales:

Naming: describe the job, not the look

The single most important naming principle is this: semantic and component tokens should name the purpose, never the appearance. A token called color-text-red breaks the day error text becomes orange — the name now lies. A token called color-feedback-error survives that change untouched, because "error" is still true regardless of which red (or orange) it resolves to.

A naming structure that scales reads from broad category to specific modifier, because it sorts and audits cleanly:

That last one — color-text-on-action — is a token people forget and regret. When your action color is a saturated blue, text on that blue needs to be white or near-white, and that pairing has nothing to do with color-text-primary. Make it an explicit token or you'll watch engineers hardcode #FFFFFF onto buttons and then file accessibility bugs when someone introduces a light-yellow "warning" action.

For primitives, naming the appearance is correct and expected — blue-500 should describe what it is, because that's the whole point of the primitive layer. The "describe intent" rule applies above it.

Implementing with CSS custom properties

CSS custom properties are the natural home for tokens because they cascade and can be overridden by context — which is exactly how theming works. The pattern is two layers of variables: primitives defined once at the root, semantics defined at the root and re-defined under a theme selector.

Define primitives globally — --blue-600: #2563EB, --gray-900: #111827, and so on. Then map semantics to them: --color-action-primary: var(--blue-600) and --color-text-primary: var(--gray-900). Components only ever read the semantic variables: color: var(--color-text-primary). They never name a primitive and never name a hex code.

In Tailwind v4 this maps cleanly onto the new @theme directive, which both declares the tokens and generates utilities from them, exposing every token as a runtime CSS variable. You define --color-text-primary: var(--color-gray-900) inside @theme, use text-primary in markup, and override the variable under a .dark selector in your base layer. The official Tailwind theme variables documentation covers the mechanics, and the same two-layer discipline applies regardless of framework.

Dark mode is a token-mapping problem, not a color problem

The reason the semantic layer earns its keep is that dark mode becomes almost trivial once it exists. You do not touch a single component. You override the semantic tokens inside a .dark (or [data-theme="dark"]) scope so they resolve to different primitives.

In light mode, --color-bg-surface: var(--white) and --color-text-primary: var(--gray-900). In dark mode, under the .dark selector, --color-bg-surface: var(--gray-900) and --color-text-primary: var(--gray-100). Every component that reads color-bg-surface flips automatically, because the cascade does the work.

Two things that bite people here:

The pitfalls that actually break at scale

After enough rebrands and theme launches, the failure modes rhyme:

The honest summary is that tokens don't make color decisions for you — they make color decisions cheap to change. The structure is what buys you the ability to rebrand on a Friday, ship dark mode in a sprint, and fix a contrast bug in one line instead of forty repositories. Spend your effort on the semantic layer and the naming, keep components honest about which tier they touch, and the system will absorb changes you can't even predict yet.

Frequently Asked Questions

What is the difference between primitive and semantic color tokens?

A primitive (or base) token names a raw color value with no context — for example blue-500: #3B82F6. It describes what the color is. A semantic token names the color's purpose, such as color-action-primary or color-feedback-error, and points at a primitive. Components should reference semantic tokens, never primitives, so that rebranding or theming only requires repointing the semantic layer rather than editing every component.

How should I name color tokens in a design system?

Name semantic and component tokens by intent, not appearance. color-feedback-error survives a redesign where error red becomes orange; color-text-red does not. Use a broad-to-specific structure like category-property-variant-state (e.g. color-bg-surface-raised, color-action-primary-hover). Primitive tokens are the exception — they should describe the literal color, like gray-900, because that is their whole purpose.

Why use a 50-950 numeric color scale?

The 50-950 scale (50, 100, 200 … 900, 950) popularized by Tailwind has become an industry default, so it's instantly familiar to new engineers. 50 is the lightest tint, ~500 is the pure brand hue, and 950 is near-black. Spacing the steps in a perceptual model like HSL or OKLCH — rather than naively mixing white and black in sRGB — keeps the steps feeling evenly spaced and avoids muddy mid-tones.

How do color tokens make dark mode easier?

Dark mode becomes a token-mapping problem rather than a redesign. Because components only read semantic tokens, you override those tokens inside a .dark or [data-theme=dark] scope so they resolve to different primitives — for example color-bg-surface points at white in light mode and gray-900 in dark. The CSS cascade flips every component automatically, with no component code changes. Remember to desaturate accent colors and avoid pure black backgrounds to reduce eye strain and halation.

What contrast ratios do color tokens need to meet for accessibility?

WCAG requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text and UI components. Bake these checks into your token system by validating every semantic text-on-background pairing in each theme. A combination that passes in light mode can fail in dark mode, so test both, and define an explicit text-on-action token to ensure labels on saturated buttons stay legible.

Want to experiment with colors?

Try our free color palette generator to find your perfect harmony — with a built-in WCAG contrast checker.

Open the Generator