---
title: 'Color Tokens'
source: 'https://academia.sh/en/courses/design-systems/color-tokens'
course: 'Design Systems'
language: en
updated: '2026-08-19T05:19:55+00:00'
license: 'CC BY-SA 4.0'
---

# Color Tokens

Writing color roles as a contract, declaring legal foreground–ground pairs, testing state derivatives with the separation threshold, and auditing the contract automatically in every theme.

The previous lesson established token layers and cross-layer reference rules. In that
architecture, a color token was only a name and a reference: `border` was bound to the
`neutral-500` step. That is only part of what a color carries.

The rest of a color token is **where it can be used**: which grounds the `text-secondary`
role can fall on, which threshold it must clear, and which derivative it switches to in
which state. Without this written down, every component checks on its own or not at all.
This lesson defines color tokens together with their pairings and turns the definition
into an auditable contract.

## A Contract Is More Than a Role List

In the Color System lesson, roles were bound to steps, and pairings were listed and
checked by hand. At the system level, two things change.

First, the pairing list becomes a **contract**: a component may use only a pair written
into it. A foreground–ground combination outside the contract is not a preference, it is
a violation. Second, the contract reruns for every theme; passing once is not enough.

The role set also grows: three more sets join the ten roles from the Color System lesson
— state derivatives (hover, active, disabled), semantic color pairs, and the focus ring.
The Interaction States lesson established that states must be measurably separated at the
1.2 contrast ratio this course adopted; the contract checks this threshold too.

The third part of the contract is **pairs with no criterion**. A disabled control's text
is exempt from the accessibility criteria's contrast requirement, and the exemption
appears in the contract not as an absent threshold but as one explicitly written as
`null`: the decision is made and recorded.

```js
// color.mjs — writing color roles as a contract and auditing the contract

function hslRgb(h, s, l) {
  s /= 100; l /= 100;
  const k = (n) => (n + h / 30) % 12;
  const a = s * Math.min(l, 1 - l);
  const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
  return [f(0), f(8), f(4)].map((v) => Math.round(v * 255));
}
const hex = (rgb) => "#" + rgb.map((v) => v.toString(16).padStart(2, "0")).join("");
function channel(v) {
  const s = v / 255;
  return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
const luminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
function contrast(a, b) {
  const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
  return (x + 0.05) / (y + 0.05);
}

// Primitive layer: families and step lightness values (the ramp from the Color System lesson).
const LIGHTNESS = { "000": 100, "050": 97, 100: 92, 200: 84, 300: 74, 400: 62, 500: 50, 600: 40, 700: 31, 800: 22, 900: 14 };
const FAMILY = { neutral: [214, 8], primary: [214, 62], success: [145, 55], warning: [38, 85], error: [8, 68] };
const color = (family, step) => hslRgb(FAMILY[family][0], FAMILY[family][1], LIGHTNESS[step]);

// Semantic layer: each role names a job and binds to a primitive step.
const ROLE = {
  surface: ["neutral", "000"],
  "surface-secondary": ["neutral", "050"],
  "surface-selected": ["primary", "100"],
  border: ["neutral", "500"],
  "text-primary": ["neutral", "900"],
  "text-secondary": ["neutral", "600"],
  "text-tertiary": ["neutral", "500"],
  "action-primary": ["primary", "600"],
  "action-primary-on": ["neutral", "000"],
  "action-primary-hover": ["primary", "700"],
  "action-primary-active": ["primary", "800"],
  "action-disabled": ["neutral", "200"],
  "action-disabled-on": ["neutral", "500"],
  "focus-ring": ["primary", "600"],
  "success-ground": ["success", "100"],
  "success-text": ["success", "800"],
  "warning-ground": ["warning", "100"],
  "warning-text": ["warning", "800"],
  "error-ground": ["error", "100"],
  "error-text": ["error", "700"],
};
const roleColor = (role) => color(...ROLE[role]);

console.log("role                     family  step     hex");
for (const [name, [family, b]] of Object.entries(ROLE)) {
  console.log(`${name.padEnd(24)} ${family.padEnd(7)} ${b.padStart(7)}  ${hex(roleColor(name))}`);
}

// Contract: which foreground can fall on which ground, and which threshold applies.
// A null threshold means the criterion does not apply; the reason must be written into the contract.
const CONTRACT = [
  ["text-primary", "surface", 4.5],
  ["text-primary", "surface-secondary", 4.5],
  ["text-secondary", "surface", 4.5],
  ["text-tertiary", "surface", 4.5],
  ["text-primary", "surface-selected", 4.5],
  ["border", "surface", 3.0],
  ["action-primary", "surface", 3.0],
  ["action-primary-on", "action-primary", 4.5],
  ["action-primary-on", "action-primary-hover", 4.5],
  ["action-primary-on", "action-primary-active", 4.5],
  ["action-disabled-on", "action-disabled", null],
  ["focus-ring", "surface", 3.0],
  ["success-text", "success-ground", 4.5],
  ["warning-text", "warning-ground", 4.5],
  ["error-text", "error-ground", 4.5],
];

console.log("\nforeground               ground                   contrast   threshold  result");
let failing = 0;
for (const [fg, ground, threshold] of CONTRACT) {
  const k = contrast(roleColor(fg), roleColor(ground));
  const result = threshold === null ? "exempt" : k >= threshold ? "passed" : "FAILED";
  if (threshold !== null && k < threshold) failing++;
  console.log(
    `${fg.padEnd(24)} ${ground.padEnd(24)} ${k.toFixed(2).padStart(7)}:1 ${(threshold === null ? "-" : threshold.toFixed(1)).padStart(6)}  ${result}`
  );
}
console.log(`pairs that miss the threshold: ${failing} / ${CONTRACT.filter((s) => s[2] !== null).length}`);

// State derivatives: every state role must be distinguishable from its base role.
// The criterion is the 1.2 contrast ratio adopted in the Interaction States lesson.
const SEPARATION_THRESHOLD = 1.2;
const STATES = [
  ["action-primary-hover", "action-primary"],
  ["action-primary-active", "action-primary-hover"],
  ["action-disabled", "surface"],
  ["surface-selected", "surface"],
  ["surface-secondary", "surface"],
];
console.log("\nstate role               base role              contrast  threshold  result");
for (const [state, base] of STATES) {
  const k = contrast(roleColor(state), roleColor(base));
  console.log(
    `${state.padEnd(24)} ${base.padEnd(22)} ${k.toFixed(3).padStart(8)} ${SEPARATION_THRESHOLD.toFixed(1).padStart(5)}  ${k >= SEPARATION_THRESHOLD ? "passed" : "FAILED"}`
  );
}

// For the text-tertiary role: which neutral step first clears the 4.5 threshold?
console.log("\nneutral step  against surface  4.5 threshold  3.0 threshold (large text)");
for (const b of ["300", "400", "500", "600", "700"]) {
  const k = contrast(color("neutral", b), roleColor("surface"));
  console.log(
    `${b.padStart(12)} ${k.toFixed(2).padStart(13)} ${(k >= 4.5 ? "passed" : "FAILED").padStart(10)} ${(k >= 3.0 ? "passed" : "FAILED").padStart(23)}`
  );
}

// For surface-secondary: which neutral step first clears the 1.2 separation threshold?
console.log("\nneutral step  against surface  1.2 separation threshold  text-secondary on it");
for (const b of ["050", "100", "200", "300"]) {
  const z = color("neutral", b);
  const sep = contrast(z, roleColor("surface"));
  const mt = contrast(roleColor("text-secondary"), z);
  console.log(
    `${b.padStart(12)} ${sep.toFixed(3).padStart(13)} ${(sep >= 1.2 ? "passed" : "FAILED").padStart(16)} ` +
      `${(mt.toFixed(2) + ":1 " + (mt >= 4.5 ? "passed" : "FAILED")).padStart(24)}`
  );
}

// Dark-theme counterpart: values derived with the preservation calculation from the Dark Theme lesson.
const DARK = {
  surface: "#212327",
  "surface-secondary": "#34383d",
  "text-primary": "#ffffff",
  "text-secondary": "#989fa8",
  border: "#7b848f",
  "action-primary": "#89a9d2",
  "action-primary-on": "#212327",
  "error-text": "#dd9589",
};
const missing = Object.keys(ROLE).filter((r) => !(r in DARK));
console.log(`\nroles with a dark-theme counterpart: ${Object.keys(DARK).length} / ${Object.keys(ROLE).length}`);
console.log("roles missing a dark counterpart:");
for (const r of missing) console.log(`  ${r}`);

// The part of the contract that can be audited in the dark theme
const rgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
console.log("\npairs auditable in the dark theme");
let audited = 0;
for (const [fg, ground, threshold] of CONTRACT) {
  if (threshold === null || !(fg in DARK) || !(ground in DARK)) continue;
  audited++;
  const k = contrast(rgb(DARK[fg]), rgb(DARK[ground]));
  console.log(
    `  ${fg.padEnd(24)} ${ground.padEnd(20)} ${k.toFixed(2).padStart(6)}:1 ${threshold.toFixed(1).padStart(5)}  ${k >= threshold ? "passed" : "FAILED"}`
  );
}
console.log(`audited: ${audited} / ${CONTRACT.filter((s) => s[2] !== null).length}`);
```

```
role                     family  step     hex
surface                  neutral     000  #ffffff
surface-secondary        neutral     050  #f7f7f8
surface-selected         primary     100  #dee9f7
border                   neutral     500  #757e8a
text-primary             neutral     900  #212327
text-secondary           neutral     600  #5e656e
text-tertiary            neutral     500  #757e8a
action-primary           primary     600  #275ea5
action-primary-on        neutral     000  #ffffff
action-primary-hover     primary     700  #1e4980
action-primary-active    primary     800  #15335b
action-disabled          neutral     200  #d3d6d9
action-disabled-on       neutral     500  #757e8a
focus-ring               primary     600  #275ea5
success-ground           success     100  #dff6e9
success-text             success     800  #195733
warning-ground           warning     100  #fcefd9
warning-text             warning     800  #684508
error-ground             error       100  #f8e0dd
error-text               error       700  #852819

foreground               ground                   contrast   threshold  result
text-primary             surface                    15.74:1    4.5  passed
text-primary             surface-secondary          14.70:1    4.5  passed
text-secondary           surface                     5.89:1    4.5  passed
text-tertiary            surface                     4.11:1    4.5  FAILED
text-primary             surface-selected           12.82:1    4.5  passed
border                   surface                     4.11:1    3.0  passed
action-primary           surface                     6.50:1    3.0  passed
action-primary-on        action-primary              6.50:1    4.5  passed
action-primary-on        action-primary-hover        9.05:1    4.5  passed
action-primary-on        action-primary-active      12.68:1    4.5  passed
action-disabled-on       action-disabled             2.82:1      -  exempt
focus-ring               surface                     6.50:1    3.0  passed
success-text             success-ground              7.54:1    4.5  passed
warning-text             warning-ground              7.57:1    4.5  passed
error-text               error-ground                7.21:1    4.5  passed
pairs that miss the threshold: 1 / 14

state role               base role              contrast  threshold  result
action-primary-hover     action-primary            1.393   1.2  passed
action-primary-active    action-primary-hover      1.401   1.2  passed
action-disabled          surface                   1.459   1.2  passed
surface-selected         surface                   1.228   1.2  passed
surface-secondary        surface                   1.071   1.2  FAILED

neutral step  against surface  4.5 threshold  3.0 threshold (large text)
         300          1.91     FAILED                  FAILED
         400          2.74     FAILED                  FAILED
         500          4.11     FAILED                  passed
         600          5.89     passed                  passed
         700          8.39     passed                  passed

neutral step  against surface  1.2 separation threshold  text-secondary on it
         050         1.071           FAILED            5.51:1 passed
         100         1.204           passed            4.90:1 passed
         200         1.459           passed            4.04:1 FAILED
         300         1.912           passed            3.08:1 FAILED

roles with a dark-theme counterpart: 8 / 20
roles missing a dark counterpart:
  surface-selected
  text-tertiary
  action-primary-hover
  action-primary-active
  action-disabled
  action-disabled-on
  focus-ring
  success-ground
  success-text
  warning-ground
  warning-text
  error-ground

pairs auditable in the dark theme
  text-primary             surface               15.74:1   4.5  passed
  text-primary             surface-secondary     11.80:1   4.5  passed
  text-secondary           surface                5.89:1   4.5  passed
  border                   surface                4.15:1   3.0  passed
  action-primary           surface                6.50:1   3.0  passed
  action-primary-on        action-primary         6.50:1   4.5  passed
audited: 6 / 14
```

## The Third Text Level Cannot Be Built in Color

The contract audit fails one of the fourteen criteria-bearing pairs: the `text-tertiary`
role produces a 4.11:1 contrast against the surface and cannot clear the 4.5 threshold.

The third table gives the options for a fix. The first neutral step that clears the 4.5
threshold is 600; but 600 is already the step for the `text-secondary` role. So the third
text level cannot exist on this scale without sitting on the same color as the second
level.

This finding is a direct consequence of the Visual Hierarchy lesson's two-channel rule.
If the color channel cannot carry three levels, the third level moves to another channel:
size, weight, or position. The Typographic Scale lesson already separated the metadata
level on both size and contrast, so color needs no third shade.

The table's last column shows one more route: the neutral 500 step clears the 3.0
threshold that applies to large text. If the third level will be used only in large text,
the role can be kept, but that requires attaching a condition to the role's name, and a
conditional role should not be used unless that condition is written into the contract —
the component using it does not otherwise know it.

## The Secondary Surface Produces No Separation

The state-derivative table gives the second finding, and this finding is quieter than the
first. Four state roles clear the separation threshold: hover at 1.393, active at 1.401,
disabled at 1.459, selected at 1.228. The fifth does not: the `surface-secondary` role
produces only a 1.071 contrast against the surface.

This means every rule written on the assumption of a second surface level goes unbacked.
Wherever a side panel, a table header, or a card's surface is set apart with neutral 050,
the separation does not actually exist — a scale step is spent for no visual result. This
is the color-side counterpart of the unbacked-variety concept from the Repetition and
Consistency lesson.

The last table measures the fix and shows a trade-off. Neutral 100 is the first step that
clears the separation threshold (1.204), and the secondary text on top of it still clears
4.90:1. Moving to neutral 200 strengthens the surface separation (1.459) but drops the
secondary text to 4.04, losing the threshold.

A system-level rule follows from this: **darkening a surface role requires every text
role on top of it to be re-audited.** A surface is not a decision on its own; it is a
decision together with every role that falls on it. The contract's job is exactly to make
this dependency visible.

## A Pair with No Criterion Is Also a Decision

The disabled action's text carries a 2.82:1 contrast against its ground, and the table
reads "exempt." This row is the most misunderstood part of the contract.

The absence of a criterion is not an oversight, it is a written decision: a disabled
control is closed to interaction, and the accessibility criteria exempt it from the
contrast requirement. Writing the exemption explicitly into the contract does two jobs —
the audit tool does not flag that row as an error, and the reader sees it was not a
forgotten check.

Its cost is recorded too: why a disabled button is disabled cannot be understood through
color alone, and low contrast by itself carries no information. The Microcopy lesson's
rule repeats here — the state's reason is given through text. Where the contract writes
no criterion, another contract takes over.

## The Contract Reruns in Every Theme

The last two blocks give this lesson's main system-level finding. Only eight of the
twenty roles have a defined dark-theme counterpart; twelve are missing. Of the contract's
fourteen criteria-bearing pairs, only six can be audited in the dark theme.

All six auditable pairs pass — expected, since they were derived with the Dark Theme
lesson's preservation calculation. The problem is not the pairs that pass; it is the
eight pairs that **cannot be audited**. The dark theme has no selected-surface role, so
components using it reference an undefined token — the undefined-reference defect from
the previous lesson.

This also shows how to find the gap: through the product of the role set and the theme
set. Twenty roles and two themes make forty cells; twelve are empty. Without a report,
these gaps surface only once a user switches to the dark theme.

That every semantic color is missing stands out on its own. The success, warning, and
error pairs are defined and audited in the light theme; none exist in the dark theme.
Semantic colors are the least-used roles but appear at the most critical moments, so
their absence is noticed latest.

## The Contract's Written Form

Roles and pairings live in separate files. Roles are written in style as custom
properties; pairings are written as a data file the audit reads.

```css
:root {
  /* primitive */
  --neutral-000: #ffffff;
  --neutral-100: #e9eaec;
  --neutral-600: #5e656e;
  --neutral-900: #212327;
  --primary-600: #275ea5;
  --primary-700: #1e4980;
  --primary-800: #15335b;

  /* semantic */
  --surface: var(--neutral-000);
  --surface-secondary: var(--neutral-100);
  --text-primary: var(--neutral-900);
  --text-secondary: var(--neutral-600);
  --action-primary: var(--primary-600);
  --action-primary-hover: var(--primary-700);
  --action-primary-active: var(--primary-800);
  --action-primary-on: var(--neutral-000);
}
```

The pairing contract has no counterpart in style, and it should not. CSS cannot compute a
contrast ratio; only an audit running at build time or in a test stage can read the
contract. This is the main reason the token system is a data layer independent of style:
values turn into style, rules turn into audits.

The second detail is that `surface-secondary` has been moved from neutral 050 to neutral
100. The audit made this fix mandatory; the written form carries the corrected version.
Leaving a defect the audit found unfixed in the source reduces the audit to a report
generator.

## Summary

- A color token is not just a name and a value; which grounds it can fall on and which
  threshold it must clear are part of the definition.
- The pairing list is a contract: a foreground–ground combination that is not in the
  contract is a violation, not a preference, and the contract is rerun for every theme.
- The scale cannot serve every role: the third text level cannot be built on this scale
  (neutral 500 does not clear the threshold, neutral 600 collides with the second level),
  and the secondary surface cannot be separated at neutral 050 with a 1.071 contrast. An
  unserviceable role moves to the size or weight channel; darkening a surface requires
  every text role on it to be re-audited.
- A pair with no criterion is a written decision; the exemption is stated explicitly in
  the contract, and the information it lacks is supplied through text.
- Only eight of the twenty roles have a defined dark-theme counterpart; six of the
  fourteen criteria-bearing pairs can be audited. Missing cells are found through the
  product of the role set and the theme set.
- Values turn into style, rules turn into audits; because CSS cannot compute a contrast
  ratio, the contract lives outside style.

## Next Step

On the color side, the contract was built on the pairing concept: a role's correctness
was measured together with another role, not on its own. On the typography side,
dependency takes another form. A text level is not a single number: size, line height,
weight, and letter spacing together form one decision, and spreading these four across
separate tokens lets wrong combinations get assembled. The next lesson defines typography
tokens as composite tokens, fits line-box heights onto the spacing grid, and audits the
levels' distinction threshold.
