A complete design token specification for data-dense interfaces: token architecture, functional colour assignments, accessibility validation, and dark/light mode management.
Dark-first for data interfaces. Dark backgrounds reduce eye strain during extended use, improve contrast for bright data elements (such as highlighted nodes on a graph), and increase perceived performance by reducing the number of high-luminance pixels refreshed per frame. Deliver a dark-first theme and treat light mode as a secondary variant.
Colour encodes meaning, not aesthetics. Every colour in the interface must communicate something. Brand colours do not belong on data elements — they have no semantic relationship to the data being represented. Reserve brand colours for chrome (product logo, top navigation, key CTAs).
No hardcoded colour values. Every colour reference in component code must resolve to a design token. var(--colour-accent-primary), not #00BFFF. This is the only way to guarantee consistent theming, dark/light switching, and future palette changes without a codebase-wide search-and-replace.
Accessibility through luminance. WCAG 2.1 AA requires a minimum contrast ratio of 4.5:1 for normal text against its background. Build this into the token definitions, not as an afterthought. Every text/background token pair must be verified to meet the minimum.
Tokens operate at three levels. Each level gets more specific; each level uses only references to the level below.
Raw palette values — colour names with hex/HSL values. No semantics attached. These are never used directly in components.
/* Example base token set */
--colour-blue-100: #e6f4ff;
--colour-blue-300: #93c5fd;
--colour-blue-500: #3b82f6;
--colour-blue-700: #1d4ed8;
--colour-blue-900: #1e3a8a;
--colour-neutral-0: #ffffff;
--colour-neutral-50: #f8fafc;
--colour-neutral-100: #f1f5f9;
--colour-neutral-200: #e2e8f0;
--colour-neutral-400: #94a3b8;
--colour-neutral-600: #475569;
--colour-neutral-800: #1e293b;
--colour-neutral-900: #0f172a;
--colour-neutral-950: #020617;
--colour-amber-400: #fbbf24;
--colour-amber-500: #f59e0b;
--colour-red-400: #f87171;
--colour-red-500: #ef4444;
--colour-green-400: #4ade80;
--colour-green-500: #22c55e;
--colour-cyan-400: #22d3ee;
--colour-cyan-500: #06b6d4;
--colour-violet-400: #a78bfa;
--colour-violet-500: #8b5cf6;
--colour-orange-400: #fb923c;
--colour-orange-500: #f97316;Named by function, not by colour value. These are what components reference.
/* Dark mode (default) */
:root[data-theme="dark"] {
/* Surfaces */
--surface-background: var(--colour-neutral-950);
--surface-elevated: var(--colour-neutral-900);
--surface-overlay: var(--colour-neutral-800);
--surface-interactive: var(--colour-neutral-700, 20%); /* with opacity */
/* Typography */
--text-primary: var(--colour-neutral-50);
--text-secondary: var(--colour-neutral-400);
--text-muted: var(--colour-neutral-600);
--text-disabled: var(--colour-neutral-700);
/* Borders */
--border-default: var(--colour-neutral-800);
--border-strong: var(--colour-neutral-600);
--border-focus: var(--colour-cyan-500);
/* Status */
--status-success: var(--colour-green-400);
--status-warning: var(--colour-amber-400);
--status-error: var(--colour-red-400);
--status-info: var(--colour-cyan-400);
/* Accent */
--accent-primary: var(--colour-cyan-500);
--accent-secondary: var(--colour-violet-400);
--accent-focus: var(--colour-cyan-400);
}
/* Light mode */
:root[data-theme="light"] {
/* Surfaces */
--surface-background: var(--colour-neutral-50);
--surface-elevated: var(--colour-neutral-0);
--surface-overlay: var(--colour-neutral-100);
--surface-interactive: var(--colour-neutral-200);
/* Typography */
--text-primary: var(--colour-neutral-900);
--text-secondary: var(--colour-neutral-600);
--text-muted: var(--colour-neutral-400);
--text-disabled: var(--colour-neutral-300);
/* Borders */
--border-default: var(--colour-neutral-200);
--border-strong: var(--colour-neutral-400);
--border-focus: var(--colour-blue-500);
/* Status */
--status-success: var(--colour-green-500);
--status-warning: var(--colour-amber-500);
--status-error: var(--colour-red-500);
--status-info: var(--colour-cyan-500);
/* Accent */
--accent-primary: var(--colour-blue-500);
--accent-secondary: var(--colour-violet-500);
--accent-focus: var(--colour-blue-700);
}Scoped to a specific component. Reference semantic tokens, never base tokens.
/* Example: graph node component tokens */
--node-background: var(--surface-elevated);
--node-border: var(--border-default);
--node-border-selected: var(--accent-focus);
--node-text: var(--text-primary);
--node-text-secondary: var(--text-secondary);
--node-glow-selected: var(--accent-primary);Base tokens → raw colour values (never used directly in components)
↓
Semantic tokens → named by function (used in component tokens and typography)
↓
Component tokens → scoped to one component (used in component CSS)
This indirection means: changing the dark mode primary accent colour requires updating one semantic token. Every component that references that semantic token updates automatically.
| Token | Dark value | Light value | Use |
|---|---|---|---|
--status-success |
#4ade80 |
#22c55e |
Pipeline success, validation pass, healthy |
--status-warning |
#fbbf24 |
#f59e0b |
Degraded state, low confidence, attention required |
--status-error |
#f87171 |
#ef4444 |
Pipeline failure, validation error, critical alert |
--status-info |
#22d3ee |
#06b6d4 |
Informational, neutral highlight, in-progress |
Status colours must not be repurposed for categorical data encoding — they carry semantic meaning that overrides any other use.
An 8-value palette for encoding categorical entity types. Selected for perceptual distinctness and colour-blind safety:
| Index | Dark hex | Light hex | Entity type (example) | Colour-blind safe |
|---|---|---|---|---|
| 1 | #22d3ee |
#0891b2 |
Person / Individual | Yes |
| 2 | #4ade80 |
#16a34a |
Organisation | Yes |
| 3 | #fbbf24 |
#d97706 |
Location | Yes |
| 4 | #a78bfa |
#7c3aed |
Event | Yes |
| 5 | #fb923c |
#ea580c |
Concept / Topic | Yes |
| 6 | #f472b6 |
#db2777 |
Document / Source | Yes (protanopia) |
| 7 | #34d399 |
#059669 |
Process / Activity | Yes |
| 8 | #60a5fa |
#2563eb |
Product / Artefact | Yes |
Note on colour 6 (pink): borderline for protanopia (red-blindness). If your entity palette includes this colour, pair it with a distinct shape.
Confidence is encoded through opacity applied on top of the categorical colour:
| Confidence | Opacity multiplier | Visual result |
|---|---|---|
| 0.80 – 1.00 | 1.0 | Full colour |
| 0.60 – 0.79 | 0.75 | Slightly faded |
| 0.40 – 0.59 | 0.55 | Notably faded |
| 0.20 – 0.39 | 0.35 | Clearly faded |
| < 0.20 | 0.20 | Nearly transparent |
This allows colour (categorical) and opacity (confidence) to vary independently. A blue Person node with 0.3 confidence is clearly a person (blue, circular) but visually uncertain (faded).
Mode switching is handled at the semantic token layer. All component code uses semantic tokens and switches mode transparently:
// Mode switch — one function changes the entire interface
function setTheme(mode) {
document.documentElement.setAttribute('data-theme', mode);
localStorage.setItem('preferred-theme', mode);
}
// Respecting system preference
const systemPreference = window.matchMedia('(prefers-color-scheme: dark)');
const savedPreference = localStorage.getItem('preferred-theme');
setTheme(savedPreference ?? (systemPreference.matches ? 'dark' : 'light'));No component should inspect the current theme directly. Components read tokens; tokens change when the theme changes.
The same contrast ratio targets apply in both modes. A text/background pair that meets WCAG AA in dark mode must also meet it in light mode:
| Token pair | Dark contrast | Light contrast | WCAG AA |
|---|---|---|---|
--text-primary / --surface-background |
17.4:1 | 15.8:1 | ✓ AAA |
--text-secondary / --surface-background |
5.9:1 | 4.6:1 | ✓ AA |
--status-error / --surface-background |
5.2:1 | 4.9:1 | ✓ AA |
--accent-primary / --surface-background |
4.8:1 | 4.6:1 | ✓ AA |
--text-muted / --surface-background |
2.8:1 | 2.5:1 | ✗ (use for non-text only) |
The --text-muted token does not meet AA for text. Use it only for decorative or non-essential text (timestamps, version numbers), never for content the user must read.
- Automated contrast check: integrate a colour contrast audit into your CI/CD pipeline. Tools: axe-core (for DOM-based interfaces), Storybook a11y addon.
- Colour-blind simulation: use the Chromium DevTools "Emulate vision deficiency" feature to test all four major deficiency types (protanopia, deuteranopia, tritanopia, achromatopsia).
- Manual dark/light comparison: render the key screens in both modes side-by-side and verify no information is lost in either mode.
- User preference test: verify that
prefers-color-scheme: darkis respected on first load before any user preference is saved.
Apply this checklist before releasing any new interface or after significant palette changes:
Contrast ratios:
- All body text tokens meet WCAG 2.1 AA (≥ 4.5:1)
- All large-text tokens meet WCAG 2.1 AA (≥ 3:1)
- All non-text UI component colours meet WCAG 2.1 AA (≥ 3:1)
- No critical information is conveyed by colour alone (always pair with shape, pattern, or label)
Colour blindness:
- Categorical palette verified safe for deuteranopia (most common: red-green)
- Status palette verified safe for deuteranopia and protanopia
- Any red-green combination has a secondary distinguishing channel
Dark / light mode:
- All semantic tokens have explicit dark and light values defined
- No component references base tokens directly (always via semantic tokens)
- Mode switch tested without page reload
- System preference (
prefers-color-scheme) is respected on first load
Token hygiene:
- No hardcoded hex values in component stylesheets
- All new tokens added to the base → semantic → component hierarchy
- Token names follow the naming convention (
--[category]-[role]-[modifier]) - Deprecated tokens are listed with a migration path, not silently removed