How to Build a Design System Color Palette
Summary: A design system color palette isn't just a list of hex codes β it's a structured token architecture that scales across products, themes, and platforms. This guide walks you through every layer: from extracting brand colours to building tonal scales, defining primitive, semantic, and component tokens, implementing light and dark mode with CSS custom properties, integrating with Tailwind CSS, syncing Figma to code, and learning from real-world systems like Material Design, Apple HIG, and GitHub Primer.
π Table of Contents
- 1. What Is a Design System?
- 2. Understanding Color Tokens
- 3. From Brand Color to Full Palette
- 4. Building Tonal Scales
- 5. Semantic Mapping
- 6. Component-Level Tokens
- 7. Light & Dark Mode Tokens
- 8. CSS Custom Properties Implementation
- 9. Tailwind CSS Custom Theme
- 10. Figma β Code Workflow
- 11. Documenting Your Palette
- 12. Real-World Examples
- 13. Common Mistakes
- 14. FAQ
1. What Is a Design System?
A design system is a collection of reusable components, design decisions, and guidelines that ensure consistency across every product and platform a team builds. It's not just a UI kit β it's the shared language between designers, developers, product managers, and brand teams. The design system codifies how things look, how they behave, and why those decisions were made.
At the foundation of every design system sits the color palette. Color touches every component β buttons, text, backgrounds, borders, icons, alerts, charts, illustrations. A poorly structured palette creates cascading inconsistencies: designers pick slightly different blues, developers hardcode hex values, and dark mode becomes an afterthought patched with filter: invert(1). A well-structured palette, built on tokens, makes theming trivial and consistency automatic.
The difference between a "list of colours" and a "design system color palette" is architecture. A list says "our blue is #3B82F6." A design system says "our primary color token resolves to blue-500 in light mode and blue-400 in dark mode, and is used for interactive elements, links, and focus rings." That structure is what this guide teaches you to build.
If you're starting from scratch and need help choosing your initial brand colours, start with our guide on how to choose a color palette for your website, then come back here to systematise those choices into a full token architecture.
2. Understanding Color Tokens
Color tokens are named variables that store colour values. Instead of scattering #3B82F6 across your codebase, you reference --color-primary. But tokens aren't just CSS variables with pretty names β they form a hierarchy that separates what a colour is from what a colour means from where a colour is used.
The Three Token Tiers
π΅ Tier 1: Primitive Tokens (Global)
Raw colour values named by their hue and shade. These are the building blocks β they describe what the colour is, not what it's for. Primitive tokens are exhaustive: they contain every shade you might ever need.
/* Primitive tokens β named by hue + shade */ --blue-50: #EFF6FF; --blue-100: #DBEAFE; --blue-200: #BFDBFE; --blue-300: #93C5FD; --blue-400: #60A5FA; --blue-500: #3B82F6; --blue-600: #2563EB; --blue-700: #1D4ED8; --blue-800: #1E40AF; --blue-900: #1E3A8A; --blue-950: #172554; --gray-50: #F9FAFB; --gray-100: #F3F4F6; /* ... full scale ... */ --gray-900: #111827; --gray-950: #030712;
π― Tier 2: Semantic Tokens (Intent)
Semantic tokens map primitives to meaning. They describe what a colour communicates β primary, danger, success, surface, muted β without specifying where it's used. This is the layer that enables theming: changing --color-primary from var(--blue-500) to var(--blue-400) in dark mode doesn't require touching any component code.
/* Semantic tokens β named by intent */ --color-primary: var(--blue-500); --color-secondary: var(--purple-500); --color-success: var(--green-500); --color-warning: var(--amber-500); --color-danger: var(--red-500); --color-info: var(--sky-500); --color-foreground: var(--gray-900); --color-muted: var(--gray-500); --color-surface: var(--gray-50); --color-border: var(--gray-200);
π§© Tier 3: Component Tokens (Scoped)
Component tokens bind semantic tokens to specific UI elements. They're the most granular layer β describing exactly where a colour appears. Not every team needs this tier, but it's invaluable at scale when you need to override one component's colour without affecting others that share the same semantic token.
/* Component tokens β named by element + property */ --button-primary-bg: var(--color-primary); --button-primary-text: var(--white); --button-primary-hover: var(--blue-600); --input-border: var(--color-border); --input-focus-ring: var(--color-primary); --input-bg: var(--color-surface); --card-bg: var(--color-surface); --card-border: var(--color-border); --alert-danger-bg: var(--red-50); --alert-danger-text: var(--red-800); --alert-danger-border: var(--red-200);
This three-tier architecture means you can rebrand an entire product by changing only primitive tokens. You can switch between light and dark themes by remapping semantic tokens. And you can customise individual components without side effects by adjusting component tokens. The token chain always flows downward: primitives β semantics β components.
3. From Brand Color to Full Palette
Every design system palette starts with one or two brand colours. The challenge is expanding those into a complete, functional system. Here's a step-by-step process that works for teams of any size.
Step 1: Define Your Brand Anchors
Start with your primary brand colour and one or two supporting colours. These are your anchor hues β the colours that define your visual identity. If you don't have brand colours yet, use Hue's palette generator to explore options, or extract them from an existing logo or image with the color extractor.
/* Brand anchors */ Primary: #3B82F6 (Blue β trust, technology) Secondary: #8B5CF6 (Purple β creativity, premium) Accent: #F59E0B (Amber β energy, attention)
Step 2: Add Functional Hues
Every interface needs status colours. These are non-negotiable β users have strong learned associations with them.
/* Functional hues */ Success: #22C55E (Green β confirmations, completion) Warning: #F59E0B (Amber β caution, attention needed) Danger: #EF4444 (Red β errors, destructive actions) Info: #0EA5E9 (Sky β informational, neutral alerts)
Step 3: Build a Neutral Scale
Neutrals are the backbone of your palette β they handle backgrounds, text, borders, dividers, and disabled states. Most of your UI is neutral. A slightly tinted neutral (warm grey, cool grey, or blue-grey) creates a more cohesive feel than pure grey.
/* Cool-tinted neutral scale */ --gray-50: #F8FAFC; /* lightest background */ --gray-100: #F1F5F9; /* secondary background */ --gray-200: #E2E8F0; /* borders, dividers */ --gray-300: #CBD5E1; /* disabled text/borders */ --gray-400: #94A3B8; /* placeholder text */ --gray-500: #64748B; /* muted text */ --gray-600: #475569; /* secondary text */ --gray-700: #334155; /* primary text (dark bg) */ --gray-800: #1E293B; /* headings (dark bg) */ --gray-900: #0F172A; /* primary text */ --gray-950: #020617; /* darkest background */
Tip: Tailwind CSS popularised the "Slate" scale β a blue-tinted grey that works beautifully with blue-primary palettes. If your primary is warm (orange, red), consider a warm grey like "Stone" or "Sand" instead. The tint should be subtle enough that neutrals still read as grey, not as a colour.
Step 4: Generate Tonal Scales for Each Hue
Each anchor and functional hue needs a full tonal scale β typically 10-13 steps from near-white to near-black. The next section covers how to build these systematically.
4. Building Tonal Scales
A tonal scale takes a single hue and produces a range of shades from very light (for backgrounds) to very dark (for text on light backgrounds). The key is perceptual uniformity β each step should feel like an equal jump in lightness to the human eye.
The HSL Approach
The simplest method is to fix the hue, slightly adjust saturation, and sweep lightness from ~97% (50-step) down to ~10% (950-step). HSL isn't perceptually uniform β a 10% lightness change at the top of the scale looks different from one at the bottom β but it's a reasonable starting point that you can refine by eye.
/* HSL-based blue scale */ --blue-50: hsl(214, 100%, 97%); /* #EFF6FF */ --blue-100: hsl(214, 95%, 93%); /* #DBEAFE */ --blue-200: hsl(214, 93%, 87%); /* #BFDBFE */ --blue-300: hsl(214, 94%, 78%); /* #93C5FD */ --blue-400: hsl(217, 91%, 68%); /* #60A5FA */ --blue-500: hsl(217, 91%, 60%); /* #3B82F6 β anchor */ --blue-600: hsl(221, 83%, 53%); /* #2563EB */ --blue-700: hsl(224, 76%, 48%); /* #1D4ED8 */ --blue-800: hsl(226, 71%, 40%); /* #1E40AF */ --blue-900: hsl(224, 64%, 33%); /* #1E3A8A */ --blue-950: hsl(226, 57%, 21%); /* #172554 */
Notice how the hue shifts slightly across the scale (214 β 226). This is intentional β it counteracts the BezoldβBrΓΌcke shift, a perceptual phenomenon where hue appears to change as lightness changes. Blues look more purple when darker, so shifting the hue slightly towards blue compensates.
The OKLCH Approach (Modern)
OKLCH is a perceptually uniform colour space β equal numeric steps produce visually equal lightness changes. It's the best tool for generating scales that feel consistent.
/* OKLCH-based scale generation */ --blue-50: oklch(0.97 0.01 250); /* very light */ --blue-100: oklch(0.93 0.03 250); --blue-200: oklch(0.87 0.07 250); --blue-300: oklch(0.78 0.12 250); --blue-400: oklch(0.68 0.17 250); --blue-500: oklch(0.60 0.20 250); /* anchor */ --blue-600: oklch(0.53 0.20 250); --blue-700: oklch(0.46 0.18 250); --blue-800: oklch(0.39 0.15 250); --blue-900: oklch(0.32 0.12 250); --blue-950: oklch(0.22 0.08 250); /* very dark */
OKLCH has excellent browser support in 2026. If you need to support older browsers, generate your scale in OKLCH for perceptual accuracy and then export to hex or RGB for the actual token values. Hue's palette generator can output in either format.
Testing Your Scale
Once you've generated a scale, test it against these criteria:
- Contrast ratios: 50β100 steps should work as backgrounds for dark text. 800β950 steps should work as backgrounds for light text. Check with Hue's contrast checker.
- Visual uniformity: Lay all steps side by side. The lightness jumps should feel even β no sudden dark patches or barely-distinguishable adjacent steps.
- Hue consistency: All steps should feel like the "same colour" at different brightnesses. If the 900 step looks purple when it should be blue, adjust the hue.
- Cross-hue harmony: Place your blue, red, and green 500 steps side by side. They should feel like they belong in the same family β similar visual weight and saturation intensity.
5. Semantic Mapping
Semantic mapping is the act of assigning meaning to your primitive colours. This is where you go from "we have blue-500" to "blue-500 means primary, and primary means interactive elements." Semantic tokens are the most important layer of your token system β they're what component authors actually reference.
Categories of Semantic Tokens
/* ===== Backgrounds ===== */ --surface-default: var(--white); /* page bg */ --surface-raised: var(--gray-50); /* card bg */ --surface-overlay: var(--white); /* modal bg */ --surface-sunken: var(--gray-100); /* input bg, recessed areas */ /* ===== Text ===== */ --text-primary: var(--gray-900); /* headings, body */ --text-secondary: var(--gray-600); /* descriptions */ --text-muted: var(--gray-500); /* captions, timestamps */ --text-disabled: var(--gray-400); /* disabled labels */ --text-inverse: var(--white); /* text on dark bg */ --text-link: var(--blue-600); /* hyperlinks */ /* ===== Borders ===== */ --border-default: var(--gray-200); /* cards, inputs */ --border-strong: var(--gray-300); /* emphasized borders */ --border-focus: var(--blue-500); /* focus rings */ /* ===== Status ===== */ --status-success: var(--green-500); --status-warning: var(--amber-500); --status-danger: var(--red-500); --status-info: var(--sky-500); /* ===== Interactive ===== */ --interactive-default: var(--blue-500); /* buttons, links */ --interactive-hover: var(--blue-600); /* hover state */ --interactive-active: var(--blue-700); /* pressed state */ --interactive-disabled: var(--gray-300); /* disabled state */
The naming convention matters. Avoid colour names in semantic tokens β --interactive-default is better than --blue-interactive because the colour might change during a rebrand, but the intent remains the same. Token names should describe purpose, not appearance.
Different design systems use different naming conventions. Material Design uses md-sys-color-primary. GitHub Primer uses color-fg-default. The prefix matters less than consistency β pick a convention and enforce it across every platform.
6. Component-Level Tokens
Component tokens are the most granular tier. They bind semantic tokens to specific elements, giving you surgical control over individual component colours without affecting the rest of the system. Not every project needs this tier β small teams can often skip straight from semantic tokens to components. But at scale (multiple products, white-labelling, complex theming), component tokens are essential.
/* Button component tokens */ --button-primary-bg: var(--interactive-default); --button-primary-bg-hover: var(--interactive-hover); --button-primary-bg-active: var(--interactive-active); --button-primary-text: var(--text-inverse); --button-primary-border: transparent; --button-secondary-bg: transparent; --button-secondary-bg-hover: var(--surface-raised); --button-secondary-text: var(--interactive-default); --button-secondary-border: var(--border-default); --button-danger-bg: var(--status-danger); --button-danger-bg-hover: var(--red-600); --button-danger-text: var(--text-inverse); /* Alert component tokens */ --alert-success-bg: var(--green-50); --alert-success-text: var(--green-800); --alert-success-border: var(--green-200); --alert-success-icon: var(--green-500); --alert-danger-bg: var(--red-50); --alert-danger-text: var(--red-800); --alert-danger-border: var(--red-200); --alert-danger-icon: var(--red-500); /* Input component tokens */ --input-bg: var(--surface-sunken); --input-border: var(--border-default); --input-border-focus: var(--border-focus); --input-text: var(--text-primary); --input-placeholder: var(--text-muted); --input-ring: var(--blue-500 / 0.3);
The power of component tokens shows up in theming scenarios. Imagine a white-label product where Client A wants red primary buttons and Client B wants green. With component tokens, you only change --button-primary-bg β the rest of the system stays intact. Without them, you'd have to override --interactive-default, which would cascade to every interactive element β links, focus rings, toggles, everything.
7. Light & Dark Mode Tokens
The token architecture you've built pays off here. Switching between light and dark mode is simply a matter of remapping semantic tokens to different primitive values. The component code never changes β it still references var(--surface-default) and var(--text-primary). Only the values behind those tokens change.
/* ===== Light Mode (default) ===== */
:root,
[data-theme="light"] {
--surface-default: var(--white);
--surface-raised: var(--gray-50);
--surface-overlay: var(--white);
--text-primary: var(--gray-900);
--text-secondary: var(--gray-600);
--text-muted: var(--gray-500);
--border-default: var(--gray-200);
--interactive-default: var(--blue-600);
--interactive-hover: var(--blue-700);
}
/* ===== Dark Mode ===== */
[data-theme="dark"] {
--surface-default: var(--gray-950);
--surface-raised: var(--gray-900);
--surface-overlay: var(--gray-800);
--text-primary: var(--gray-50);
--text-secondary: var(--gray-300);
--text-muted: var(--gray-400);
--border-default: var(--gray-700);
--interactive-default: var(--blue-400);
--interactive-hover: var(--blue-300);
}
/* Alternative: system preference */
@media (prefers-color-scheme: dark) {
:root {
--surface-default: var(--gray-950);
--text-primary: var(--gray-50);
/* ... */
}
}Dark Mode Color Decisions
Dark mode isn't just "invert everything." Several decisions require specific thought:
- Primary colours shift lighter: A blue-600 that has good contrast on white needs to become blue-400 to have good contrast on dark backgrounds. Always verify with a contrast checker.
- Saturation decreases: Highly saturated colours on dark backgrounds cause visual vibration and eye strain. Desaturate your 400-step colours by 5-15% for dark mode.
- Elevation uses surface lightness: In light mode, elevated elements get shadows. In dark mode, Material Design's approach of using progressively lighter surfaces is more effective than shadows.
- Status colours need adjustment: A green-500 success badge on white is fine. On gray-900, it might need to be green-400, and its background might shift from green-50 to green-950/0.3.
Preview all of your dark mode token mappings with Hue's dark mode previewer to catch contrast and readability issues before shipping.
8. CSS Custom Properties Implementation
CSS custom properties (variables) are the native runtime mechanism for tokens. They cascade, they can be overridden per element, and they update live β making them ideal for theming, dark mode, and component-scoped colour overrides.
/* tokens.css β complete implementation */
/* ===== Primitive Layer ===== */
:root {
/* Blue */
--blue-50: #EFF6FF;
--blue-100: #DBEAFE;
--blue-200: #BFDBFE;
--blue-300: #93C5FD;
--blue-400: #60A5FA;
--blue-500: #3B82F6;
--blue-600: #2563EB;
--blue-700: #1D4ED8;
--blue-800: #1E40AF;
--blue-900: #1E3A8A;
--blue-950: #172554;
/* Neutral (Slate) */
--gray-50: #F8FAFC;
--gray-100: #F1F5F9;
--gray-200: #E2E8F0;
--gray-300: #CBD5E1;
--gray-400: #94A3B8;
--gray-500: #64748B;
--gray-600: #475569;
--gray-700: #334155;
--gray-800: #1E293B;
--gray-900: #0F172A;
--gray-950: #020617;
/* Red, Green, Amber β same structure */
}
/* ===== Semantic Layer (Light) ===== */
:root,
[data-theme="light"] {
--color-bg: var(--gray-50);
--color-surface: var(--white, #FFFFFF);
--color-text: var(--gray-900);
--color-text-muted: var(--gray-500);
--color-border: var(--gray-200);
--color-primary: var(--blue-600);
--color-primary-fg: var(--white, #FFFFFF);
}
/* ===== Semantic Layer (Dark) ===== */
[data-theme="dark"] {
--color-bg: var(--gray-950);
--color-surface: var(--gray-900);
--color-text: var(--gray-50);
--color-text-muted: var(--gray-400);
--color-border: var(--gray-700);
--color-primary: var(--blue-400);
--color-primary-fg: var(--gray-950);
}
/* ===== Usage in Components ===== */
.card {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 24px;
}
.btn-primary {
background: var(--color-primary);
color: var(--color-primary-fg);
}
.btn-primary:hover {
filter: brightness(1.1);
}Composable Alpha Channels
A common challenge: you want to use a token colour at 10% opacity for a background, but CSS custom properties don't let you modify opacity. There are two solutions:
/* Solution 1: Store as RGB components */
:root {
--primary-rgb: 59, 130, 246;
}
.badge {
background: rgba(var(--primary-rgb), 0.1);
color: rgb(var(--primary-rgb));
}
/* Solution 2: Modern color-mix() β no RGB splitting needed */
.badge-modern {
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
color: var(--color-primary);
}
/* Solution 3: Relative color syntax (newest) */
.badge-newest {
background: rgb(from var(--color-primary) r g b / 0.1);
color: var(--color-primary);
}The color-mix() function has broad browser support in 2026 and is the cleanest solution β no need to decompose tokens into RGB channels. It also works across colour spaces, so you can mix in OKLCH for better perceptual results.
9. Tailwind CSS Custom Theme
Tailwind CSS v3+ makes it straightforward to integrate your design system tokens. The key is mapping your tokens to Tailwind's theme config so you can use utility classes like bg-primary and text-surface instead of raw colours.
// tailwind.config.ts
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
// Primitive tokens (full scales)
blue: {
50: "#EFF6FF",
100: "#DBEAFE",
200: "#BFDBFE",
300: "#93C5FD",
400: "#60A5FA",
500: "#3B82F6",
600: "#2563EB",
700: "#1D4ED8",
800: "#1E40AF",
900: "#1E3A8A",
950: "#172554",
},
// Semantic tokens (reference CSS variables)
primary: {
DEFAULT: "var(--color-primary)",
foreground: "var(--color-primary-fg)",
},
surface: {
DEFAULT: "var(--color-surface)",
raised: "var(--color-surface-raised)",
},
foreground: {
DEFAULT: "var(--color-text)",
muted: "var(--color-text-muted)",
},
border: {
DEFAULT: "var(--color-border)",
},
success: "var(--color-success)",
warning: "var(--color-warning)",
danger: "var(--color-danger)",
},
},
},
};
export default config;By referencing CSS variables in your Tailwind config, theme switching happens via CSS β no JavaScript-driven class swapping needed. Changing [data-theme="dark"] on the <html> element instantly updates every Tailwind utility that references those variables.
Tailwind v4 CSS-First Tokens
/* Tailwind v4 β define tokens directly in CSS */
@theme {
--color-primary: var(--color-primary);
--color-surface: var(--color-surface);
--color-foreground: var(--color-text);
--color-border: var(--color-border);
--color-success: #22C55E;
--color-warning: #F59E0B;
--color-danger: #EF4444;
}
/* Now use: bg-primary, text-foreground, border-border */Tailwind v4's @theme directive eliminates the config file for token definitions. It's CSS-first, which means your tokens live in the same place as your other styles β reducing context-switching between CSS and JavaScript config.
10. Figma β Code Workflow
The biggest design system failure isn't wrong colours β it's colour drift between Figma and code. A designer updates a token in Figma, but the developer never sees the change. Or a developer adds a new semantic token in CSS, but Figma still uses raw hex values. The fix is a single source of truth with automated syncing.
Option 1: Figma Variables (Built-in)
Figma's native Variables feature lets you define colour tokens with modes (light/dark). You can create variable collections that mirror your token tiers: one collection for primitives, one for semantic tokens.
- Use the Figma REST API to export variables as JSON
- Transform the JSON into CSS custom properties using a build script
- Run the script on CI to auto-generate
tokens.cssfrom Figma data
Option 2: Tokens Studio Plugin
Tokens Studio (formerly Figma Tokens) is the most popular plugin for design token management. It supports multi-tier token hierarchies, references between tokens, and direct sync to GitHub/GitLab repositories. Changes in Figma create pull requests automatically.
Option 3: Style Dictionary (Build Tool)
Style Dictionary by Amazon is a build system that takes token definitions (JSON/YAML) and outputs platform-specific formats β CSS variables, iOS Swift, Android XML, SCSS, JavaScript, and more. This is the standard tool for multi-platform design systems.
// tokens/color.json (Style Dictionary input)
{
"color": {
"blue": {
"500": { "value": "#3B82F6", "type": "color" },
"600": { "value": "#2563EB", "type": "color" }
},
"primary": {
"value": "{color.blue.500}",
"type": "color",
"description": "Primary interactive color"
}
}
}
// Output: CSS custom properties
// --color-blue-500: #3B82F6;
// --color-blue-600: #2563EB;
// --color-primary: #3B82F6;
// Output: iOS Swift
// static let blue500 = UIColor(hex: "#3B82F6")
// static let primary = UIColor(hex: "#3B82F6")The ideal workflow: Figma (design) β Tokens Studio (sync) β GitHub (storage) β Style Dictionary (transform) β CSS/iOS/Android (output). Designers own the token values; the pipeline ensures code always reflects the latest design decisions.
11. Documenting Your Palette
An undocumented palette is an unused palette. Developers will guess, designers will go rogue, and your carefully constructed token hierarchy will be bypassed with raw hex values. Good documentation makes the right thing easier than the wrong thing.
What to Document
- Token catalogue: Every token, its value in each theme, and a visual swatch. Include the full chain: which primitive feeds which semantic feeds which component token.
- Usage guidelines: When to use
--color-primaryvs--color-info. When to use--text-secondaryvs--text-muted. The distinction is obvious to the person who created the tokens, but not to a new team member. - Do / Don't examples: Visual examples of correct and incorrect token usage. "Do: use
--status-dangerfor error states. Don't: use--red-500directly." - Contrast matrix: A table showing which foreground tokens meet WCAG AA/AAA against which background tokens. This prevents accessibility regressions before they happen.
- Decision log: Why you chose these specific values. "We use Slate instead of pure Gray because our primary is blue, and the cool undertone creates visual cohesion." This prevents future maintainers from "fixing" intentional decisions.
Where to Document
The best documentation lives where developers already look. A Notion page that nobody reads is worse than no documentation at all. Consider:
- Storybook: Live interactive docs next to the components themselves
- README in the tokens package: Visible in the repo, searchable, version-controlled
- A dedicated /design-system route: Internal site with live token rendering and a copy-paste reference
For public-facing documentation, consider a blog or guide like those on Byline that explains your design philosophy and shares the journey of building your system.
12. Real-World Examples
The best way to learn token architecture is to study systems that millions of developers use daily. Here's how three of the best handle colour.
π¨ Material Design 3 (Google)
Material Design 3 introduced dynamic colour β generating an entire palette from a single source colour using the HCT (Hue, Chroma, Tone) colour space. The system produces 5 key colour roles (Primary, Secondary, Tertiary, Error, Neutral) each with 13 tonal steps.
- Uses the HCT colour space for perceptual uniformity
- Generates 5 tonal palettes from 1 source colour
- Defines 29 semantic roles (primary, on-primary, primary-container, etc.)
- Light/dark schemes are pre-calculated from the tonal palettes
- Tokens prefixed with
md-sys-color-
π Apple Human Interface Guidelines
Apple's HIG takes a different approach β fewer tokens, more opinionated. The system provides a set of system colours (blue, green, indigo, orange, pink, purple, red, teal, yellow) that automatically adapt to light/dark mode, accessibility settings, and platform (iOS, macOS, watchOS).
- System colours shift hue/saturation between modes, not just lightness
- Provides semantic colours:
label,secondaryLabel,systemBackground - Vibrancy effects modify colour based on background content
- Increased Contrast accessibility mode has separate colour values
- Platform-specific: iOS and macOS use different default values for the same token
π GitHub Primer
GitHub Primer is one of the most thoroughly documented design system palettes. It uses a functional naming convention that separates colour into foreground (fg), background (bg), and border categories.
- Naming:
color-fg-default,color-bg-subtle,color-border-muted - Supports 8 themes: light, dark, dark dimmed, dark high contrast, plus colour blindness variants
- Tokens stored in JSON, transformed via Style Dictionary
- Public Figma library mirrors every token
- Full contrast matrix published in documentation
The lesson from all three: semantic naming beats colour naming. Every production system separates "what a colour is" from "what it means." The specifics (naming conventions, colour spaces, number of tiers) vary, but the architecture pattern is universal. Study these systems, then adapt the pattern to your own scale and needs.
13. Common Mistakes
Building a design system palette is a process with many subtle pitfalls. These are the mistakes we see most often.
β Using raw hex values in components
Every #3B82F6 hardcoded in a component is a theming bug waiting to happen. When the brand colour changes β and it will β you'll be grep-replacing hex values across thousands of files. Always reference tokens: var(--color-primary) instead of #3B82F6.
β Skipping the semantic layer
Jumping from primitives (--blue-500) directly to components (button { background: var(--blue-500) }) means dark mode requires overriding every component individually. The semantic layer is what makes theming a single-file change.
β Not testing contrast at every step
Your beautiful tonal scale is useless if text on the 100-step background doesn't meet WCAG AA contrast with the 700-step text colour. Test every foreground-background combination you plan to use. Hue's contrast checker makes this fast.
β Building dark mode as an afterthought
Designing your palette for light mode first and then trying to "make it work" in dark mode always produces worse results. Define both modes simultaneously β every semantic token should have a light and dark value from the start.
β Too many hue families
More colours doesn't mean more flexibility. It means more cognitive load, more inconsistency, and a diluted brand identity. Most products need 2-3 brand hues + 4 functional hues + 1 neutral. That's 7-8 hue families total, which is plenty for even complex enterprise applications.
β Not documenting token purpose
Naming a token --text-secondary is good. Adding a description β "Use for descriptions, subtitles, and supporting text that doesn't need full emphasis" β is what prevents misuse. Without documentation, two developers will interpret "secondary" differently.
14. Frequently Asked Questions
What is the difference between primitive, semantic, and component color tokens?
Primitive tokens are raw colour values (blue-500: #3B82F6). Semantic tokens map primitives to intent (color-primary: blue-500). Component tokens bind semantic tokens to specific UI elements (button-bg: color-primary). This three-tier system lets you change a brand colour in one place and have it cascade through every component automatically. Learn more about structuring tokens in our palette generator guide.
How many colors should a design system palette have?
Most production design systems use 6-10 hue families (primary, secondary, neutral, success, warning, error, info, plus 2-3 accent or brand colours), each with 10-13 tonal steps from lightest to darkest. This gives you 60-130 primitive tokens total, which cover virtually every UI scenario including light and dark modes.
How do I create color tokens for dark mode?
Define semantic tokens that swap underlying primitive values per theme. For example, --color-surface might reference --gray-100 in light mode and --gray-900 in dark mode. The component code never changes β only the token mapping does. Use CSS custom properties scoped to a data-theme attribute or prefers-color-scheme media query. Preview your dark mode tokens with Hue's dark mode previewer.
Should I use HSL or hex values for my design tokens?
HSL is better for building tonal scales because you can adjust lightness systematically while keeping hue and saturation consistent. However, store tokens as hex or RGB in your final output for maximum compatibility. Modern CSS colour spaces like OKLCH offer even better perceptual uniformity for generating palettes β Hue's palette generator supports both.
How do I sync colors between Figma and code?
Use Figma's built-in Variables feature to define colour tokens, then export them via the Figma REST API or plugins like Tokens Studio. Transform the exported JSON into CSS custom properties, Tailwind theme config, or platform-specific formats using Style Dictionary. This creates a single source of truth that keeps design and code in sync.
What are the most common mistakes when building a design system color palette?
The top mistakes are: using raw hex values instead of tokens (making theming impossible), skipping semantic tokens and jumping straight from primitives to components, not testing contrast ratios at every tonal step, building light mode first and retrofitting dark mode, choosing too many hue families that dilute brand identity, and not documenting when and why to use each token.