Blog

How to Build a Color System for UI Design

A step-by-step guide to creating a professional UI color system: from brand colors to accessible shade scales, semantic tokens, and Figma sync.

UI Color palette

Ondrej PesickaAugust 16, 2026

Most designers start a project the same way: pick a brand color, spin up a Figma file, and start generating grays and accent colors by feel. Tweak until it looks right. Check contrast when it comes up.

It works, until it doesn't.

Three months later, you've got 40 color styles in Figma with names like blue-dark-2, gray-new, and success-green-FINAL. Your developer is asking which green to use for success states. Your dark mode is held together with overrides. And every accessibility audit flags a handful of text-on-background combinations that barely miss the contrast threshold.

A UI color system prevents all of this. Not by adding complexity, but by giving you a predictable structure that scales with your product.

This guide walks you through building one from scratch: from a single brand color to a full, accessible, Figma-ready palette.

What a UI color system actually is

A brand color palette and a UI color system are not the same thing.

A brand palette answers: what are our colors?

A UI color system answers: exactly which shade of which color goes where, and why?

A proper UI color system has three layers:

  1. Primitive colors: raw shade scales, numbered 50-950 or 100-900, for every color in your palette. These are your ingredients. They don't carry meaning on their own.
  2. Semantic colors: named by purpose, not appearance. color.text.primary, color.background.success, color.border.danger. These reference primitives but stay stable even if you swap out the underlying shade.
  3. Component tokens (optional, for mature systems): component-specific references like button.background.default that point to semantic colors.

Most teams building a design system need layers one and two. Layer three becomes important when your component library grows large enough that semantic colors alone don't give you enough control.

For the rest of this guide, we'll focus on building layers one and two.

Step 1: Define your color categories

Before touching any color tools, decide which color categories your system needs. A typical UI color system includes:

Brand colors: Your primary and secondary brand colors. Usually 1-3 colors with full shade scales.

Neutrals / Grays: The workhorse of any UI. Used for text, backgrounds, borders, dividers, and surfaces. You'll use more neutral shades than anything else.

Semantic colors:

Extended palette (if needed): Additional hues for data visualization, categories, or feature differentiation.

For a typical product, you're looking at 4-6 color families, each with 9-11 shades. That's roughly 40-60 primitive color values, plus your semantic layer on top.

Step 2: Build your shade scales

This is where most color systems break down.

The intuitive approach is to take your brand color, lighten it toward white for lighter shades, darken toward black for darker ones, and call it done. The problem: human vision isn't linear. A mathematically even progression from light to dark will look uneven to the eye. Shades that should look distinct will blend together, and jumps between others will look too harsh.

The solution is perceptual uniformity: shades that look evenly spaced because they're built in a color space that models human vision, not just raw RGB math.

Why OKLCH changes everything

Most designers work in HSL, because it's what color pickers expose. But HSL has a fundamental flaw: the "L" (lightness) doesn't match perceived brightness across hues. A yellow at HSL 50% lightness looks far brighter than a blue at the same value.

OKLCH (and its predecessor LCH) fixes this. In OKLCH, adjusting lightness actually changes how bright the color appears, consistently across every hue. That means shades generated in OKLCH look balanced to the eye, not just on paper.

Practically, this means:

Generating your scales

For each color family, you need a scale of roughly 9-11 shades. A common convention:

50   Near white, subtle background tints
100  Light backgrounds, hover states
200  Borders, dividers
300  Disabled states
400  Placeholder text
500  Main brand color (usually your starting point)
600  Pressed states, hover on primary buttons
700  Strong emphasis
800  Dark backgrounds
900  Near-black, high-contrast text
950  Darkest, often used for dark mode surfaces

Your 500 (or sometimes 400 or 600, depending on the hue) should be your starting brand color. Generate the rest by adjusting lightness and chroma in OKLCH: keep chroma roughly consistent across the scale, and adjust lightness in equal perceptual steps.

Easing curves

Equal perceptual steps don't have to mean completely linear progressions. Many designers apply subtle easing to their shade curves, stepping more slowly through the midtones where shades are used most, and faster through the extremes. This gives you more usable shades in the range where they matter.

Step 3: Build your neutral scale

Your neutral scale deserves special attention. It's the most-used color family in any UI. Text, backgrounds, borders, and surfaces are almost entirely neutral.

Two approaches:

True neutral gray: Built from black and white in OKLCH, with zero chroma. Completely colorless. Works with any brand palette.

Tinted neutral: A very low-chroma version of your brand hue. The chroma is subtle (often imperceptible), but it creates warmth or coolness that ties the neutrals to the brand color. Slightly warmer or cooler grays feel more intentional than pure neutral.

Either works. Tinted neutrals are harder to get right but look more polished. If you go tinted, keep chroma values low (under 5 in OKLCH) to avoid colors that look "off" in backgrounds.

Step 4: Define semantic colors

Primitive scales are ingredients. Semantic colors are your recipe.

The goal: name colors by purpose so that when you change a shade or swap a hue, your components update in one place.

The semantic color structure

Text
  text.primary          → neutral-900
  text.secondary        → neutral-600
  text.disabled         → neutral-400
  text.inverse          → neutral-50
  text.success          → success-700
  text.danger           → danger-700

Background
  bg.default            → neutral-50
  bg.subtle             → neutral-100
  bg.surface            → white
  bg.success            → success-50
  bg.danger             → danger-50

Border
  border.default        → neutral-200
  border.focus          → primary-500
  border.danger         → danger-400

Interactive
  interactive.primary           → primary-500
  interactive.primary.hover     → primary-600
  interactive.primary.pressed   → primary-700

Build this in a spreadsheet or design token file first, then implement it in Figma variables or your CSS custom properties. The naming convention matters more than the exact structure. Choose one and stay consistent.

Important: semantic color names should never include the color name. bg.success is good. bg.green is not, because when you swap success to teal, every token reference is now wrong.

Step 5: Check accessibility

This step is where many color systems fail, not because designers don't care about accessibility, but because checking it is tedious and often left to the end.

Accessibility needs to be built into the palette, not checked after.

WCAG 2.1 requirements

WCAG 2.1 uses a contrast ratio formula. Key thresholds:

Your semantic text tokens need to meet these thresholds against their expected backgrounds.

The most common check: text.primary on bg.default. Then text.secondary on bg.subtle. Then interactive states: button text on button background.

WCAG 3.0 / APCA

WCAG 3.0 introduces APCA (Advanced Perceptual Contrast Algorithm), which is a significant improvement over WCAG 2.1's contrast formula. WCAG 2.1's algorithm has a known flaw: it treats all hues equally when human vision doesn't. APCA accounts for polarity (light text on dark vs dark text on light), font size, and weight.

Why does this matter in practice? Some combinations that pass WCAG 2.1 feel hard to read. Some that fail WCAG 2.1 are actually fine. APCA gives you a more accurate picture.

WCAG 3.0 is not yet the legal standard in most jurisdictions, but forward-thinking teams are building to both standards now. If your product is used in any regulated industry, or if accessibility matters to your users, checking APCA alongside WCAG 2.1 is worth doing.

Check every text token against its expected background, and every interactive element (button, link, icon) against its background surface.

Step 6: Handle dark mode

Dark mode isn't "invert the palette." It's a second theme built on the same primitive scales.

In dark mode, you flip the relationship between shades. Your bg.default goes from neutral-50 to neutral-950, and your text.primary goes from neutral-900 to neutral-50, but the semantic token names stay the same. Components reference text.primary and get the right value for whichever theme is active.

This is exactly why the semantic layer matters. Without it, dark mode requires touching every component. With it, you update a token file.

A few dark mode-specific considerations:

Backgrounds need more layering. Dark mode UIs often use more distinct surface colors to create depth. Where light mode gets away with white + subtle gray, dark mode needs 950, 900, 850 (or wherever your neutral scale lands).

Chroma often needs to be reduced. Bright, saturated colors on dark backgrounds can feel harsh or bleed visually. Consider slightly desaturating your interactive and brand colors in dark mode.

Shadows don't work the same. Light mode depth = drop shadows. Dark mode depth = lighter inner surfaces. Plan your elevation model accordingly.

Step 7: Export to Figma and code

A color system that lives only in a spreadsheet isn't a color system. It's a document.

Two exports you need:

Figma: Set up color styles or (preferably) Figma variables. Variables support modes, so you can implement light and dark mode as variable collections and switch between them per frame or per component. Name your variables to match your semantic token structure.

Code: Export as CSS custom properties, a JSON token file, or Tailwind config, whatever your tech stack uses. The key is that the naming and structure match between Figma and code, so designers and developers are referencing the same values.

If you're using a design tokens standard (like W3C Design Tokens format), you can use tools to transform one token file into CSS variables, Tailwind config, Swift, Kotlin, and more from a single source.

Common color system mistakes

Naming by appearance, not purpose. blue-500 as a variable name means nothing when requirements change. Name by role: interactive.primary.

Skipping the semantic layer. Mapping components directly to primitive values creates a maintenance nightmare. One change means hunting through every component.

Not checking accessibility during construction. Accessibility failures found in QA are expensive. Build contrast checks into the palette creation process.

Too many custom values. Every time a designer adds a custom one-off color, the system grows harder to maintain. Define what "outside the system" means and when it's acceptable.

Inconsistent shade naming conventions. Pick a scale (100-900, or 50-950, or 1-10) and stick to it. Mixing conventions, with some colors on 9 shades and some on 11, creates confusion.

No version history. Color systems evolve. Without version history, you can't safely experiment or roll back decisions that turn out to be wrong.

The process in summary

  1. Define your color categories: brand, neutrals, semantic (success/warning/danger/info), and extended if needed.
  2. Generate shade scales: use OKLCH for perceptual uniformity; aim for 9-11 shades per color family.
  3. Build your neutral scale: true neutral or subtly tinted; this is your most-used color family.
  4. Define semantic tokens: name by purpose, not appearance; reference primitives.
  5. Check accessibility: every text and interactive token against its expected background, using WCAG 2.1 and APCA.
  6. Handle dark mode: second theme, same semantic tokens, different primitive references.
  7. Export: Figma variables and code tokens, named consistently.

A tool built for this workflow

If you're doing this manually, across color pickers, contrast checkers, and Figma, expect to spend days on a palette that should take hours.

Atmos is built specifically for this workflow. It gives you a color wheel, a shade generator that works in LCH and OKLCH, a built-in WCAG 2.1 and APCA contrast checker, a vision simulator for color blindness, and a Figma plugin that syncs your palette directly into Figma variables.

The result: you can go from a brand color to a complete, accessible, Figma-ready color system in a fraction of the time, without switching between five different tools or guessing whether your contrast will pass.

Start with a free 14-day trial. No credit card required.

Start your free trial →