Дизайн-токены без Tailwind: организация CSS custom properties в больших проектах
Каждые полгода кто-то в моём окружении предлагает «всё переписать на Tailwind». Аргумент один и тот же — у нас стилей слишком много, разработчики придумывают новые отступы, бардак растёт. Я не против Tailwind как идеи, но против того, чтобы инструмент был ответом на проблему дисциплины. У меня в дизайн-системе на 200+ компонентов мы обходимся обычным CSS с custom properties и за пять лет не накопили того хаоса, ради которого обычно зовут Tailwind.
Эта статья — про то, как организовать дизайн-токены через CSS variables так, чтобы это масштабировалось. Без препроцессоров и без рантайм-библиотек.
Что такое токен и зачем он нужен
Дизайн-токен — это именованная переменная для значения стиля. --color-primary-500, --space-md, --radius-sm. У токена есть смысл (за что отвечает), у значения смысла нет.
Идея простая: компонент не должен знать конкретного цвета. Он должен знать «акцентный цвет действия». Значение вынесено в одно место и меняется централизованно. Если завтра брендбук переехал с синего на зелёный — правишь одну переменную, не двести компонентов.
Tailwind решает ту же задачу, но через другой механизм — utility-классы. У него тоже под капотом токены (через tailwind.config.js), просто доступ к ним через имена классов. Если у тебя отлажен процесс работы с CSS, токены через CSS variables — менее инвазивный путь.
Двухслойная архитектура
Главная идея, без которой всё рассыплется: токены делятся на два уровня.
Primitive tokens — голые значения. --blue-500: #3b82f6, --space-16: 16px. Это палитра, без смысла.
Semantic tokens — смысловые алиасы поверх примитивов. --color-action-default: var(--blue-500), --space-card-gap: var(--space-16). Компоненты используют только их.
:root {
/* primitives */
--blue-50: #eff6ff;
--blue-500: #3b82f6;
--blue-700: #1d4ed8;
--gray-50: #f9fafb;
--gray-900: #111827;
--space-4: 4px;
--space-8: 8px;
--space-16: 16px;
--space-24: 24px;
/* semantic */
--color-action-default: var(--blue-500);
--color-action-hover: var(--blue-700);
--color-action-bg-subtle: var(--blue-50);
--color-text-primary: var(--gray-900);
--color-text-secondary: #4b5563;
--space-inline-sm: var(--space-8);
--space-block-md: var(--space-16);
}В компоненте используешь только семантические:
.button {
background: var(--color-action-default);
padding: var(--space-block-md) var(--space-inline-sm);
}
.button:hover {
background: var(--color-action-hover);
}Зачем такая сложность. Чтобы тёмная тема, кастомизация, ребрендинг работали. В тёмной теме примитивы остаются те же (синий — это синий), а семантические переопределяются:
[data-theme="dark"] {
--color-action-default: var(--blue-700);
--color-text-primary: var(--gray-50);
--color-text-secondary: #9ca3af;
}Соглашения именования
Без чётких правил именования токены превращаются в свалку через полгода. У меня работает такая схема для семантических: --<category>-<property>-<variant>-<state>.
- category: color, space, radius, shadow, font, motion.
- property: что именно. Для color — text, bg, border, action. Для space — inline (горизонтальный), block (вертикальный).
- variant: основной/второстепенный/акцентный. primary, secondary, subtle, accent, danger.
- state: default, hover, active, disabled, focus.
Примеры:
--color-bg-primary— основной фон страницы--color-bg-subtle— фон-плашки с легкой подсветкой--color-border-strong-hover— сильный бордер при hover--space-inline-md— средний горизонтальный отступ--motion-duration-base— стандартная длительность анимации
Соглашение должно быть зафиксировано в README дизайн-системы. У нас в команде разница между bg-subtle и bg-muted регулярно вызывала споры. Записали — стало проще.
Где жить определению токенов
Один файл tokens.css на проект. Структурировано по секциям:
/* tokens.css */
:root {
/* === colors / primitive === */
--blue-500: #3b82f6;
/* ... */
/* === colors / semantic === */
--color-action-default: var(--blue-500);
/* ... */
/* === spacing / primitive === */
--space-4: 4px;
/* ... */
/* === spacing / semantic === */
--space-inline-sm: var(--space-8);
/* ... */
/* === typography === */
--font-size-body: 16px;
--line-height-body: 1.5;
}Я пробовала разбивать на отдельные файлы (colors.css, spacing.css, typography.css) и потом импортировать в один. Не зашло: при чтении токена приходится прыгать между файлами. В одном файле всё видно сразу, и поиском быстрее найти.
Импортируется этот файл в самом начале глобальных стилей. Не в каждом компоненте — глобально, один раз.
Темизация
Самое типичное применение токенов — переключение тем. С двухслойной архитектурой это бесплатно: меняем только семантический слой.
:root {
--color-bg-page: #ffffff;
--color-text-primary: #111827;
}
[data-theme="dark"] {
--color-bg-page: #0a0a0a;
--color-text-primary: #f5f5f5;
}
[data-theme="high-contrast"] {
--color-bg-page: #000000;
--color-text-primary: #ffffff;
}Переключатель темы — атрибут на корне:
function setTheme(theme: "light" | "dark" | "high-contrast") {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
}Никакой перерендер компонентов не нужен — браузер сам пересчитает все CSS variables. На 200 компонентах одновременно. За миллисекунды.
Скоупы и оверрайды
CSS variables скоупируются по DOM. Это даёт мощный механизм точечных переопределений.
/* Глобально */
:root {
--button-bg: var(--color-action-default);
}
.button {
background: var(--button-bg);
}
/* Локально для тёмной плашки */
.dark-banner {
--button-bg: white;
--color-text-primary: white;
}Внутри .dark-banner все кнопки автоматически становятся белыми, потому что переменная переопределена в этом скоупе. Без классов-модификаторов, без пропов в React, без перекидывания темы.
Я часто использую этот трюк для контекстных карточек: в каталоге plain-карточка, в feature-секции — с тёмным фоном и инвертированным текстом. Никаких <Card variant="inverted"> — просто scoped токены.
Связь с компонентами в React
В React-компоненте я не пишу токены в JS. Стили — в отдельном CSS-файле, через имя класса. Без CSS-in-JS.
import "./Button.css";
type Props = {
children: React.ReactNode;
variant?: "primary" | "secondary" | "danger";
};
export function Button({ children, variant = "primary" }: Props) {
return (
<button className={`button button--${variant}`}>
{children}
</button>
);
}/* Button.css */
.button {
padding: var(--space-block-sm) var(--space-inline-md);
border-radius: var(--radius-sm);
font: var(--font-button);
cursor: pointer;
transition: background var(--motion-duration-fast);
}
.button--primary {
background: var(--color-action-default);
color: var(--color-text-on-action);
}
.button--primary:hover {
background: var(--color-action-hover);
}
.button--secondary {
background: var(--color-bg-subtle);
color: var(--color-text-primary);
}
.button--danger {
background: var(--color-danger-default);
color: var(--color-text-on-action);
}Динамика через React — пробрасывание стилей-переменных:
<div
className="progress"
style={{ "--progress-value": `${percent}%` } as React.CSSProperties}
/>.progress::before {
width: var(--progress-value, 0%);
}Один из немногих случаев, когда стиль идёт через JSX. И только потому, что значение меняется динамически.
Линтинг и контроль
Чтобы команда не заводила хардкоженные значения, нужен линтер. Я использую stylelint с правилом declaration-property-value-disallowed-list:
{
"rules": {
"declaration-property-value-disallowed-list": {
"/color|background/": ["/#[0-9a-f]/i"],
"/padding|margin|gap/": ["/^[0-9]+px$/"]
},
"comment-empty-line-before": null
}
}Это говорит: никаких хексов в свойствах с color, никаких пиксельных значений в padding/margin/gap. Только токены. Если разработчик пишет color: #333, линтер красным подсвечивает на ревью.
Помимо линтера, у нас есть Storybook со страничкой «Tokens», где видны все доступные значения. Я заметила, что когда у разработчика есть визуальный референс, он реже изобретает свои отступы.
Что не работает
Несколько граней, на которые я налетала.
Calc и фолбэки
Старые браузеры (которых уже почти не осталось, но всё же) не понимают custom properties. Если в коде padding: var(--space-md, 16px) — фолбэк сработает. Но если в самом tokens.css идёт цепочка --space-md: var(--space-16), то у IE11 ничего не выйдет. С 2024 года я перестала об этом думать, но если у тебя legacy-проект — учитывай.
Перебор с уровнями
Можно сделать три, четыре, пять уровней семантики. Не стоит. Двух (primitive + semantic) хватает в 99% случаев. Если хочется добавить третий — обычно это значит, что нужно лучше продумать имена в семантике, а не плодить уровни.
Цвета через HSL
Хочется завести один --brand-hue: 220 и крутить от него все оттенки. Красиво в теории, в проде ломается, когда дизайнер захочет «чуть зеленее в 700-м». Лучше заводить каждый оттенок отдельно — это не сильно дольше, но даёт свободу артистам.
Что унести с собой
Дизайн-токены через CSS variables — это не «версия Tailwind для тех, кто не хочет учить классы». Это про разделение значений и семантики, что в долгосрочной перспективе масштабируется лучше utility-классов. Особенно — на больших проектах с темами и кастомизацией.
Двухслойная архитектура (primitive + semantic), один файл tokens.css, scoped overrides через DOM, линтер на хардкод. Этого хватает, чтобы 200+ компонентов жили в порядке без CSS-in-JS, без препроцессоров и без переписывания на новую модную библиотеку каждые два года.