Design Tokens — color

Design tokens are named values of the visual language. Instead of “that blue somewhere in CSS,” you get a contract: color.primary.default, understood the same way by design, web, and — when needed — native.

For color this is especially critical: inconsistency spreads fastest here, and accessibility breaks here first.

Why tokens instead of hex in components

// Bad: magic value
<button style={{ backgroundColor: '#3B82F6' }} />

// Better: token
<button style={{ backgroundColor: 'var(--color-action-primary)' }} />

Benefits:

  • one source of truth;
  • easier themes (light/dark/brand);
  • easier audits and cross-platform consistency;
  • palette changes do not require hunting the whole repository.

Three layers of color tokens

1. Primitive (raw palette)

export const color = {
  blue: {
    50: '#eff6ff',
    100: '#dbeafe',
    500: '#3b82f6',
    600: '#2563eb',
    900: '#1e3a8a'
  },
  gray: {
    50: '#f9fafb',
    500: '#6b7280',
    900: '#111827'
  }
};

2. Semantic (meaning in the product)

export const semanticColor = {
  action: {
    primary: '{color.blue.500}',
    primaryHover: '{color.blue.600}'
  },
  text: {
    primary: '{color.gray.900}',
    secondary: '{color.gray.500}'
  },
  feedback: {
    success: '#16a34a',
    danger: '#dc2626',
    warning: '#d97706'
  }
};

3. Component tokens (optional)

button.primary.bg → semantic.action.primary. Needed when variants multiply and the shared semantic layer is no longer enough.

Components should consume semantic (or component) tokens, not primitive blue.500 directly — otherwise a brand change turns into a repo-wide search.

How to build a palette

  1. Lock brand colors from the guidelines.
  2. Build a 50–900 scale (or a smaller but regular set of steps).
  3. Add neutrals for text, backgrounds, and borders.
  4. Add feedback: success / warning / danger / info.
  5. Check contrast of key pairs.

The neutral scale usually matters more than a “pretty accent”: it carries typography, forms, and tables.

Contrast and a11y

WCAG AA minimum:

  • normal text — 4.5:1;
  • large text and meaningful UI elements — 3:1.

Practical rules:

  • primary text on background — high contrast (gray.900 on white);
  • secondary text must still be readable, not “almost gray on gray”;
  • do not rely on color alone for errors — add text/icon;
  • check the primary button: background + text + focus state.

Tools: WebAIM Contrast Checker, DevTools, axe.

Warning

A text.disabled token may be intentionally low-contrast, but interactive elements that look disabled without another state cue are a common a11y mistake.

CSS variables as the delivery layer

:root {
  --color-bg: #ffffff;
  --color-text: #111827;
  --color-action-primary: #2563eb;
  --color-border: #e5e7eb;
}

[data-theme="dark"] {
  --color-bg: #0b1220;
  --color-text: #f8fafc;
  --color-action-primary: #60a5fa;
  --color-border: #334155;
}
.button-primary {
  background: var(--color-action-primary);
  color: #fff;
}

This approach pairs well with both Tailwind theme and CSS Modules: JS tokens stay the source, CSS variables are the runtime contract.

Dark mode without pain

Do not copy an “inverted rainbow.” Switch semantic tokens:

  • background / surface / elevated;
  • text primary/secondary;
  • borders and shadows;
  • action colors (sometimes lighter on dark backgrounds for contrast).

The primitive palette can stay shared; what changes is the semantic → primitive mapping.

Lesson takeaway

A design-system color system = palette + semantic mapping + contrast checks + delivery via variables/theme. If you skip the semantic layer, you do not have tokens — you have a list of hex codes.