View
Design Systems22 min readJun 2024

Building a Scalable Color System for Modern Digital Products

A color system isn't a palette — it's a set of rules for when and why each color appears. Foundations, surfaces, semantics, and how to build an 11-stop scale from a single hue.

ColorDesign TokensAccessibilityFoundations
01/Foundations

Foundation colors

Foundation colors define the atmosphere of the entire product. These are the most frequently used colors in the interface — they form the visual base layer for every component.

#FFFFFF

Primary Background

The main canvas. Base surface for all page-level content.

#F5F5F5

Secondary Background

Separates sections and creates visual grouping within layouts.

#EBEBEB

Tertiary Background

Third depth level — nested panels, sub-regions, inset areas.

+ shadow

Elevated Surface

Floating panels, command menus, popovers — visually raised above main layout.

rgba(0,0,0,.5)

Overlay Background

Behind modals and drawers. Reduces underlying interface, preserving context.

02/Surfaces

Surface system

Surface colors define how components feel physically layered inside the interface. Each component type has a surface that matches its perceived depth.

SurfaceRoleUsageKey property
CardContent containerTiles, list items, dashboard blocksRestrained contrast
ModalInterruption layerConfirmations, forms, dialogsStrong emphasis
DropdownInteraction menuSelect menus, command palettesSubtle + responsive
TooltipContextual hintIcon labels, field helpersMax readability
HoverInteractivity signalList items, rows, nav linksSubtle brightness shift
ActivePersistent selectionSelected rows, nav current, tabsPersistent, distinct

Depth rule

Each layer should be visually distinguishable from the one beneath — but only just enough. Over-contrasting surfaces creates noise; under-contrasting makes the layout feel flat. Aim for perceivable, not dramatic.

03/Typography

Typography colors

Typography color creates information hierarchy. A strong type system lets users instantly distinguish primary content from secondary or supporting information — without reading a word.

Ag

Primary

Headings, critical info, CTAs. Maximum visual weight.

Ag

Secondary

Body content, descriptions, explanations.

Ag

Muted

Timestamps, metadata, captions, helper text.

Ag

Disabled

Inactive UI. Must look off — never invisible.

Ag

Accent

Links, interactive labels, highlighted values.

Common mistake

Reducing disabled text opacity too aggressively. Disabled states must appear inactive while remaining accessible — don't make users wonder if the element exists.

04/Borders

Borders & dividers

Borders do structural work, not decorative work. Modern UI systems use them far more subtly than older systems — only where separation genuinely aids comprehension.

Subtle

Cards, grouped sections, tables, navigation. Barely-there structure.

Default

Component boundaries. Clearly visible — defines containment.

Strong

Active states, selected components. Use sparingly — commands attention fast.

Focus

Keyboard navigation ring. Highly visible, distinct from hover states.

05/Brand & Accent

Brand & accent system

Accent colors define product personality. They're the colors users associate with your brand — appearing on primary CTAs, active navigation, and interactive focus states.

Primary Accent

CTAs, active nav, focused elements. Core interaction color.

Secondary Accent

Supporting emphasis. Works alongside primary without competing.

Accent Hover

Darkened state on pointer entry — confirms interactivity.

Accent Active

Pressed or selected. Darkest variant — communicates commitment.

Restraint is the point

Accent colors lose meaning when overused. If everything is accented, nothing is. Reserve primary accent for the most important interactive moments — let neutral surfaces carry the rest.

06/Semantic States

Semantic states

Semantic colors carry universal meaning across the interface. They communicate system feedback — success, caution, failure, information — independently of brand color.

Success

Completed actions, confirmed states, passed validation. Always positive.

Warning

Non-blocking issues, cautionary notices. Something to watch — not act on immediately.

Error

Failures, invalid inputs, destructive actions. Demands immediate attention.

Info

Neutral notices, contextual tips, non-critical system messages.

Never color-only

Always pair semantic color with an icon, label, or text. For users with color-vision deficiency, red and green are often indistinguishable without a secondary visual signal.

07/Principles

Putting it together

A color system isn't a palette — it's a set of rules for when and why each color appears. The goal is a product where color communicates function so clearly that users never have to wonder.

01

Name by function, not value

Tokens like color-text-secondary age well. Names like gray-500 don't — they describe what the color is, not what it does.

02

Design dark mode first

Dark mode surfaces token-structure problems quickly. If your system maps cleanly to dark, it's probably built correctly. Light mode rarely breaks what dark mode wouldn't reveal first.

03

Accessibility is a constraint

WCAG contrast ratios — 4.5:1 for text, 3:1 for UI elements — should be baked into tokens at definition time. Not audited at the end of a sprint.

08/Picking Your Primary

How to select your primary color

Your primary is the most consequential decision in the system — every hover, CTA, and focus ring stems from it. Here's the framework for choosing it well.

Step 1 — Choose a hue family

Red0°–20° · Urgency, passion, power. Conflicts with error semantics — use with extreme care.
Orange20°–50° · Energy, creativity, warmth. Bold personality. Borders warning territory.
Green100°–150° · Growth, nature, money, health. Borders success semantics — keep saturation distinct.
Teal170°–195° · Modern, calm, approachable. Strong SaaS and fintech presence. Very scalable.
Blue200°–240° · Trust, reliability, clarity. The most-used primary in digital products. Scales beautifully.
Purple260°–300° · Creativity, premium, sophistication. Differentiating in SaaS — fewer competitors use it.

Hue carries brand meaning before a word is read

Blue signals trust (fintech, SaaS); green maps to growth and money; purple reads premium; teal is calm and modern. Red and orange feel urgent but clash with error/warning semantics. The hue is a brand statement on its own.

Step 2 — Test against four criteria before committing

01

Contrast on white and dark backgrounds

Your primary must reach at least 4.5:1 contrast against white for text use, and work equally on dark surfaces. Colors in the 40–60% lightness range tend to fail one or both. Vivid mid-tone colors often look great but fail text contrast — test before falling in love with a color.

02

Legibility at small scale

Primary colors appear on 14px labels, 2px focus rings, and 8px active nav dots. A color that looks confident at 200px can become ambiguous at 12px. Always render the color at its smallest intended use. Cool hues generally hold up better at small sizes than warm hues.

03

Semantic neutrality

Avoid hues already claimed by semantic states. Red, orange, and yellow carry universal error/warning meaning. Green often signals success. Using them as your primary creates an unresolvable cognitive conflict. Blue, teal, and purple have no strong semantic pre-assignment.

04

Full-scale scalability (11 stops)

Your primary must generate a complete tonal scale — very light tints through very dark shades — without losing recognizable identity. Low-saturation or unusual hue angles often collapse into gray at the light end or muddy brown at the dark end. Test the extremes (stop 50 and 950) before committing.

Step 3 — Validate contrast ratios for every use context

The primary appears as a fill behind white text, as text on white, and as text on its own tint — each with different contrast requirements. All must pass before you commit.

7.2:1White on primary
CTA button labelAAA Pass
5.9:1Primary text on white
Link text on pageAA Pass
4.6:1Primary on tint bg
Label on active rowAA Pass

The ratio targets

WCAG AA: 4.5:1 for normal text, 3:1 for large text (18px+ / 14px+ bold) and UI boundaries. AAA: 7:1. Target AA everywhere, AAA on critical elements.

Step 4 — Define the colors that live around the primary

No primary lives alone — it needs hover/active variants, complementary accents, and a neutral palette to carry the interface. Six proven harmony strategies:

Tints & shades (same hue)

Lighter tints for backgrounds and hover fills; darker shades for active and pressed states. All derived from the same hue — zero dissonance. The most practical pairing strategy for any product UI. Always build these first.

Analogous (±30° on wheel)

Adjacent hues feel cohesive and calm. A blue primary might pair with cyan and indigo. Great for secondary accents that support without competing — multi-section dashboards where each section needs a distinct but harmonious color.

Triadic (120° apart)

Three hues equally spaced on the wheel. Creates vibrant, high-energy contrast — ideal for data visualization where categories need clearly distinct colors. Use primary at full weight, the other two as supporting accents.

Complementary (180° opposite)

Maximum contrast — the hue directly across the wheel. Use the complement very sparingly: a highlight color, a sale badge, a special alert. Never as an equal-weight second primary. One dominant, one accent-only.

Split complementary

Primary + two hues flanking the complement. Lower tension than full complementary, more variety than analogous. Excellent for extended palettes — fintech apps with multiple product lines, analytics with 3+ data categories.

Primary + neutral (most common)

One strong accent color and a complete neutral gray family. The neutral carries 90% of the interface; the primary punctuates the 10% that matters most. Simple, robust, and the right choice for most product UIs.

The complete set of colors derived from one primary

Primary (500) — the base interactive color

Used on: primary CTA fills, active navigation indicators, selected checkbox/radio fills, focus rings, active tab underlines, highlighted metric values. This is the color users associate with “action” in your product.

#0066CC · HSL(210, 100%, 40%)

Hover state (400) — one step lighter

The primary lightens slightly on hover — a subtle shift that signals interactivity without a dramatic change. Never use a completely different hue for hover; always derive it from the same hue, one stop lighter.

#3380FF · HSL(210, 100%, 57%) · hover of primary CTA

Active / pressed state (600) — one step darker

When a button is actively clicked or a state is “on”, the color darkens one stop. This communicates commitment — something is selected and persisting. Pressed button fills, active toggle backgrounds, current nav item fills.

#0052A3 · HSL(210, 100%, 32%) · active press, selected

Tint backgrounds (50–200) — the ambient presence

Very light tints used for: active row fills, selected card backgrounds, info banner fills, highlighted search results, chip/badge fills for filters. They keep the primary present in the layout without the full weight of a button fill.

#EBF3FF (50) · #CCE0FF (100) · #99C0FF (200)

Dark shades (700–900) — text and high-contrast use

Used when the primary hue appears as text on a light background, or as a border on an accent-tinted surface. Stop 700 works as accent link color on white. 800 for text inside info banners. 900 for maximum contrast.

#003D7A (700) → link text · #002952 (800) → text on tint bg

Neutral gray family — the interface carrier

The workhorse. Slightly warm or cool the neutral to harmonize with your primary hue. A blue primary pairs with a cool gray; a warm orange primary with a warm gray. Use 5–10 stops, same naming convention as the primary scale.

gray-50 → gray-950 · HSL(210, 6%, varies) for a blue system

One primary is usually enough

Most interfaces need just one primary, one neutral scale, and semantic colors. Add a secondary accent only for a clear functional need — never to make the palette “more interesting.” Restraint is a marker of maturity.

09/Swatch Scale

Building the complete color swatch

A complete swatch is a tonal scale of 11 stops — from near-white to near-black — all derived from a single hue. It is the raw-material layer of the system. Every semantic token eventually maps back to a stop in one of these scales.

11

Why 11 stops (50 through 950)?

Enough resolution for every context — pale backgrounds, mid-weight fills, dark text — from one hue. Fewer leaves gaps; more creates ambiguity.

500

Why 500 is the anchor, not 0

The primary sits mid-scale, leaving five lighter tints above and five darker shades below — symmetrical room for hover and active variants in both directions.

Complete scale — Blue · base #0066CC · HSL(210°)★ = base stop
50#EBF3FF
100#CCE0FF
200#99C0FF
300#66A0FF
400#3380FF
500#0066CC
600#0052A3
700#003D7A
800#002952
900#001529
950#000A14

All 11 stops share hue angle 210° — only lightness and saturation change across the scale

Try it — one primary drives every tool below

Pick a primary once — it flows through every tool below. Toggle contrast badges for per-stop WCAG ratings, turn on Full system to derive the neutral and semantic scales, then export to CSS, Tailwind, SCSS, JSON, or OKLCH.

Interactive · drives every tool below
Your primary — drives every tool below
Primary color
Scale name
Base HSL221° 83% 53%
Feel
Presets
brand

Click any stop to copy its hex · ★ marks the stop nearest your base color

Use this scale
Ratio8.02:1
AA · normal AAA · normal AA · large

The quick brown fox 14px

The quick brown fox 16px

The quick brown fox 24px

WCAG AA needs 4.5:1 for normal text, 3:1 for large (≥18px or ≥14px bold). AAA needs 7:1. Test the exact pairs before committing them to tokens — a pair that fails here will fail in production.

Copy as

50 · 100 · 200 — Tint zone

Ambient backgrounds

Active row fills, hover surfaces, info banners, selected card fills, chip backgrounds. High lightness (92–97%), reduced saturation. Must pass 4.5:1 with your darkest text on top.

300 · 400 · 500 — Action zone

Interactive fills

500 = primary CTA fill, active nav, focus ring. 400 = hover state. 300 = secondary buttons, less-critical interactive elements. All must pass 4.5:1 with white text on top.

600 · 700 · 800 — Depth zone

Active states and text

600 = active/pressed CTA. 700 = accent link text on white. 800 = text on tinted backgrounds (50–100 fills). These shades give the primary hue presence without the full-weight button fill.

Step-by-step — how to generate the scale from scratch

01

Lock your base color as stop 500 — express it in HSL

Start with your chosen primary. This is stop 500. Convert to HSL immediately — it gives you direct control over hue, saturation, and lightness separately. The hue angle (H) stays constant across all 11 stops; only L and optionally S change. Starting in hex or RGB means guessing at relationships between stops.

#0066CC → HSL(210, 100%, 40%) → Stop 500
02

Set the lightness anchors at both extremes

Before filling the middle, define your two poles. Stop 50 ≈ L 95–97% (almost white, still perceptibly tinted). Stop 950 ≈ L 4–7% (almost black, still carrying the hue). These anchors define the full range. Without them, the middle stops will drift.

Stop 50: HSL(210, 70%, 96%) — Stop 950: HSL(210, 80%, 5%)
03

Distribute the 9 intermediate stops on a perceptual curve

A linear lightness distribution doesn't look linear to human eyes — the mid-range appears compressed and the extremes too spread. Use an eased curve: smaller gaps between 300–600, larger gaps at the extremes. Adjust after visually checking the rendered scale.

L values: 96 · 92 · 84 · 72 · 57 · 40(★) · 32 · 24 · 16 · 10 · 5
04

Tune saturation at the extremes to avoid muddiness

At very high lightness (50–200), full saturation looks washed out or aggressively vivid — pull it to 60–80%. At very low lightness (800–950), full saturation looks artificial — reduce to 70–85%. The middle (300–700) stays near full saturation to keep identity. Render and adjust by eye.

50–200: S ≈ 60–80% · 300–700: S ≈ 90–100% · 800–950: S ≈ 70–85%
05

Verify contrast at each stop — in both light and dark mode

Every stop needs to work in context. Run each through a contrast checker against white, your dark background, and your primary text color. Targets: 50–200 ≥ 4.5:1 with darkest text; 400–600 ≥ 4.5:1 with white; 700–800 ≥ 4.5:1 with white for text. Adjust failing stops by shifting L a few points.

Check each stop against: white · #111 (dark text) · your ink bg
06

Name with a prefix and number — never the visual value

Name with a consistent prefix and the stop number: blue-50, blue-100 … blue-950. Never name by visual description (light-blue, sky, navy) — those become wrong the moment you adjust the shade. The number is semantic-neutral and survives any future palette revision.

blue-50 → blue-950 · NOT: sky, powder, cobalt, midnight
07

Map scale stops to semantic tokens — never reference raw stops in components

Once the scale exists, create a semantic token layer on top of it. Components should only ever consume semantic tokens, not raw scale stops. This lets you swap the entire scale (or individual stops) later without touching any component. Semantic tokens describe function, not value.

blue-500 → --accent-primary · blue-400 → --accent-hover · blue-100 → --accent-surface

The hue-shift trick

A perfectly constant hue makes light tints look cold and dark shades muddy. Shift 5–10° warmer in the light stops and ~5° cooler in the dark ones — mimicking light and shadow. Tailwind does this across its whole palette.

A complete system has multiple scales — here are the essential ones

Primary scale (required)

Your brand hue, 11 stops. The one described above. Every interactive state, accent fill, and focus ring derives from this scale.

Neutral / gray scale (required)

11 stops from near-white to near-black. Slightly warm or cool to harmonize with your primary. Used for everything that isn't accented: backgrounds, text, borders, dividers, subtle surfaces. Design it with as much care as the primary — it's used far more.

gray-50 (#FAFAFA) · gray-500 (#6E6E6E) · gray-950 (#0A0A0A)

Error / danger scale (required)

A red hue, 11 stops. Used exclusively for error states, destructive action fills, validation failures. Because red is semantically loaded, this scale should never bleed into decorative use. Keep it disciplined.

Success scale (required)

A green hue, 11 stops. Confirmation states, completion indicators, positive metric highlights. If green is your primary, shift success to a teal or emerald hue to maintain semantic separation.

Warning scale (required)

An amber/yellow hue, 11 stops. Non-blocking cautions, rate-limit notices, “review before continuing” states. Yellow is notoriously hard to get contrast right at mid-stops — test stop 500 especially carefully against white.

Secondary accent scale (optional)

Only build this if your product has a clear second interaction color — a secondary CTA, a separate product line, or data viz needing a second category. Use an analogous or split-complementary hue. Don't add it just to avoid looking plain.

One hue, not one color

The common mistake is defining a primary as one hex and stopping. Your primary is a full 11-stop family — the hex is just the anchor; the scale is what makes it work across every component, state, and mode.

10/Dark Mode

Dark mode is a remap, not an invert

The mistake is inverting colors. The fix is re-mapping role tokens to different scale stops: your darkest gray becomes the background, your lightest becomes the text. Same tokens, different resolution per mode.

Interactive
Mode

Account settings

The same token names drive both modes — only the scale stop they resolve to changes.

Cancel

Token remap

Page backgroundneutral-100neutral-950
Raised surfacewhiteneutral-900
Text — primaryneutral-900neutral-50
Text — secondaryneutral-500neutral-400
Borderneutral-200neutral-800
Accent fillbrand-600brand-500
Accent text / linkbrand-600brand-400

Lighten accents in the dark

A mid accent that passes contrast on white often fails on a dark surface. In dark mode, accents usually step one or two stops lighter (blue-600 → blue-400) so they keep their 4.5:1 against the new background.

11/Motion

Color in motion

Color isn't static — it changes on hover, focus, and state. Those transitions need duration and easing, or the interface feels either jarring or sluggish.

Interactive
Duration
Easing
List row (hover fill)
Inline link →
Focus ring
transition: 150ms cubic-bezier(0.4, 0, 0.2, 1)

Hover the elements. Color transitions read best at 75–150ms for hover and focus — fast enough to feel instant, slow enough to register — with an ease-out curve. Past ~250ms feels laggy on interactive states, and everything honours prefers-reduced-motion.

12/Data Visualization

Color on data visualization

Charts play by different rules. Sequential, diverging, and categorical data each need a different palette structure — and your brand primary usually can't just be reused as a data color.

Interactive
Type
hue 221° · from your primary

One hue, light → dark. For ordered magnitude — heatmaps, density, low-to-high. The eye reads darkness as “more.”

13/Perceptual Uniformity

Why HSL lies — and OKLCH doesn't

Two HSL colors at the same lightness can look dramatically different in brightness. HSL lightness is a math construct; perceptual spaces like OKLCH and LCH model human vision, which is why they produce better-looking, more even scales.

Interactive
HSL — same L, six huesuneven · luminance spread 54%
19%
64%
56%
40%
10%
22%
OKLCH — same L, six hueseven · luminance spread 4%
15%
16%
18%
19%
17%
15%

Every swatch in a row claims the same lightness. In HSL the measured luminance lurches — yellow blinds, blue sinks — because HSL lightness is a math artefact, not a perceptual one. OKLCH is built on human vision, so the row reads evenly. That is why OKLCH/LCH scales look smoother and why a constant-lightness palette only behaves in a perceptual space.

14/Color Vision

Color-blindness simulation

“Use icons too” is the start, not the answer. The deeper question is which hue combinations stay distinct under deuteranopia, protanopia, and tritanopia — and which collapse into the same color.

Interactive
Simulate

Categorical palette

Success vs ErrorDISTINCT
Info vs WarningDISTINCT

Red/green is the classic trap — under deuteranopia and protanopia success and error converge, which is why color alone can never carry meaning. Blue/orange survives every deficiency: it differs in lightness and warmth, not just hue. Pick semantic pairs that stay apart on more than one axis.

15/Temperature & Mood

Color temperature & mood

Warm vs. cool neutrals change the entire feeling of an interface. A 5–10° hue shift in the gray family is invisible up close and unmistakable across a full screen.

Interactive

Interface on a cool neutral

Mood: calm, technical, trustworthy. The accent and content never changed — only the gray family did.

A 5–10° hue shift and a few percent of saturation in the neutrals is invisible stop-by-stop but unmistakable across a full screen. Cool grays feel engineered and calm; warm grays feel editorial and inviting. Pick the temperature to match the product's voice — and keep it consistent.

16/Token Architecture

Token naming: global → alias → component

A full taxonomy has three tiers. Global tokens are the raw scale; alias tokens assign a semantic role; component tokens override for one component. Most teams stop at alias and never explain when the third tier is warranted.

Interactive

Component tokens

Resolution chain

03

Component token

--button-primary-bg

↓ references
02

Alias token (semantic role)

--color-action

↓ references
01

Global token (raw scale)

brand-600

↓ resolves to

Value

#0e3a95

Most components should consume alias tokens directly. A component token (tier 3) is only warranted when a component must deliberately diverge — e.g. a brand button that stays one color across every theme. Add them sparingly; each one is a place the system can drift.

17/Exceptions

When to break the system

Marketing pages, empty states, onboarding, and loading screens legitimately need colors outside the system. Having an explicit policy for these is what prevents every screen from becoming an exception.

Interactive
Context
Marketing

The policy

Expressive gradients and off-palette hues are encouraged — but body copy, CTAs, and form fields still use system tokens, and text must pass contrast.

“Break the system” isn't a free pass — it's a documented exception with a boundary. Naming where off-system color is allowed is what stops every screen from becoming an exception.

18/Migration

Color versioning

How do you move the primary from blue to indigo without breaking 200 components? You don't touch the components at all — you re-point one alias token, and the change propagates everywhere.

Interactive
Brand version

Components (unchanged)

Inline link →Active nav item

The only edit

/* tokens.css */
--color-action: brand-600;

Every component references the --color-action alias, never the raw stop. Re-pointing that one line migrates the entire product — 200 components move without a single component edit. That indirection is the whole point of the alias tier.

19/QA

Auditing color usage

Extract every hex value a product actually ships and map it back to tokens. The gap between what the system says and what the code does is where drift lives — paste real values below to see it.

Interactive

Paste values from code / Figma

exact 4drift 1off 2

Mapped to nearest token

#0e3a95brand-600exact
#0f3d9cbrand-600drift → snap to token
#410e95brand-600off-system
#f4f4f5neutral-50exact
#18191bneutral-900exact
#ff0000error-600off-system
#1146bbbrand-500exact

The audit is the gap between what the system says and what shipped code actually uses. “Drift” values are near-duplicates that should snap to a token; “off-system” values need a decision — adopt, replace, or document as an exception.

20/Tooling

Figma variable setup

How collections, modes, and scoping map to the token architecture: one collection for the raw scale, a separate collection for semantic tokens with light/dark modes, and scoping so each variable can only be used where it belongs.

Interactive
Mode

Primitives

1 collection · no modes
brand/600#0e3a95
gray/50#f4f4f5
gray/900#18191b
gray/200#d3d5d9

Semantic

1 collection · Light / Dark modes
actionbrand/600Fill, Stroke
surfacegray/50Fill
textgray/900Text
bordergray/200Stroke

Primitives are the raw scale in one mode-less collection. Semantic tokens live in a separate collection with Light/Dark modes and alias the primitives — flip the mode and every alias re-points. Scoping restricts where each variable can be applied (a text token can't be picked as a fill), which is how the structure survives contact with a real file.

22/The Human Side

Getting buy-in

Color decisions get overridden by taste (“can we make it more vibrant?”). The way through is to frame every decision as a user outcome, not a preference — tap each objection to see the reframe.

Interactive

Color decisions get overridden when they're framed as taste — because taste is arguable. Reframe every one as a user outcome (contrast, attention, recognition, accessibility) and the conversation moves from “I prefer” to “users will.”

23/The Human Side

The “one-off” problem

Designers keep adding custom colors per component — “just this once.” A system with enough range removes the temptation. Watch ad-hoc colors fragment into near-duplicates while one well-ranged scale covers it all.

Interactive

Ad-hoc “just this once”

Click “add a one-off” a few times…

One well-ranged scale

11 stops already span every tint and shade anyone reaches for. There's no gap to “fill” with a custom value.

Designers add one-offs because the system feels like it's missing a step — so they eyeball “close enough,” and the palette quietly fragments into dozens of near-duplicates nobody can maintain. The fix isn't discipline alone; it's a scale with enough range that the temptation never arises.