دروس تعليمية

بناء نظام ألوان لنظام التصميم الخاص بك

قراءة 11 دقيقة

Ad hoc color choices accumulate into chaos. A brand blue specified as #3B82F6 in one component, #2563EB in another, and rgba(59, 130, 246, 0.8) in a third — all ostensibly the "same" blue — creates a visual inconsistency that undermines brand cohesion, makes dark mode impossible to implement reliably, and turns every color change into a codebase archaeology project.

A color system solves this by establishing a single source of truth for every color in your interface, organized into two layers: a primitive palette of all available color values, and a semantic token layer that maps those primitives to their intended roles. This separation makes it possible to build dark mode, high-contrast mode, and brand reskins by changing semantic token assignments — without touching any component code.

This guide walks through building a complete, production-ready color system from the ground up: choosing a primitive palette, generating shade scales, defining semantic tokens, implementing them in CSS and Tailwind, and setting up documentation and testing.


Why Systematic Colors Matter

The argument for systematic colors is not aesthetic — it is operational. Here is what breaks without a system:

Inconsistency compounds over time: Early in a project, designers and developers make ad hoc color decisions that "look right." Over two years, a codebase accumulates 40 distinct blue values that are all supposed to be the same blue. No one knows which one is correct.

Dark mode requires semantic mapping: Dark mode cannot work if colors are hardcoded by their visual value. color: #1E293B is a dark color that looks good as text on a light background. In dark mode, you need a different value. If you hardcoded #1E293B in 80 components, you have 80 places to update — or a complex CSS override system that creates a new layer of technical debt. Semantic tokens eliminate this.

Theme changes are expensive: Rebranding or changing a primary color should be a single change. With a semantic system, you change --color-primary-600 in the token file and every button, link, focus ring, and border that references it updates automatically.

Accessibility audits fail silently: Without a system, checking that all text passes WCAG contrast requirements requires manually auditing every text/background combination. With semantic tokens, you can define rules ("text-primary on surface-primary must pass AA") and test them programmatically.


Naming Conventions: Primitive vs. Semantic

A well-structured color system has two naming tiers that serve different purposes.

Primitive Color Tokens

Primitive tokens are the raw color values, named by their position in a scale. They make no assumptions about usage — they are simply a palette of available colors:

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

Primitive token names answer: "What is this color?" They do not answer: "What is this color for?"

Semantic Color Tokens

Semantic tokens reference primitive tokens and name colors by their purpose:

color-action-primary       → blue-600
color-action-primary-hover → blue-700
color-action-primary-text  → white-0

color-surface-default      → neutral-50 (light mode) / neutral-900 (dark mode)
color-surface-elevated     → white-0 (light mode) / neutral-800 (dark mode)

color-text-primary         → neutral-900 (light mode) / neutral-50 (dark mode)
color-text-secondary       → neutral-600 (light mode) / neutral-400 (dark mode)
color-text-disabled        → neutral-400 (light mode) / neutral-600 (dark mode)

color-border-default       → neutral-200 (light mode) / neutral-700 (dark mode)
color-border-focused       → blue-500

Semantic token names answer: "What is this color for?" They do not specify a value — they reference a primitive.

This separation is the key structural decision. Components reference semantic tokens, not primitives. When you define dark mode, you remap semantic tokens to different primitives — no component code changes.

Naming Schemes in the Wild

Different design systems use different naming conventions for their semantic layer:

System Example Semantic Name Convention
Material Design sys.color.primary Dot-separated role hierarchy
Apple HIG label, secondaryLabel Simple role names
Radix UI --accent-9, --gray-11 Scale-based semantic names
Primer (GitHub) fgColor.default, bgColor.default Role + variant

The convention matters less than the consistency. Pick one scheme and apply it throughout.


Shade Scale Generation (50–950)

A shade scale is an ordered series of color values from near-white to near-black within a single hue family. Tailwind CSS popularized the 50–950 scale; Material Design uses 100–900. The specific numbers matter less than having enough steps to cover the range of use cases: very light tints for backgrounds, mid-range values for interactive elements, and deep shades for text.

What Makes a Good Shade Scale

A usable shade scale has two properties:

  1. Sufficient perceptual steps: Adjacent shades should be visually distinct — not so close together that the difference is imperceptible, not so far apart that gaps appear in the coverage.
  2. Consistent lightness progression: The lightness values should increase predictably from 950 (darkest) to 50 (lightest), with no surprising jumps or reversals.

The failure mode for hand-picked shade scales is irregular lightness spacing. A scale where shades 300, 400, and 500 are all perceptually similar and then 500 to 700 jumps dramatically is not useful — you will always reach for 500 when you need something in the middle of the range.

Generating Shades in OKLCH

OKLCH (OK Lightness-Chroma-Hue) is the best color space for shade generation because its lightness channel is perceptually uniform — equal numeric steps produce equal perceived steps. If you space lightness values from L=0.15 (very dark) to L=0.98 (very light) in equal increments, the resulting shades will look equally spaced visually.

To generate an 11-step shade scale in OKLCH:

  1. Choose your base hue and chroma: e.g., H=250 (blue), C=0.20.
  2. Space lightness values evenly: L = 0.15, 0.22, 0.30, 0.38, 0.47, 0.55, 0.63, 0.72, 0.82, 0.92, 0.97.
  3. Optionally vary chroma slightly: reduce chroma at the extremes (very light and very dark) where saturated colors look out of place.
  4. Convert the OKLCH values to hex for implementation.

A practical OKLCH-to-hex scale for a blue hue:

Shade | OKLCH                    | Hex
------+--------------------------+--------
50    | oklch(0.97 0.04 250)     | #EFF6FF
100   | oklch(0.92 0.07 250)     | #DBEAFE
200   | oklch(0.84 0.11 250)     | #BFDBFE
300   | oklch(0.74 0.16 250)     | #93C5FD
400   | oklch(0.65 0.19 250)     | #60A5FA
500   | oklch(0.55 0.22 250)     | #3B82F6
600   | oklch(0.47 0.22 250)     | #2563EB
700   | oklch(0.40 0.20 250)     | #1D4ED8
800   | oklch(0.32 0.18 250)     | #1E40AF
900   | oklch(0.24 0.14 250)     | #1E3A8A
950   | oklch(0.17 0.10 250)     | #172554

Use ColorFYI's Shade Generator to generate Tailwind-style shade scales from any base color, and the Color Converter to inspect each generated shade in OKLCH space to verify lightness progression.

How Many Shade Steps?

For most design systems, 9–11 steps is the right range:

  • 50 and 950: Edge values for surface backgrounds and deepest text/border use.
  • 100–200: Light tinted backgrounds (alert backgrounds, hover states on white surfaces).
  • 300–400: Icon colors, placeholder text, disabled states.
  • 500: Often the "pure" brand color — the one the brand color standards specify.
  • 600–700: Interactive element fills (buttons, links) where contrast against white is required.
  • 800–900: Text and text-on-color-background use.

Generate shades for every hue family in your palette: your primary brand color, a neutral gray series, and any accent colors (red/error, green/success, yellow/warning, etc.).


Semantic Color Tokens

With primitives established, define semantic tokens that map primitives to roles. The granularity of this mapping determines how flexible the system is.

Surface Colors

Surface tokens define the background layers of your UI:

:root {
  --color-surface-base: var(--neutral-50);       /* Page background */
  --color-surface-elevated: var(--white);         /* Card, sheet backgrounds */
  --color-surface-overlay: var(--white);          /* Modal, popover backgrounds */
  --color-surface-sunken: var(--neutral-100);     /* Inset fields, code blocks */
}

[data-theme="dark"] {
  --color-surface-base: var(--neutral-950);
  --color-surface-elevated: var(--neutral-900);
  --color-surface-overlay: var(--neutral-800);
  --color-surface-sunken: var(--neutral-950);
}

Text Colors

Text tokens cover every text role in the hierarchy:

:root {
  --color-text-primary: var(--neutral-900);      /* Body text, headings */
  --color-text-secondary: var(--neutral-600);    /* Captions, labels */
  --color-text-tertiary: var(--neutral-400);     /* Placeholder, hint */
  --color-text-disabled: var(--neutral-300);     /* Disabled state */
  --color-text-inverse: var(--white);            /* Text on dark/colored backgrounds */
  --color-text-link: var(--blue-600);
  --color-text-link-hover: var(--blue-700);
}

[data-theme="dark"] {
  --color-text-primary: var(--neutral-50);
  --color-text-secondary: var(--neutral-400);
  --color-text-tertiary: var(--neutral-600);
  --color-text-disabled: var(--neutral-700);
  --color-text-link: var(--blue-400);
  --color-text-link-hover: var(--blue-300);
}

Interactive / Action Colors

Action tokens cover every state of interactive elements:

:root {
  /* Primary action */
  --color-action-primary-bg: var(--blue-600);
  --color-action-primary-bg-hover: var(--blue-700);
  --color-action-primary-bg-active: var(--blue-800);
  --color-action-primary-text: var(--white);

  /* Secondary / ghost action */
  --color-action-secondary-border: var(--blue-600);
  --color-action-secondary-text: var(--blue-600);
  --color-action-secondary-bg-hover: var(--blue-50);

  /* Destructive action */
  --color-action-danger-bg: var(--red-600);
  --color-action-danger-bg-hover: var(--red-700);
  --color-action-danger-text: var(--white);
}

Feedback / Status Colors

Status tokens map functional colors to their semantic meaning:

:root {
  /* Success */
  --color-status-success-bg: var(--green-50);
  --color-status-success-border: var(--green-200);
  --color-status-success-text: var(--green-700);
  --color-status-success-icon: var(--green-500);

  /* Warning */
  --color-status-warning-bg: var(--yellow-50);
  --color-status-warning-border: var(--yellow-200);
  --color-status-warning-text: var(--yellow-800);
  --color-status-warning-icon: var(--yellow-500);

  /* Error */
  --color-status-error-bg: var(--red-50);
  --color-status-error-border: var(--red-200);
  --color-status-error-text: var(--red-700);
  --color-status-error-icon: var(--red-500);

  /* Info */
  --color-status-info-bg: var(--blue-50);
  --color-status-info-border: var(--blue-200);
  --color-status-info-text: var(--blue-700);
  --color-status-info-icon: var(--blue-500);
}

Border Colors

:root {
  --color-border-default: var(--neutral-200);
  --color-border-strong: var(--neutral-400);
  --color-border-focused: var(--blue-500);
  --color-border-error: var(--red-500);
  --color-border-disabled: var(--neutral-200);
}

[data-theme="dark"] {
  --color-border-default: var(--neutral-700);
  --color-border-strong: var(--neutral-500);
  --color-border-focused: var(--blue-400);
  --color-border-error: var(--red-400);
  --color-border-disabled: var(--neutral-700);
}

Implementation in CSS

The CSS implementation uses custom properties defined at the :root level (or on [data-theme] selectors for theming). Components reference semantic tokens, never primitives directly.

File Structure

design-system/
├── tokens/
│   ├── primitives.css       # All primitive color values
│   ├── semantic.css         # Semantic token → primitive mapping
│   └── dark.css             # Dark mode semantic overrides
└── components/
    ├── button.css
    ├── card.css
    └── ...

primitives.css

:root {
  /* Neutral scale */
  --neutral-50: #F8FAFC;
  --neutral-100: #F1F5F9;
  --neutral-200: #E2E8F0;
  --neutral-300: #CBD5E1;
  --neutral-400: #94A3B8;
  --neutral-500: #64748B;
  --neutral-600: #475569;
  --neutral-700: #334155;
  --neutral-800: #1E293B;
  --neutral-900: #0F172A;
  --neutral-950: #020617;

  /* Blue scale */
  --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;

  /* White and black */
  --white: #FFFFFF;
  --black: #000000;
}

Component Usage

Components reference only semantic tokens:

.button-primary {
  background-color: var(--color-action-primary-bg);
  color: var(--color-action-primary-text);
  border: none;
}

.button-primary:hover {
  background-color: var(--color-action-primary-bg-hover);
}

.button-primary:active {
  background-color: var(--color-action-primary-bg-active);
}

.button-primary:disabled {
  background-color: var(--neutral-200);  /* Exception: disabled state often uses primitives */
  color: var(--color-text-disabled);
}

When the theme changes from light to dark, --color-action-primary-bg resolves to a different value automatically — no button-specific dark mode overrides needed.


Implementation in Tailwind CSS

Tailwind v3 and v4 support design token integration through the config file and CSS variable approach.

Tailwind v3 Config

Define your color scale in tailwind.config.js and map to CSS custom properties:

// tailwind.config.js
const colors = require("tailwindcss/colors");

module.exports = {
  theme: {
    colors: {
      transparent: "transparent",
      current: "currentColor",
      white: "#ffffff",
      black: "#000000",

      // Primitive scales
      neutral: {
        50:  "#F8FAFC",
        100: "#F1F5F9",
        200: "#E2E8F0",
        300: "#CBD5E1",
        400: "#94A3B8",
        500: "#64748B",
        600: "#475569",
        700: "#334155",
        800: "#1E293B",
        900: "#0F172A",
        950: "#020617",
      },
      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 via CSS variables
      "action-primary": "var(--color-action-primary-bg)",
      "action-primary-hover": "var(--color-action-primary-bg-hover)",
      "surface": "var(--color-surface-base)",
      "surface-elevated": "var(--color-surface-elevated)",
      "text-primary": "var(--color-text-primary)",
      "text-secondary": "var(--color-text-secondary)",
      "border-default": "var(--color-border-default)",
    },
  },
};

With this setup, you can write bg-action-primary and it resolves to the correct semantic token value, which in turn resolves to the correct primitive for the current theme.

Tailwind v4 CSS-First Config

Tailwind v4 replaces the JavaScript config with a CSS-first approach using @theme:

@import "tailwindcss";

@theme {
  /* Primitive colors */
  --color-blue-500: #3B82F6;
  --color-blue-600: #2563EB;
  --color-blue-700: #1D4ED8;
  --color-neutral-50: #F8FAFC;
  --color-neutral-900: #0F172A;

  /* Semantic tokens */
  --color-surface: var(--color-surface-base);
  --color-text: var(--color-text-primary);
  --color-primary: var(--color-action-primary-bg);
}

/* Semantic definitions */
:root {
  --color-surface-base: var(--color-neutral-50);
  --color-text-primary: var(--color-neutral-900);
  --color-action-primary-bg: var(--color-blue-600);
}

[data-theme="dark"] {
  --color-surface-base: oklch(0.145 0 0);
  --color-text-primary: var(--color-neutral-50);
  --color-action-primary-bg: var(--color-blue-500);
}

Testing and Documentation

A color system is only useful if it is understood and reliably implemented by everyone on the team. Testing catches regressions; documentation makes the system learnable.

Contrast Testing

Every text token paired with its intended background surface token should be programmatically verified to meet WCAG 2.1 AA requirements (4.5:1 for normal text, 3:1 for large text). Write tests that:

  1. Resolve the current CSS custom property values (for both light and dark themes).
  2. Calculate the WCAG contrast ratio between each text/surface pair.
  3. Assert the ratio meets the required threshold.
import { getContrastRatio } from "some-color-utility";

const tokens = {
  light: {
    textPrimary: "#0F172A",
    surfaceBase: "#F8FAFC",
  },
  dark: {
    textPrimary: "#F8FAFC",
    surfaceBase: "#020617",
  }
};

describe("Color system contrast", () => {
  test("text-primary on surface-base passes AA in light mode", () => {
    const ratio = getContrastRatio(tokens.light.textPrimary, tokens.light.surfaceBase);
    expect(ratio).toBeGreaterThanOrEqual(4.5);
  });

  test("text-primary on surface-base passes AA in dark mode", () => {
    const ratio = getContrastRatio(tokens.dark.textPrimary, tokens.dark.surfaceBase);
    expect(ratio).toBeGreaterThanOrEqual(4.5);
  });
});

Use ColorFYI's Contrast Checker for manual verification of individual token pairs during token definition, before writing automated tests.

Color Token Documentation

Document each semantic token with: - Name: The exact CSS variable name. - Value (light): The primitive it references in light mode. - Value (dark): The primitive in dark mode. - Usage: Where this token should be used. - Anti-patterns: Where it should not be used.

| Token                        | Light          | Dark           | Usage                          |
|------------------------------|----------------|----------------|--------------------------------|
| --color-text-primary         | neutral-900    | neutral-50     | Body text, headings            |
| --color-text-secondary       | neutral-600    | neutral-400    | Captions, metadata, labels     |
| --color-action-primary-bg    | blue-600       | blue-500       | Primary button background      |
| --color-surface-elevated     | white          | neutral-900    | Card, sheet, panel backgrounds |

Visual Regression Testing

Pair the token documentation with visual regression tests using tools like Percy, Chromatic, or Playwright's screenshot comparison. A visual regression test renders your component library at each token change, capturing screenshots and comparing against a baseline — any color change triggers a review, catching accidental regressions.

Storybook Color Palette Page

In Storybook, create a dedicated color palette story that renders all tokens as swatches:

// ColorPalette.stories.jsx
const tokens = [
  { name: "--color-surface-base", label: "Surface / Base" },
  { name: "--color-surface-elevated", label: "Surface / Elevated" },
  { name: "--color-text-primary", label: "Text / Primary" },
  // ...
];

export function ColorPaletteDoc() {
  return (
    <div>
      {tokens.map(({ name, label }) => (
        <div key={name} style={{ display: "flex", alignItems: "center", gap: 16 }}>
          <div style={{
            width: 48,
            height: 48,
            backgroundColor: `var(${name})`,
            borderRadius: 4,
            border: "1px solid rgba(0,0,0,0.1)"
          }} />
          <div>
            <div>{label}</div>
            <code>{name}</code>
          </div>
        </div>
      ))}
    </div>
  );
}

This renders the actual live token values — in whatever theme is active in Storybook — making the documentation self-verifying rather than static.


Key Takeaways

  • A color system separates primitive tokens (named by their value position in a scale: blue-600) from semantic tokens (named by their purpose: color-action-primary-bg). Components always reference semantic tokens, never primitives.
  • This separation enables dark mode and theming by remapping semantic tokens to different primitives at the CSS custom property level, with zero component code changes.
  • Generate shade scales (50–950) using OKLCH color space where the lightness channel is perceptually uniform — equal numeric steps produce equally spaced visual results. Use ColorFYI's Shade Generator to create Tailwind-compatible shade scales from any anchor color.
  • Semantic token categories cover four areas: surface (background layers), text (hierarchy of text roles), action (interactive element states), and feedback (success, warning, error, info).
  • In CSS, define primitives in one file and semantic mappings in another. Use [data-theme="dark"] selector overrides to remap semantic tokens for dark mode.
  • In Tailwind v3, extend the config's color object with CSS custom property references. In Tailwind v4, use the CSS-first @theme block to define both primitive and semantic tokens.
  • Test every text/surface token pair for WCAG contrast compliance — programmatically, not just manually. Use ColorFYI's Contrast Checker for manual verification during token definition.
  • Document tokens in Storybook with live swatches that render actual CSS custom property values, ensuring documentation reflects the real current state of the system.

الألوان ذات الصلة

العلامات التجارية ذات الصلة

الأدوات ذات الصلة