Huely color system builder

Huely/Guides/Color design tokens

From a color palette to design tokens

A palette becomes a design system at the moment colors stop being named after what they look like and start being named after what they do. This is the structure that makes that possible, and what it looks like in each output format.

Published · Last reviewed

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:

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

  1. Semantics named after values. --color-text-blue survives exactly until the first theme.
  2. Components referencing primitives. Every one of those is a theme bug waiting to happen.
  3. No semantic layer at all. A palette exported as eighty-eight primitives is a palette, not a token system.
  4. Hard-coded hex alongside tokens. One #fff in a component defeats the theme for that element.
  5. Ramps generated in HSL. The step numbers stop corresponding across hues, so swapping a hue changes contrast.
  6. Two sources of truth. A Tailwind config and a CSS custom property file maintained by hand will drift; generate both from one export.

Checklist

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.

Next

Palette builderExport CSS variables, SCSS, Tailwind and JSON, base colors or full ramps. OKLCH vs HSLWhy the ramps have to be perceptual for step numbers to mean anything. Dark mode palettesThe second set of semantic mappings. Accessible color palettesWhat to measure before the tokens ship.