CSS-переменные и дизайн-токены: рабочие приёмы из практики
Как связать дизайн-токены с CSS custom properties так, чтобы тема переключалась без перезагрузки, а мёртвые значения не размножались по проекту. Разбираем подходы и их цену.

Вы завели --color-primary: #2563eb, переиспользовали в двадцати местах — и через месяц у вас три оттенка синего, потому что кто-то захардкодил #2563eb в новом компоненте. Плюс дизайнер прислал тёмную тему, а переключать нужно без перезагрузки страницы. Простой подход «одна переменная — один цвет» с этим не справляется. Разберём, как организовать CSS custom properties и дизайн-токены так, чтобы тема жила в рантайме, а источник истины был один.
Материал для фронтенд-разработчиков, которые уже используют CSS-переменные и упёрлись в масштабирование: смену тем, отраслевую палитру и синхронизацию с дизайном. Всё, что ниже, работает в актуальных браузерах с поддержкой @property (Chrome/Edge 85+, Safari 16.4+, Firefox 128+). Где @property не обязателен — отмечено отдельно.
Токен и CSS-переменная — не одно и то же
Дизайн-токен — это именованное решение дизайн-системы: «основной цвет действия», «отступ между карточками». CSS custom property — механизм, которым это решение доставляется в браузер. Между ними должен быть слой, иначе вы каждый раз переписываете все компоненты при смене палитры.
Рабочая структура — три уровня токенов:
- Примитивы — сырые значения без смысла:
--blue-500: #2563eb,--space-4: 16px. Их не используют в компонентах напрямую. - Семантические — назначение:
--color-action: var(--blue-500),--gap-card: var(--space-4). Компоненты ссылаются только на них. - Компонентные — при необходимости:
--button-bg: var(--color-action). Нужны, когда у компонента своя логика темизации.
Смена темы = переопределение семантического уровня. Примитивы остаются, компоненты не трогаются.
Правило, которое экономит недели рефакторинга: в разметке компонентов не должно быть ни одного хекс-кода и ни одной ссылки на примитив. Только семантические токены. Всё остальное — утечка абстракции.
Как это выглядит в CSS
/* Уровень 1: примитивы — в :root, глобально */
:root {
--blue-500: #2563eb;
--blue-600: #1d4ed8;
--gray-50: #f9fafb;
--gray-900: #111827;
--space-4: 16px;
}
/* Уровень 2: семантика — светлая тема по умолчанию */
:root {
--color-action: var(--blue-500);
--color-action-hover: var(--blue-600);
--color-bg: var(--gray-50);
--color-text: var(--gray-900);
}
/* Тёмная тема: переопределяем только семантику */
[data-theme="dark"] {
--color-action: var(--blue-500);
--color-bg: var(--gray-900);
--color-text: var(--gray-50);
}
/* Компонент знает только про семантику */
.button {
background: var(--color-action);
color: var(--color-bg);
padding: var(--space-4);
}
.button:hover {
background: var(--color-action-hover);
}Переключение темы — смена одного атрибута на <html>: document.documentElement.dataset.theme = 'dark'. Ни один компонент не перерисовывается через JS, всё делает каскад.
Четыре приёма, которые снимают реальную боль
1. Fallback у семантического токена
Второй аргумент var() — значение по умолчанию. Оно спасает, когда компонент попал на страницу, где нужный токен не объявлен:
.card {
/* если --color-surface не задан — берём светло-серый */
background: var(--color-surface, #f3f4f6);
}Цена: fallback молча маскирует опечатку в имени токена. var(--color-surfase, ...) отработает fallback вместо того, чтобы сломаться заметно. Не используйте fallback как основной механизм — только как страховку на границах системы (виджеты, встраиваемые блоки).
2. Композиция вместо новых переменных
Не заводите --color-action-10 для полупрозрачного варианта. Храните цвет в компонентах color-mix() или в HSL-каналах и собирайте прозрачность на месте:
:root {
--color-action: #2563eb;
}
.overlay {
/* color-mix: Chrome/Edge 111+, Safari 16.2+, Firefox 113+ */
background: color-mix(in srgb, var(--color-action) 12%, transparent);
}Так один токен покрывает и заливку, и подложку, и границу. Меньше переменных — меньше рассинхрона.
3. Типизация через @property
Обычная CSS-переменная — это строка. Её нельзя анимировать: transition для --color-action сработает скачком, а не плавно. @property объявляет тип, и переменная начинает интерполироваться:
@property --color-action {
syntax: '<color>';
inherits: true;
initial-value: #2563eb;
}
.button {
background: var(--color-action);
transition: --color-action 0.2s ease;
}Плюс: типизированная переменная с неверным значением (например, --color-action: 16px) не сломает соседей — браузер подставит initial-value. Цена: без @property анимация переменной не работает никак, обходных путей на чистом CSS нет.
4. Медиазависимые токены
Токен может менять значение по контексту — тёмная системная тема, узкий экран — без единой строки JS:
:root {
--gap-card: 24px;
}
@media (max-width: 640px) {
:root { --gap-card: 12px; }
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #111827;
--color-text: #f9fafb;
}
}Компонент, использующий --gap-card, адаптируется сам. Это переносит логику адаптива из компонентов в централизованный слой токенов.
Когда что выбирать
| Задача | Механизм | Чем платите |
|---|---|---|
| Смена темы в рантайме | Переопределение семантики через [data-theme] | Нужна дисциплина: ноль хардкода в компонентах |
| Плавный переход между темами | @property + transition | Нет поддержки в старых браузерах, деградирует в скачок |
| Оттенки и прозрачность одного цвета | color-mix() | Safari 16.1 и ниже не поддерживает, нужен fallback |
| Реакция на системную тему | prefers-color-scheme | Конфликтует с ручным переключателем — нужен приоритет |
| Синхронизация с Figma | Экспорт токенов в JSON → сборка в CSS | Отдельный инструмент в пайплайне (Style Dictionary и т.п.) |
Что ломается на практике
- Каскад съедает переопределение. Если тёмную тему вешаете на
body[data-theme], а примитивы объявлены в:root, специфичность у семантики выше — работает. Но стоит объявить семантику тоже в:rootи переопределить в классе с той же специфичностью — выиграет тот, кто ниже в файле. Держите переопределения тем в отдельном месте после дефолтов. - Инлайновый стиль перебивает всё.
element.style.setProperty('--color-action', ...)ставит значение на конкретный элемент с максимальным приоритетом. Удобно для точечной подсветки, опасно как случайная утечка из JS-логики. - Циклическая ссылка молча гасит переменную. Если
--a: var(--b)и--b: var(--a), обе становятся невалидными и превращаются вinitial, а не в вашу ожидаемую величину. DevTools покажет вычисленное значение пустым — ищите цикл. - Мигание темы при загрузке (FOUC). Если тему ставит JS после отрисовки, пользователь видит вспышку светлой темы перед тёмной. Ставьте
data-themeинлайновым скриптом в<head>до отрисовки body, либо опирайтесь наprefers-color-schemeв CSS.
Приоритет ручного переключателя над системной темой
Частый конфликт: пользователь выбрал светлую тему вручную, но система у него тёмная — и prefers-color-scheme: dark перебивает выбор. Решение — не смешивать источники. Системную тему используйте только как значение по умолчанию, а ручной выбор всегда пишите в data-theme, который специфичнее медиазапроса на :root:
/* дефолт — по системе */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--color-bg: #111827;
}
}
/* явный выбор всегда сильнее */
[data-theme="light"] { --color-bg: #f9fafb; }
[data-theme="dark"] { --color-bg: #111827; }Ключ здесь — :not([data-theme]): медиазапрос действует, только пока пользователь ничего не выбрал явно.
Что делать дальше
Если токенов уже больше полусотни и они живут в Figma — стоит поднять пайплайн экспорта: дизайнер меняет значение в макете, JSON пересобирается в CSS автоматически, ручной синхронизации нет. Смотрите в сторону Style Dictionary. Инвалидация и версионирование токенов между релизами — отдельная тема, здесь её не разворачиваем. Ближайший шаг для существующего проекта: прогоните поиск по кодовой базе на хекс-коды и px внутри компонентов — каждое найденное значение либо становится ссылкой на семантический токен, либо остаётся с пометкой, почему оно исключение.
Эмпатичный слушатель: Поддержка, снятие тревожности, дыхательные практики.
Спросить за 8 ₽Источники: Using CSS custom properties (variables) — MDN, @property — CSS — MDN
Частые вопросы
А если браузер не поддерживает @property — анимация темы просто пропадёт?
Почему у меня переопределение темы не срабатывает, хотя переменная объявлена?
Стоит ли использовать fallback в var() везде на всякий случай?
Как избежать мигания светлой темы при загрузке страницы?
Можно ли анимировать любой тип CSS-переменной через @property?
Не бьёт ли большое число CSS-переменных по производительности?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.