Every design system reaches the same awkward moment. Someone needs a slightly darker version of the gray that is already called $gray-dark, and the only names left are $gray-darker and $gray-darkest. Then someone needs one darker than that.

Color naming looks like bikeshedding and is not. Names determine whether a redesign takes an afternoon or a fortnight, and whether a new developer picks the right token or the nearest-looking one.

Why descriptive names fail

The instinct is to name a color after what it looks like: $light-blue, $dark-gray, $brand-green. Three problems appear reliably.

They run out of room. Adjectives have no scale. Once you have light, lighter and lightest, the next value needs a name that does not exist.

They go stale. The moment marketing changes the brand from green to blue, $brand-green is either a lie or a rename touching every file that references it. This happens more often than anyone plans for.

They break in dark mode. $light-bg must become dark on a dark theme. A name that contradicts its value is worse than no name — it actively misleads.

Two layers, not one

The pattern that has settled across most mature systems separates naming into two layers with different jobs.

Primitives name the color itself. They are literal, numeric and never used directly in components:

--blue-50:  #EFF6FF;
--blue-500: #3B82F6;
--blue-900: #1E3A8A;
--gray-100: #F3F4F6;
--gray-900: #111827;

The numbers are a lightness scale, not a count — 50 is lightest, 900 or 950 darkest, and the gaps leave room to insert values later without renaming anything. This is the convention Tailwind popularised, and the Tailwind color generator produces exactly this shape from one base color.

Semantic tokens name the role, and point at primitives:

--color-text-primary:   var(--gray-900);
--color-text-secondary: var(--gray-600);
--color-surface:        var(--white);
--color-surface-raised: var(--gray-50);
--color-border:         var(--gray-200);
--color-action:         var(--blue-500);
--color-action-hover:   var(--blue-600);
--color-danger:         var(--red-500);

Components reference only the semantic layer. That single rule is what makes the system flexible: a theme swaps the mapping, and no component changes.

[data-theme="dark"] {
  --color-text-primary: var(--gray-50);
  --color-surface:      var(--gray-950);
  --color-border:       var(--gray-800);
  --color-action:       var(--blue-400);
}

Notice that dark mode does not just flip the ends — the action color moves to a lighter, less saturated step, for the reasons covered in our post on designing a dark mode palette.

Naming the semantic layer

A convention that scales is category–role–variant–state, dropping the parts you do not need:

  • --color-text-primary
  • --color-text-secondary
  • --color-surface-raised
  • --color-border-focus
  • --color-action-hover
  • --color-danger-subtle

Consistent ordering matters more than the specific words. If it is text-primary in one place it cannot be primary-text in another, or nobody can guess a token name without looking it up — and a token nobody can guess is a token nobody uses.

Keep the vocabulary small and deliberately constraining. Somewhere between fifteen and thirty semantic tokens covers most products. If you find yourself at eighty, you have started naming instances rather than roles, and the system has stopped helping.

Common naming traps

Naming after a component. --color-button-blue looks harmless until three other components want the same value and end up referencing a token named after a button. Name the role, not the first place it was used.

Naming after a person or a project. --color-rebrand-2024 ages badly and means nothing to anyone who was not there.

Overloading "primary". It commonly means both "the main brand color" and "the most prominent text". Pick one meaning and be explicit — --color-action for the interactive color removes the ambiguity entirely.

Encoding the value in the name. --color-blue-3b82f6 guarantees a rename the first time the value is tuned.

Skipping the primitive layer. Tempting for a small project, painful later. Without it, changing the brand blue means editing every semantic token that happens to contain that hex value, and finding all of them is exactly the problem the system was meant to solve.

How many steps in a scale

Ten steps (50, 100, 200 … 900) is the common answer, and it is a reasonable default. Some systems add 950 for dark-mode surfaces, which is worth having.

What matters more than the count is that the steps are perceptually even. A naive linear interpolation from white to a color produces bunched-up light steps and a muddy middle. Building the ramp in a perceptual space, or hand-tuning the lightness values, gives a scale where each step feels like the same size move — see our color harmony guide for why saturation needs nudging at the extremes.

Not every hue needs ten steps. Semantic colors like danger and warning typically need three or four: a subtle background, a border, a solid fill, and a text-safe dark version. Generating ten of each because the blue has ten is padding.

Document contrast alongside the tokens

The single most useful thing a color documentation page can include is which combinations are safe. Not the palette as decorative swatches, but a table: this text token on this surface token gives this ratio and passes AA.

It converts an accessibility question that every developer would otherwise have to ask into a lookup. Build it once by running the pairs through the contrast checker, and note the failures explicitly too — knowing that --color-text-secondary must not be used on --color-surface-raised prevents the bug before it is written.

Migrating an existing mess

Few teams get to start clean. A migration that works in practice:

  1. Inventory what exists. Every hard-coded color value in the codebase. The count is usually alarming and is the argument for doing the work.
  2. Cluster the near-duplicates. You will find six grays within a couple of percent of each other, all unintentional. Pick one per cluster.
  3. Build the primitive scales from the survivors.
  4. Define the semantic layer and map it onto the primitives.
  5. Migrate component by component, replacing literals with semantic tokens. Do not attempt a single global replace; near-duplicates need judgement about which role each one was playing.
  6. Add a lint rule that rejects raw hex values in component styles. Without enforcement the mess returns within two quarters.

The test

A well-named color system passes one question: can you change the brand color in a single place and have the entire product update correctly, including dark mode, without anything breaking or any contrast ratio silently failing?

If yes, the naming is doing its job. If no, the names are decoration on top of hard-coded values, and the next redesign will cost what this one did.

Our CSS color guide covers the custom-property mechanics that make this structure work, and the CSS color generator produces variable blocks in the shape described here.