Primitives and semantics
Two layers, and the discipline is that components only ever reference the second one.
Primitives are the palette: blue-600,
grey-50, red-500. They name a color and carry no
opinion about its use. There are a lot of them — eight hues at eleven steps
is eighty-eight — and most are never referenced directly.
Semantics name a job and point at a primitive:
color-bg-default, color-text-muted,
color-border-strong, color-action-primary,
color-feedback-danger. There are far fewer of them, and they are
the only names a component uses.
The payoff is concentrated in three places. A dark theme is a second set of
semantic mappings over the same primitives. A rebrand changes the primitives
and leaves every component untouched. And a semantic name is the only thing
that makes a code review question answerable — bg: grey-100
cannot be wrong, whereas bg: color-surface-raised either is or
is not what that element should be.
Some systems add a third component layer —
button-primary-bg — pointing at semantics. Worth having when
components are themed independently; overhead otherwise.
Naming
Use a consistent order and stick to it. category-property-variant-state
reads well and sorts usefully:
color-bg-default
color-bg-subtle
color-bg-raised
color-text-default
color-text-muted
color-text-on-action
color-border-default
color-border-strong
color-action-primary
color-action-primary-hover
color-action-primary-active
color-feedback-danger
color-feedback-danger-subtle
Three rules that prevent the usual decay:
- Never name a semantic token after its value.
color-text-blueis a lie the first time the theme changes. - Never name it after one usage site.
color-sidebar-bgbecomes wrong the moment a second thing needs it. - Avoid ordinal names.
color-primary-2says nothing;color-action-secondarysays what it is for.
Pick one case convention and apply it everywhere — kebab-case in CSS, and whatever your platform expects elsewhere, generated from the same source rather than maintained twice.
Why 50–950
Eleven steps — 50, 100, 200 … 900, 950 — is the convention Tailwind
popularised and most systems now follow. Two properties make it work: the
numbers leave room to insert a step without renumbering, and if the ramps are
built perceptually then the same number means the same lightness across
hues, so blue-500 and green-500 are
interchangeable without re-measuring contrast.
That second property only holds if the ramp is generated in a perceptual
space. A ramp stepped evenly in HSL puts yellow-500 and
blue-500 at wildly different lightnesses and the correspondence
is lost — see OKLCH vs HSL.
A rule of thumb that falls out of a well-built ramp: 50–200 are backgrounds, 300–400 are borders and disabled states, 500–600 are the color itself and fill buttons, 700–900 carry text, and 950 is for dark-theme surfaces.
CSS custom properties
:root {
/* primitives */
--blue-50: #eff5fe;
--blue-100: #d8e4fb;
--blue-400: #6f9ef0;
--blue-600: #1f5fd0;
--blue-900: #123170;
/* semantics */
--color-bg-default: #ffffff;
--color-text-default: #16181d;
--color-action-primary: var(--blue-600);
--color-action-primary-hover: var(--blue-700);
}
.button-primary {
background: var(--color-action-primary);
color: var(--color-text-on-action);
}
Custom properties are resolved at runtime, which is what makes theme switching a class change rather than a rebuild. They also cascade, so a scoped override — a single panel that is always dark — is one selector.
SCSS
$blue-600: #1f5fd0;
$blue-400: #6f9ef0;
$color-action-primary: $blue-600;
SCSS variables are compile-time: they cannot be swapped at runtime and cannot be overridden per subtree. In a system that needs theming, the usual pattern is SCSS for the build-time palette and CSS custom properties for everything a theme touches.
Tailwind v3 and v4
Tailwind v3 — configuration in JavaScript, resolved at build time:
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
blue: { 50: '#eff5fe', 400: '#6f9ef0', 600: '#1f5fd0', 900: '#123170' },
},
},
},
};
Tailwind v4 moved theme configuration into CSS. Each entry under
@theme becomes a real custom property as well as a utility
class, so the values are available at runtime to code that is not using a
Tailwind class:
@import "tailwindcss";
@theme {
--color-blue-50: #eff5fe;
--color-blue-400: #6f9ef0;
--color-blue-600: #1f5fd0;
--color-blue-900: #123170;
}
Both give you bg-blue-600 and text-blue-900. The v4
form additionally gives you var(--color-blue-600) in any
stylesheet, which removes the usual duplication between the Tailwind palette
and the custom properties a component library needs.
Export a palette in either format
DTCG JSON
The Design Tokens Community Group format is a shared JSON shape for tokens,
readable by Style Dictionary, Tokens Studio and a growing set of design
tools. Every token is an object with $type and
$value; references use braces.
{
"color": {
"blue": {
"600": { "$type": "color", "$value": "#1f5fd0" }
},
"action": {
"primary": {
"$type": "color",
"$value": "{color.blue.600}",
"$description": "Primary action fill"
}
}
}
}
Use it when tokens have to cross a tool boundary — design tool to codebase, or one codebase to another platform. For a single web codebase it is a layer of indirection with no reader, and CSS custom properties are the better answer.
Theming
Themes remap semantics; primitives stay fixed. That keeps one source of color and makes the diff between two themes small enough to review.
:root {
--color-bg-default: #ffffff;
--color-text-default: var(--grey-950);
--color-action-primary: var(--blue-600);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-bg-default: var(--grey-950);
--color-text-default: #e8e8ee;
--color-action-primary: var(--blue-400);
}
}
Note that --color-action-primary moves from step 600 to step
400: the same hue, further up the ramp, because a color that had enough
contrast against white does not have enough against near-black. See
dark mode color palettes.
What goes wrong
- Semantics named after values.
--color-text-bluesurvives exactly until the first theme. - Components referencing primitives. Every one of those is a theme bug waiting to happen.
- No semantic layer at all. A palette exported as eighty-eight primitives is a palette, not a token system.
- Hard-coded hex alongside tokens. One
#fffin a component defeats the theme for that element. - Ramps generated in HSL. The step numbers stop corresponding across hues, so swapping a hue changes contrast.
- Two sources of truth. A Tailwind config and a CSS custom property file maintained by hand will drift; generate both from one export.
Checklist
- Primitive and semantic layers are separate, and components use semantics only
- Semantic names describe the job, not the color or one usage site
- One naming convention, applied everywhere
- Ramps run 50–950 and are generated perceptually
- The same step number means the same lightness across hues
- Themes remap semantics; primitives do not change
- Every output format is generated from one source, not maintained twice
- No hard-coded color values remain in components
- Contrast is re-measured after any primitive changes
Frequently asked questions
What is the difference between a primitive and a semantic token?
A primitive names a color: blue-600 is a specific value and says nothing about where it is used. A semantic token names a job: color-action-primary points at a primitive and can be repointed per theme, per brand or per platform without touching a component. Components should reference semantics only.
Should I use Tailwind v3 config or v4 @theme?
Whichever version you are on. Tailwind v4 moved theme configuration into CSS with the @theme directive, so colors become CSS custom properties and are available at runtime; v3 keeps them in tailwind.config.js and resolves them at build time. The palette is the same either way — only the syntax and the runtime availability change.
Do I need the DTCG format?
Only if more than one tool has to read your tokens. The Design Tokens Community Group format is a shared JSON shape that Style Dictionary, Tokens Studio and several design tools can all import, which makes it the right interchange format for a design system spanning Figma and code. For a single codebase, plain CSS custom properties are simpler and enough.
How many steps should a color ramp have?
Eleven — 50, 100 through 900 in hundreds, and 950 — is the de facto convention, and matching it means your palette drops into most existing systems without a translation layer. Fewer steps force improvisation at implementation time; many more are rarely distinguishable enough to be worth naming.