Уроки

Построение цветовой системы для дизайн-системы

11 мин чтения

Стихийные цветовые решения накапливаются в хаос. Фирменный синий, заданный как #3B82F6 в одном компоненте, #2563EB — в другом и rgba(59, 130, 246, 0.8) — в третьем, при том что все они якобы «один и тот же» синий, порождают визуальную непоследовательность, разрушающую согласованность бренда, делающую тёмный режим нереализуемым и превращающую каждое изменение цвета в археологические раскопки по кодовой базе.

Цветовая система решает эту проблему, устанавливая единый источник истины для каждого цвета в интерфейсе. Она организована в два слоя: примитивная палитра из всех доступных цветовых значений и слой семантических токенов, отображающий примитивы на их предназначенные роли. Такое разделение позволяет реализовать тёмный режим, режим высокой контрастности и ребрендинг путём изменения назначений семантических токенов — без единого изменения в коде компонентов.

В этом руководстве описано построение полной, готовой к продакшену цветовой системы с нуля: выбор примитивной палитры, генерация шкал оттенков, определение семантических токенов, их реализация в CSS и Tailwind, а также организация документации и тестирования.


Почему систематизированные цвета важны

Аргумент в пользу систематизированных цветов — не эстетический, а операционный. Вот что ломается без системы:

Непоследовательность нарастает со временем: в начале проекта дизайнеры и разработчики принимают стихийные цветовые решения, которые «выглядят правильно». Спустя два года кодовая база накапливает 40 различных оттенков синего, которые все должны быть одним и тем же синим. Никто не знает, какой из них правильный.

Тёмный режим требует семантического отображения: тёмный режим не сработает, если цвета закодированы по их визуальному значению. color: #1E293B — тёмный цвет, хорошо смотрящийся как текст на светлом фоне. В тёмном режиме нужно другое значение. Если #1E293B захардкожен в 80 компонентах, вам придётся обновить 80 мест — или создать сложную систему CSS-переопределений, порождающую новый технический долг. Семантические токены устраняют эту проблему.

Смена темы обходится дорого: ребрендинг или изменение основного цвета должны быть единственным изменением. При семантической системе вы меняете --color-primary-600 в файле токенов — и каждая кнопка, ссылка, кольцо фокуса и граница, ссылающаяся на него, обновляются автоматически.

Аудиты доступности молча проваливаются: без системы проверка того, что весь текст соответствует требованиям WCAG к контрасту, требует ручного аудита каждой комбинации текст/фон. Семантические токены позволяют определить правила («text-primary на surface-primary должен пройти AA») и проверять их программно.


Соглашения об именовании: примитивные и семантические токены

Хорошо структурированная цветовая система имеет два уровня именования, выполняющих разные функции.

Примитивные цветовые токены

Примитивные токены — это сырые цветовые значения, названные по их позиции в шкале. Они не предполагают конкретного использования — это просто палитра доступных цветов:

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

Имена примитивных токенов отвечают на вопрос: «Что это за цвет?» Они не отвечают: «Для чего этот цвет?»

Семантические цветовые токены

Семантические токены ссылаются на примитивные и называют цвета по их назначению:

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

color-surface-default      → neutral-50 (светлый режим) / neutral-900 (тёмный режим)
color-surface-elevated     → white-0 (светлый режим) / neutral-800 (тёмный режим)

color-text-primary         → neutral-900 (светлый режим) / neutral-50 (тёмный режим)
color-text-secondary       → neutral-600 (светлый режим) / neutral-400 (тёмный режим)
color-text-disabled        → neutral-400 (светлый режим) / neutral-600 (тёмный режим)

color-border-default       → neutral-200 (светлый режим) / neutral-700 (тёмный режим)
color-border-focused       → blue-500

Имена семантических токенов отвечают на вопрос: «Для чего этот цвет?» Они не задают конкретное значение — они ссылаются на примитив.

Это разделение — ключевое структурное решение. Компоненты ссылаются на семантические токены, а не на примитивы. Определяя тёмный режим, вы переназначаете семантические токены на другие примитивы — код компонентов не меняется.

Схемы именования в реальных системах

Разные дизайн-системы используют разные соглашения для семантического слоя:

Система Пример семантического имени Соглашение
Material Design sys.color.primary Иерархия ролей через точку
Apple HIG label, secondaryLabel Простые имена ролей
Radix UI --accent-9, --gray-11 Имена на основе шкалы
Primer (GitHub) fgColor.default, bgColor.default Роль + вариант

Соглашение важно меньше, чем последовательность. Выберите одну схему и применяйте её повсеместно.


Генерация шкалы оттенков (50–950)

Шкала оттенков — упорядоченный ряд цветовых значений от почти белого до почти чёрного в рамках одного цветового семейства. Tailwind CSS популяризировал шкалу 50–950; Material Design использует 100–900. Конкретные числа менее важны, чем наличие достаточного количества шагов для покрытия всего диапазона: очень светлые тинты для фонов, средние значения для интерактивных элементов и глубокие оттенки для текста.

Признаки качественной шкалы оттенков

Пригодная шкала оттенков обладает двумя свойствами:

  1. Достаточное количество воспринимаемых шагов: соседние оттенки должны быть визуально различимы — не настолько близкими, чтобы разница была незаметна, и не настолько далёкими, чтобы возникали пробелы в покрытии.
  2. Последовательная прогрессия яркости: значения яркости должны предсказуемо возрастать от 950 (темнее) к 50 (светлее) без неожиданных скачков или инверсий.

Типичный сбой вручную подобранных шкал — нерегулярный шаг яркости. Шкала, в которой оттенки 300, 400 и 500 перцептивно схожи, а от 500 до 700 происходит резкий прыжок, — непригодна: вы всегда будете тянуться к 500, когда нужно что-то в середине диапазона.

Генерация оттенков в OKLCH

OKLCH (равноконтрастное пространство с характеристиками Lightness-Chroma-Hue) — лучшее цветовое пространство для генерации оттенков, поскольку его канал яркости перцептивно равномерен: равные числовые шаги дают равные воспринимаемые шаги. Если распределить значения яркости от L=0,15 (очень тёмный) до L=0,98 (очень светлый) с равными интервалами, полученные оттенки будут выглядеть равномерно распределёнными.

Для генерации 11-шаговой шкалы оттенков в OKLCH:

  1. Выберите базовый оттенок и насыщенность: например, H=250 (синий), C=0,20.
  2. Разместите значения яркости равномерно: L = 0,15, 0,22, 0,30, 0,38, 0,47, 0,55, 0,63, 0,72, 0,82, 0,92, 0,97.
  3. При желании немного варьируйте насыщенность: снижайте её на крайних значениях (очень светлый и очень тёмный), где насыщенные цвета выглядят неуместно.
  4. Конвертируйте значения OKLCH в hex для реализации.

Практическая шкала OKLCH-to-hex для синего оттенка:

Оттенок | 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

Используйте Генератор оттенков ColorFYI для создания шкал оттенков в стиле Tailwind из любого базового цвета, а Конвертер цветов — для проверки каждого оттенка в пространстве OKLCH и верификации прогрессии яркости.

Сколько шагов нужно?

Для большинства дизайн-систем оптимальный диапазон — 9–11 шагов:

  • 50 и 950: крайние значения для фоновых поверхностей и самого тёмного текста/границ.
  • 100–200: светлые тонированные фоны (фоны предупреждений, состояния наведения на белых поверхностях).
  • 300–400: цвета иконок, текст-заполнитель, отключённые состояния.
  • 500: часто «чистый» фирменный цвет — тот, который задан в стандартах фирменного стиля.
  • 600–700: заливки интерактивных элементов (кнопок, ссылок), где требуется контраст с белым.
  • 800–900: текст и текст на цветном фоне.

Генерируйте оттенки для каждого цветового семейства в палитре: основного фирменного цвета, нейтральной серой серии и акцентных цветов (красный/ошибка, зелёный/успех, жёлтый/предупреждение и т. д.).


Семантические цветовые токены

После установки примитивов определите семантические токены, отображающие примитивы на роли. Детализация этого отображения определяет гибкость системы.

Цвета поверхностей

Токены поверхностей определяют фоновые слои интерфейса:

:root {
  --color-surface-base: var(--neutral-50);       /* Фон страницы */
  --color-surface-elevated: var(--white);         /* Фон карточек, листов */
  --color-surface-overlay: var(--white);          /* Фон модальных окон, поповеров */
  --color-surface-sunken: var(--neutral-100);     /* Внутренние поля, блоки кода */
}

[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);
}

Цвета текста

Текстовые токены охватывают все текстовые роли в иерархии:

:root {
  --color-text-primary: var(--neutral-900);      /* Основной текст, заголовки */
  --color-text-secondary: var(--neutral-600);    /* Подписи, метки */
  --color-text-tertiary: var(--neutral-400);     /* Заполнитель, подсказка */
  --color-text-disabled: var(--neutral-300);     /* Отключённое состояние */
  --color-text-inverse: var(--white);            /* Текст на тёмном/цветном фоне */
  --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);
}

Интерактивные / действия

Токены действий охватывают каждое состояние интерактивных элементов:

:root {
  /* Основное действие */
  --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);

  /* Вторичное / призрачное действие */
  --color-action-secondary-border: var(--blue-600);
  --color-action-secondary-text: var(--blue-600);
  --color-action-secondary-bg-hover: var(--blue-50);

  /* Деструктивное действие */
  --color-action-danger-bg: var(--red-600);
  --color-action-danger-bg-hover: var(--red-700);
  --color-action-danger-text: var(--white);
}

Цвета обратной связи / статуса

Статусные токены отображают функциональные цвета на их семантическое значение:

:root {
  /* Успех */
  --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);

  /* Предупреждение */
  --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);

  /* Ошибка */
  --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);

  /* Информация */
  --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);
}

Цвета границ

: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);
}

Реализация в CSS

CSS-реализация использует кастомные свойства, определённые на уровне :root (или на селекторах [data-theme] для тематизации). Компоненты ссылаются на семантические токены, никогда напрямую на примитивы.

Структура файлов

design-system/
├── tokens/
│   ├── primitives.css       # Все примитивные цветовые значения
│   ├── semantic.css         # Семантический токен → отображение примитива
│   └── dark.css             # Переопределения семантики для тёмного режима
└── components/
    ├── button.css
    ├── card.css
    └── ...

primitives.css

:root {
  /* Нейтральная шкала */
  --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-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: #FFFFFF;
  --black: #000000;
}

Использование в компонентах

Компоненты ссылаются только на семантические токены:

.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);  /* Исключение: отключённое состояние часто использует примитивы */
  color: var(--color-text-disabled);
}

При смене темы со светлой на тёмную --color-action-primary-bg автоматически разрешается в другое значение — никаких переопределений тёмного режима для конкретных кнопок не требуется.


Реализация в Tailwind CSS

Tailwind v3 и v4 поддерживают интеграцию дизайн-токенов через конфигурационный файл и подход с CSS-переменными.

Конфигурация Tailwind v3

Определите цветовую шкалу в tailwind.config.js и привяжите к CSS-кастомным свойствам:

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

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

      // Примитивные шкалы
      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",
      },

      // Семантические токены через CSS-переменные
      "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)",
    },
  },
};

С такой настройкой вы можете писать bg-action-primary, и это разрешится в правильное значение семантического токена, который в свою очередь разрешается в правильный примитив для текущей темы.

CSS-ориентированная конфигурация Tailwind v4

Tailwind v4 заменяет JavaScript-конфиг CSS-ориентированным подходом с использованием @theme:

@import "tailwindcss";

@theme {
  /* Примитивные цвета */
  --color-blue-500: #3B82F6;
  --color-blue-600: #2563EB;
  --color-blue-700: #1D4ED8;
  --color-neutral-50: #F8FAFC;
  --color-neutral-900: #0F172A;

  /* Семантические токены */
  --color-surface: var(--color-surface-base);
  --color-text: var(--color-text-primary);
  --color-primary: var(--color-action-primary-bg);
}

/* Семантические определения */
: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);
}

Тестирование и документация

Цветовая система полезна только тогда, когда она понята и надёжно реализована каждым членом команды. Тестирование выявляет регрессии; документация делает систему доступной для изучения.

Тестирование контраста

Каждый текстовый токен в паре с предполагаемым фоновым токеном поверхности должен быть программно проверен на соответствие требованиям WCAG 2.1 AA (4,5:1 для обычного текста, 3:1 для крупного). Напишите тесты, которые:

  1. Разрешают текущие значения CSS-кастомных свойств (для обеих тем — светлой и тёмной).
  2. Вычисляют коэффициент контраста WCAG для каждой пары текст/поверхность.
  3. Утверждают, что коэффициент соответствует требуемому порогу.
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);
  });
});

Используйте Проверку контраста ColorFYI для ручной верификации отдельных пар токенов при их определении, до написания автоматических тестов.

Документация цветовых токенов

Документируйте каждый семантический токен с указанием: - Имени: точного имени CSS-переменной. - Значения (светлый режим): примитива, на который он ссылается в светлом режиме. - Значения (тёмный режим): примитива в тёмном режиме. - Использования: где этот токен следует применять. - Антипаттернов: где его не следует применять.

| Токен                        | Светлый        | Тёмный         | Использование                     |
|------------------------------|----------------|----------------|-----------------------------------|
| --color-text-primary         | neutral-900    | neutral-50     | Основной текст, заголовки         |
| --color-text-secondary       | neutral-600    | neutral-400    | Подписи, метаданные, метки        |
| --color-action-primary-bg    | blue-600       | blue-500       | Фон основной кнопки               |
| --color-surface-elevated     | white          | neutral-900    | Фон карточек, листов, панелей     |

Визуальное регрессионное тестирование

Дополните документацию токенов визуальными регрессионными тестами с помощью Percy, Chromatic или сравнения скриншотов Playwright. Визуальный регрессионный тест рендерит библиотеку компонентов при каждом изменении токена, захватывая скриншоты и сравнивая их с базовым эталоном — любое изменение цвета инициирует ревью, выявляя случайные регрессии.

Страница цветовой палитры в Storybook

В Storybook создайте выделенную историю цветовой палитры, отображающую все токены в виде цветовых образцов:

// 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>
  );
}

Это отображает фактические живые значения токенов — в любой теме, активной в Storybook, — делая документацию самопроверяемой, а не статичной.


Ключевые выводы

  • Цветовая система разделяет примитивные токены (названные по позиции в шкале: blue-600) от семантических токенов (названных по назначению: color-action-primary-bg). Компоненты всегда ссылаются на семантические токены, никогда на примитивы.
  • Это разделение обеспечивает тёмный режим и тематизацию путём переназначения семантических токенов на разные примитивы на уровне CSS-кастомных свойств — без единого изменения кода компонентов.
  • Генерируйте шкалы оттенков (50–950) в цветовом пространстве OKLCH, где канал яркости перцептивно равномерен: равные числовые шаги дают равномерно распределённые визуальные результаты. Используйте Генератор оттенков ColorFYI для создания шкал, совместимых с Tailwind, из любого опорного цвета.
  • Категории семантических токенов охватывают четыре области: поверхности (фоновые слои), текст (иерархия текстовых ролей), действия (состояния интерактивных элементов) и обратная связь (успех, предупреждение, ошибка, информация).
  • В CSS определяйте примитивы в одном файле, а семантические отображения — в другом. Используйте переопределения селектора [data-theme="dark"] для переназначения семантических токенов в тёмном режиме.
  • В Tailwind v3 расширяйте объект цветов конфига ссылками на CSS-кастомные свойства. В Tailwind v4 используйте CSS-ориентированный блок @theme для определения как примитивных, так и семантических токенов.
  • Тестируйте каждую пару токенов текст/поверхность на соответствие требованиям WCAG — программно, а не только вручную. Используйте Проверку контраста ColorFYI для ручной верификации при определении токенов.
  • Документируйте токены в Storybook с живыми образцами, отображающими фактические значения CSS-кастомных свойств, — это гарантирует соответствие документации реальному текущему состоянию системы.

Похожие цвета

Похожие бренды

Похожие инструменты