lenec ru

← все посты

Дизайн-токены без Tailwind: организация CSS custom properties в больших проектах

12K

Каждые полгода кто-то в моём окружении предлагает «всё переписать на 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, без препроцессоров и без переписывания на новую модную библиотеку каждые два года.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.