Skip to main content

Learning Track

Filter content by your role to see the most relevant sections

Color Foundations

Build a color system that survives theming, rebranding, and accessibility audits: core palettes, semantic roles, WCAG contrast as a computed property, and mode-aware tokens that ship to CSS cascade layers.

Darian Rosebrook
Staff Design Technologist, Design Systems Architect
Expertise:Design Systems, Color Theory, Accessibility

Why This Matters

Color is the foundation most likely to be treated as decoration, and the one where that mistake is most expensive. Every hardcoded hex value in a component is a decision that must be re-made by hand for dark mode, for each brand, and for every future accessibility audit. A tokenized color system converts those one-off decisions into a small set of named, versioned contracts.

Done well, the color layer is what makes everything else cheap: theming becomes data instead of a rewrite, contrast becomes a computed property you can validate in CI rather than a claim you re-verify by eye, and a brand change becomes a mapping change instead of a find-and-replace across the codebase.

This page walks through the color system as it is actually built in this repository—core palettes, semantic roles, component-level tokens, and the generated CSS that ties them together—so the concepts stay attached to working code you can inspect.

Core Concepts: The Three Layers of a Color System

A production color system is not one palette. It is three layers, each with a different job and a different rate of change:

  • Core primitives — the raw material: ramped palettes, mode constants, and data-visualization scales. Values live here; meaning does not.
  • Semantic roles — intent-bearing aliases like foreground.primary and action.background.primary. This is where meaning lives, and the only layer product code should reference.
  • Component tokens — scoped consumption contracts like --ds-card-color-background-default that give each component a narrow, stable interface to the layers below.

Primitives: Ramps, Not Swatches

The core layer in ui/designTokens/core/color.tokens.json defines nine ramped families—brand.primary, neutral, red, orange, yellow, green, blue, teal, and violet—each in eight steps from 100 (lightest) to 800 (darkest). A ramp is a calibrated ladder, not a pile of favorite swatches: the point of steps is that 500 and 600 relate to each other the same way in every family, so a hover state can be expressed as "one step darker" as a system rule.

Two things about this file surprise people coming from tool-palette exports. First, values are stored in the W3C Design Tokens (DTCG) format with structured color values, not hex strings:

// ui/designTokens/core/color.tokens.json (excerpt)
"blue": {
  "600": {
    "$value": {
      "colorSpace": "srgb",
      "components": [0.0, 0.2588, 0.8627]
    },
    "$description": "Level 600 of the blue color scale.",
    "$type": "color"
  }
}

Hex is an output encoding, produced during generation—the same source can emit SCSS, CSS custom properties, or type definitions without a second source of truth. Second, the families overlap on purpose: blue mirrors brand.primary. The brand ramp is what semantics reference; the named hue ramps exist so semantic decisions can be described and swapped in human terms ("make the accent teal") without touching consumers.

The ramps are not curated swatches; they are generated, and the generation is the most important provenance fact in the color system. Each ramp is produced with the Adaptive-DS-Colors generator: the first step is keyed at 1.15:1 contrast against the background, steps are interpolated uniformly in luminance up to a bounded top end near 18:1, and the same per-level contrast targets apply to every family. Verify it against the shipped hexes: neutral and blue land within ±0.07 of each other at every level—1.15, 1.57, 2.22, 3.23, 4.88, 7.46, 11.4, 16.1:1 against white.

Read that progression as a ladder of thresholds, because the anchors put each level next to a job: 400 (≈3.2:1) brackets the WCAG UI-graphics floor, 500 (≈4.9:1) sits just above the 4.5:1 body-text floor, 600 (≈7.5:1) clears AAA. "Which step for text?" is therefore not a taste question—the ramp answers it. What remains chosen is the family hue, the step count, and the anchors themselves; everything between them is derived, which is why a brand ramp can be regenerated under new anchors without re-curating eight colors by hand.

Alongside the ramps sit two smaller groups. color.mode holds the constants every theme needs—black, white, light (#fafafa), dark (#141414), transparent. And color.datavis ships perceptually uniform colormap ramps—viridis, magma, plasma, inferno, cividis—plus categorical and divergent scales, because charts have different constraints than UI chrome: magnitude must map to perceived magnitude, not to raw channel values.

Semantic Roles: Name the Job, Not the Hue

The semantic layer in ui/designTokens/semantic/color.tokens.json contains no new colors. Every entry is a reference into the core layer, grouped by the job the color does: foreground, background, border, action, feedback, status, syntax, data, navigation, gradient, overlay, and selection. The naming rule is the contract: role, then property, then state. action.background.primary.hover reads as a sentence;blue-600 does not.

Here is the primary text color as it is actually defined:

// ui/designTokens/semantic/color.tokens.json (excerpt)
"foreground": {
  "primary": {
    "$type": "color",
    "$value": "{color.mode.dark}",
    "$extensions": {
      "design.paths.light": "{color.mode.dark}",
      "design.paths.dark":  "{color.mode.light}"
    },
    "$description": "Primary foreground color"
  }
}

Read the inversion carefully, because it is the whole trick: light mode uses the constant named dark, and vice versa. The base $value says what this token is; the design.paths extensions say which core reference resolves in each mode. Text stays near-black on light surfaces and near-white on dark surfaces without the consumer knowing anything about modes. Action states work the same way by walking a ramp:

// Semantic action tokens ride the brand ramp
"action": {
  "background": {
    "primary": {
      "default": { "$value": "{color.palette.brand.primary.500}" },
      "hover":   { "$value": "{color.palette.brand.primary.600}" },
      "active":  { "$value": "{color.palette.brand.primary.700}" },
      "disabled": {
        "$value": "{color.palette.neutral.200}",
        "$extensions": {
          "design.paths.light": "{color.palette.neutral.200}",
          "design.paths.dark":  "{color.palette.neutral.700}"
        }
      }
    }
  }
}

In the default brand, brand.primary resolves to the red family, so the emitted default for --semantic-color-action-background-primary-default is #d9292b (red.500), hover is #ae0001(red.600), active is #7b0000 (red.700). The semantic layer never names a hue—it names a position on a ramp, and the brand layer decides which ramp that is.

Component Tokens: The Consumption Contract

The third layer lives next to each component: ui/components/Card/Card.tokens.css and its siblings. Each file is scoped with [data-ds-component="Card"] and prefixed --ds-card-*, and each variable resolves a semantic token with a literal fallback:

/* ui/components/Card/Card.tokens.css (excerpt) */
[data-ds-component='Card'] {
  --ds-card-color-background-default:
    var(--semantic-color-background-primary, #ffffff);
  --ds-card-size-radius-medium:
    var(--semantic-shape-card-radius, 16px);
}

The fallback is a resilience contract, not decoration: components stay renderable even if the global token stylesheet fails to load, and the fallback value makes the component's assumption about the semantic layer observable at runtime. Component styles may only reference these scoped variables—reaching past them to --semantic-* or --core-* directly is the boundary the linters (npm run tokens:lint) exist to catch.

Contrast Is a Computed Property

Contrast is where color systems earn their keep, because contrast between two colors is a function, not an opinion. WCAG 2.1 defines it from relative luminance: (L1 + 0.05) / (L2 + 0.05). The thresholds this system validates against live in utils/accessibility/tokenValidator.ts:

  • AA normal text: 4.5:1 — the floor for body copy
  • AA large text and UI: 3.0:1 — headings, icons, borders that must be seen
  • AAA normal text: 7.0:1 — long-form reading
  • AAA large text: 4.5:1

Applying the formula to the semantic pairs this system actually ships (rounded to one decimal):

  • foreground.primary on background.primary: ≈18.4:1 in light ( #141414 on #ffffff), ≈20.1:1 in dark (#fafafa on #000000) — clears every threshold with room to spare.
  • foreground.secondary on background.primary: ≈7.5:1 light ( #555555), ≈9.5:1 dark (#aeaeae) — AAA, because secondary does not mean barely legible.
  • The accent as text (red.500 light, red.400 dark) on primary backgrounds: ≈4.9:1 and ≈6.5:1 — passes AA, fails AAA. Accent-colored body text is therefore a deliberate, bounded choice, not a default.

Notice what that last bullet implies: the same accent that is fine for a large call-to-action label is not acceptable for a paragraph. Systems encode this by pairing roles—if you want red prose, you need a foreground.action role whose ramp position was chosen for text, not a borrowed button background.

One honest note on where the industry is heading: APCA (Accessible Perceptual Contrast Algorithm) models perceptual contrast better than WCAG 2.1's relative luminance, especially for dark surfaces. This repository's validators compute WCAG 2.1 ratios; treat APCA as the direction the math is moving, not something this system currently enforces.

Modes and Brands Are Data, Not Forks

Everything above—the design.paths extensions, the ramp references—exists so that themes can be data. The build composes the token JSON into app/designTokens.scss with an explicit layer order:

/* app/designTokens.scss — generated, do not edit */
@layer core, semantic, theme, brand, density;

@layer core {
  :root {
    --core-color-mode-black: #000000;
    --core-color-mode-white: #ffffff;
    --core-color-mode-light: #fafafa;
    --core-color-mode-dark: #141414;
    --core-color-palette-brand-primary-500: #0a65fe;
    /* ...316 core custom properties... */
  }
}

The theme layer emits .light and .dark class scopes plus an @media (prefers-color-scheme: dark) block, so a page can follow the system setting or be pinned explicitly. The brand layer emits [data-brand] scopes—ten of them ship today (canary, corporate, default, forest, midnight, monochrome, ocean, rose, slate, sunset). A brand file is tiny because it only decides three things: its name, which family brand.primary points at, and a density. Ocean is { name: "ocean", accent: "teal", density: "spacious" }—its accent resolves to teal.500 in light and teal.400 in dark. Midnight swaps in violet with a tight density. No component file changes; the cascade does the work.

System Roles: Where Color Decisions Land

The three layers only stay healthy if each discipline makes decisions in its own layer and hands the next layer a contract:

Design Impact

Designers own the ramps and the semantic intent. The ramp is where craft lives: even spacing in perceptual (not channel) space, a 500 that passes AA on white across families, a 600 that is one honest step darker. The semantic layer is where judgment lives: is this border decorative or structural? Should this state be feedback.warning or status.caution? In Figma, the same structure appears as variable collections with modes—collections mirror core ramps, modes mirror light/dark—so the file a designer paints with is shape-compatible with the JSON the build consumes.

Engineering Impact

Engineers own the pipeline: DTCG sources compose into semantic and brand layers, generate the cascade-layered stylesheet, and ship typed outputs. The rule that keeps the system refactorable: product code touches only scoped component tokens. When a component reads var(--ds-card-color-background-default), the card does not know what theme is active, and theme work never requires touching the card.

Accessibility Impact

Accessibility owns the pass/fail lines and gets automation for them: validateColorPair in utils/accessibility/tokenValidator.ts computes real ratios against the WCAG thresholds, so "this pair passes AA normal" is a checkable claim, not a vibe. The accessibility posture of the whole system reduces to one rule: pair roles, not colors—if a foreground role exists, its backgrounds are enumerated, and the validator walks those pairs.

Governance Impact

Governance owns the rate of change. Core ramps change rarely and carry visual review; semantic roles change more often and carry intent review; component tokens change freely within their scope. The linters make the boundary observable: scripts/check-token-references.mjs flags references that skip layers, and scripts/check-kebab-token-vars.mjs keeps naming mechanical. A color system without those tripwires regresses to swatches by attrition.

Design & Code Interplay

On the design side, the token system shows up as structure you can paint with. A Figma library that mirrors the three layers has:

  • One variable collection per core ramp — nine collections of eight steps each, named to match the token paths (brand.primary/100…800), so a designer saying "600" and a token file saying brand.primary.600 are the same statement.
  • Modes, not duplicates — semantic colors are Figma variables with light and dark modes bound to the ramp steps, exactly like design.paths.light and design.paths.dark. Switching the file to dark mode must never be a re-paint.
  • Intent names in styles — component styles reference semantic variables ("background/primary"), so a file audit can spot off-system color instantly: any raw swatch in a layer is a defect by definition.

The contrast tooling lives here too. Plugins that compute WCAG ratios against the thresholds (AA 4.5:1, large 3.0:1) let designers check pairs like foreground.secondary on background.primary (≈7.5:1 light) while choosing colors, not after. The numbers designers see in the tool are the same numbers tokenValidator.ts enforces in CI, because both implement the same formula on the same source values.

Applied Example: Shipping a Warning Border Pair

Let's ship one real decision end to end: teams need a warning border for destructive-adjacent form states, and the current feedback.border.warning resolves to #824500 in light mode. Walk the full path—ramp, alias, generation, validation, consumption—because every color decision in this system is these five steps.

Step 1: Check the ramp before touching it

The need is "a warning border that reads on both light and dark surfaces." Orange is the conventional warning hue, and the core orange ramp already has eight calibrated steps. Resist adding a new color: if the ramp lacks a usable step, the fix is usually re-spacing the ramp, not bolting on an outlier that will haunt every future "one step lighter" rule.

Step 2: Alias with both paths

The semantic entry references positions, and declares how it resolves per mode:

// ui/designTokens/semantic/color.tokens.json
"feedback": {
  "border": {
    "warning": {
      "$type": "color",
      "$value": "{color.palette.orange.700}",
      "$extensions": {
        "design.paths.light": "{color.palette.orange.700}",
        "design.paths.dark":  "{color.palette.orange.300}"
      },
      "$description": "Border for warning states; AA against both surfaces"
    }
  }
}

The dark path jumps four steps, not one—on near-black surfaces a 700-step orange disappears. Mode pairs are chosen for contrast parity, not for symmetry, and that asymmetry is exactly the knowledge the token now carries forever.

Step 3: Generate and read the diff

npm run tokens:build

/* app/designTokens.scss (generated diff) */
 @layer semantic {
   :root {
+    --semantic-color-feedback-border-warning: #a86a00;
   }
 }
 @layer theme {
   .dark {
+    --semantic-color-feedback-border-warning: #ffc458;
   }
 }

The build is the only writer of this file. Reviewing generated output is not busywork—it is where a wrong reference (say, aliasing orange.100 by typo) becomes visible as a value that could never pass contrast.

Step 4: Validate contrast in CI

The claim "AA against both surfaces" is checkable with the repo's own validator vocabulary:

// utils/accessibility/tokenValidator.ts (excerpt)
export const WCAG_LEVELS = {
  AA_NORMAL: 4.5,
  AA_LARGE: 3.0,
  AAA_NORMAL: 7.0,
  AAA_LARGE: 4.5,
};

// contrastRatioHex(fg, bg) → (L1 + 0.05) / (L2 + 0.05)
// A border is UI: it must clear AA_LARGE (3.0:1) against
// background.primary in BOTH modes — light #ffffff and dark #000000.

A three-to-one bar sounds low until you check both modes: a border that clears 3:1 on white can fail on black. That is the recurring lesson of token-level accessibility—every pair is a per-mode claim.

Step 5: Consume through the component contract

/* ui/components/TextField/TextField.tokens.css */
[data-ds-component='TextField'] {
  --ds-text-field-border-warning:
    var(--semantic-color-feedback-border-warning, #824500);
}

/* The component knows a warning border exists; nothing else. */
.textFieldWarning {
  border-color: var(--ds-text-field-border-warning);
}

Notice what never happened: nobody wrote a hex in a component, nobody branched on dark mode in a component, and when the brand switches to ocean—teal accent—the warning border stays orange, because feedback semantics reference the orange ramp, not the brand ramp. Five steps, and the decision is now durable infrastructure instead of a style line.

Constraints & Trade-offs

Every choice below was made to keep the system cheap to change in the directions it actually changes. Naming the tradeoff is the point—if you cannot say what a rule costs, you cannot tell when the rule is wrong.

  • Ramp width (8 steps): wide enough that states can be "one step away," narrow enough that choosing is still a decision. More steps would push selection cost onto every usage; fewer would force semantic roles to share steps, which is how two intents end up visually identical.
  • Semantic granularity: a role per intent (feedback.warning vs status.caution) makes theming precise but multiplies the matrix that must be validated. The working rule: split roles when the intents theme differently or fail differently; merge them when they do not.
  • Two themes, enforced: shipping exactly light and dark keeps every pair checkable in CI. A third high-contrast theme is additive (one more paths layer) but grows every pair's validation from two claims to three—budget for the audit, not just the values.
  • Perceptual ramps vs brand fidelity: the ramps are contrast-anchored derivations, not hand-picked colors, but a brand guideline can still mandate a specific flagship hex. The escape hatch honors both: pin brand.primary.500 to the brand's value and re-derive the neighbors with the generator under the same anchors—never a hex in a component.
  • Fallbacks in component tokens: the literal in var(--semantic-…, #ffffff) buys resilience when the global sheet fails and costs drift risk—the literal can silently age away from the token. Treat fallback updates as part of the token change, checked in the same review.
  • sRGB as the working space: stored components are srgb because every target (CSS, Figma) consumes it without conversion. Wide-gamut display targets would add a color-space decision at generation time—the DTCG structure already supports it; nothing in the semantic layer would change.

Common Pitfalls & Failure Modes

These are the failure modes that recur in every color system's first two years, each with the repair that fits this architecture:

1. Hex values in component styles

// Bad: The component now owns a theming decision
.card { border-color: #824500; }

// Good: The component owns an interface; the system owns the value
.card { border-color: var(--ds-card-border-warning, #824500); }

One hex looks harmless; the tenth makes dark mode a re-audit of the codebase. The linter exists because review cannot catch these reliably at scale.

2. Validating only the light theme

// Bad: A pair checked once, shipped twice
validateColorPair(fg.secondary, bg.primary);        // light: 7.5:1 ✓

// Good: Every pair is a per-mode claim
validateColorPair(fg.secondary, bg.primary, 'light'); // 7.5:1 ✓
validateColorPair(fg.secondary, bg.primary, 'dark');  // 9.5:1 ✓

Dark mode failures cluster exactly where light mode is strongest: mid-ramp colors that pass easily on white and vanish on black.

3. Components reaching past their contract

// Bad: The card is now coupled to global structure
.card { background: var(--semantic-color-background-primary); }

// Good: Scoped indirection keeps the system refactorable
.card { background: var(--ds-card-color-background-default); }

The scoped variable is two lines of insurance: it is the only thing that lets semantic tokens be renamed or re-layered without a component-by-component hunt.

4. Borrowing a background ramp for text

// Bad: red.500 passes AA on white (≈4.9:1) — as a BUTTON fill —
//    and fails as body text the day someone reads a paragraph
.bodyAccent { color: var(--core-color-palette-red-500); }

// Good: Text needs a ramp position chosen for text
.bodyAccent { color: var(--semantic-color-foreground-action); }

The numbers make it concrete: the accent clears 4.5:1 on white but not 7:1, and dips further on tinted surfaces. Roles exist so the system, not each author, remembers which threshold applies.

5. Alpha layered on tokens

// Bad — Opacity over a resolved color: contrast is now
//    a function of whatever happens to be underneath
.scrim {
  background: var(--semantic-color-overlay-scrim);
  opacity: 0.5;
}

// Good: Bake alpha where it can be tracked — the core layer has
//    explicit alpha-carrying colors (color.mode.transparent,
//    color.opacity.50) so the composited value is the token

Once alpha composites at usage time, the same token renders differently over every surface and no validator can speak to the result. Alpha is a value decision; make it in the value layer.

6. Treating the generated file as editable

/* Bad: app/designTokens.scss says AUTO-GENERATED for a reason:
   a hand edit is a change with no source, no diff review,
   and no survival of the next build */

The generated stylesheet is an artifact with a source of truth upstream. If something is wrong in the output, the fix lands in the JSON or the generator—anything else is shadow state.

Color System Health Metrics

A color system's health is measurable, and the measurements are cheap enough to run continuously. Three signals cover most of the risk:

Signal 1: Contrast debt

  • Healthy: every declared semantic pair validates against its target level in every shipped mode; validator output is empty and stays empty in CI.
  • Warning: pairs pass AA but sit between 4.5:1 and 7:1 where a guideline (or a customer contract) expects AAA—the accent-as-text case is the canonical example, fine until someone sets long-form copy in it.
  • Critical: any pair below 4.5:1 for body text or 3:1 for UI in any mode. This is a shipping defect, not technical debt—the fix outranks feature work.

Signal 2: Off-system color

  • Healthy: zero raw hex or rgb literals in component styles; tokens:lint and a grep for hex patterns in ui/components/**/*.css both come back clean.
  • Warning: literals appear only inside component token fallbacks, where they are tracked drift risk, not decisions.
  • Critical: literals in component rules or inline styles—the migration has stalled, and every new screen adds to the re-audit surface.

Signal 3: Theme parity

  • Healthy: every semantic token resolves in every shipped mode and brand; a missing paths.dark entry fails the schema and validators at build time, not in a screenshot review.
  • Warning: parity exists but is manual—brands were checked when added and not since; the next brand is where this becomes critical.
  • Critical: a mode or brand where tokens fall back to light values—users see it as "the dark theme is broken," and it is usually one missing design.paths extension away.

The pattern across all three: healthy is when the claims are machine-checkable and checked. The moment a color claim can only be verified by opening a file and squinting, the system has started becoming a palette.

Migration Strategy: From Hard-Coded Hex to Tokens

Legacy surfaces are mostly hex soup, and the migration that works is mechanical, staged, and boring on purpose:

  1. Inventory before editing. Extract every literal (hex, rgb, named colors) from component styles with their file and line. The list is the project plan; dedupe it and most systems discover they use far fewer distinct colors than anyone feared.
  2. Map each literal to ramp position. For each distinct color, decide: which family, which step, which semantic role should own it? Colors with no defensible role are the migration's actual payoff—delete or absorb them now.
  3. Add missing semantic tokens first. Land every new alias and its paths extensions in the token sources and rebuild, so the generated sheet carries the full target vocabulary before any component changes.
  4. Migrate by component, highest-traffic first. Swap literals for scoped component tokens one component at a time; each component merge is independently revertible, and visual diffs stay reviewable.
  5. Turn the linters on as you go. Enable the layer-boundary and naming checks per directory as it migrates, so regression is blocked where cleanup has happened rather than where it has not.

A codemod sketch for the mechanical core of step 4:

// Map of literal → scoped component token, built in step 2
const literalToToken = {
  '#824500': '--ds-text-field-border-warning',
  '#d9292b': '--ds-action-background-primary-default',
  // ...
};

// Mechanical pass: replace literals that have a mapping,
// and ONLY literals that have a mapping
for (const file of componentCssFiles) {
  let css = read(file);
  for (const [literal, token] of Object.entries(literalToToken)) {
    css = css.replaceAll(literal, `var(${token}, ${literal})`);
  }
  write(file, css);
  // Unmapped literals are left in place — they are step 2's
  // remaining work, not noise to silence mechanically.
}

Note the shape of the replacement: the literal survives as the fallback, so behavior cannot change in the same commit that structure does. Boring, staged, revertible—that is the whole strategy.

Verification Checklist

Additional Resources

Where to go next, inside and outside this system:

  • Design Tokens — the token architecture this page's color layer sits inside: formats, resolution, and validation (/blueprints/foundations/tokens).
  • Core vs Semantic layers — a worked deep-dive on the layer boundary this page applies to color (/blueprints/foundations/tokens/core-vs-semantic).
  • WCAG 2.1 contrast requirements — the normative definitions behind the 4.5:1 / 3:1 / 7:1 thresholds (https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html).
  • DTCG design tokens format — the W3C community group format the token sources follow (https://tr.designtokens.org/format/).
  • Adaptive-DS-Colors — the generator behind the ramps: contrast-keyed at 1.15:1, uniformly distributed in luminance, shared per-level targets across families.
  • The sources themselves — ui/designTokens/core/color.tokens.json, ui/designTokens/semantic/color.tokens.json, and the generated app/designTokens.scss are the runnable truth of everything above.

Related Concepts

Reflection Questions