Tailwind отлично подходит для прототипов. Но когда проект растёт, его основная концепция становится именно той причиной, по которой ваша дизайн-система разваливается.

Недавно в посте на LinkedIn я написал: «Нельзя построить жизнеспособную дизайн-систему на основе фреймворка Tailwind CSS». И вот почему.

Tailwind CSS — why it doesn’t work
Tailwind CSS — почему это не работает

Если взять любой классический CSS-фреймворк и правильно всё настроить, можно делать довольно сложные вещи очень легко. Например, в каждой дизайн-системе есть несколько типов кнопок: primary, secondary и т.д. Но что происходит, когда нужно добавить новую вариацию?

Новый вариант кнопки

Допустим, вам нужна новая кнопка btn-focus. Но её нужно интегрировать в существующую дизайн-систему. То есть это должна быть не просто кнопка с другим цветом фона, а кнопка, которую можно комбинировать с существующими классами дизайн-системы: btn-sm, btn-lg, btn-icon, btn-outline и т.д.

Помимо этого, в дизайн-системе обычно ты определяешь чёткие правила того, как работают дополнительные состояния. Например, цвет фона всех кнопок в состоянии hover должен быть на 15% темнее основного цвета в нормальном состоянии. Или цвет текста в активном состоянии для всех кнопок должен быть на 10% светлее.

Во многих классических UI-фреймворках можно найти что-то вроде:

.button-variant-other(@color; @background; @border);

Это генерирует полностью интегрированный вариант кнопки с помощью одной функции. Она позволяет задать систему того, как будут работать разные варианты кнопок и разные состояния этих вариантов. При этом новые элементы будут интегрированы в дизайн-систему UI-фреймворка.

Можно ли сделать то же самое легко в Tailwind? Сомневаюсь.

После моего поста на LinkedIn я получил несколько возможных решений и хотел бы их рассмотреть и прокомментировать тут.

1. CVA решение

И в качестве примера — как Shadcn структурирует код компонента Button.
Давайте посмотрим:

variant "default" | "outline" | "ghost" | "destructive" | "secondary" | "link" "default"
size "default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" 

У них есть несколько вариантов кнопок и несколько размеров. Выглядит чисто и просто? Да. И я могу сказать, что это основной подход для Tailwind CSS прямо сейчас. Главное преимущество — скорость разработки. Поскольку не нужно переключаться между файлом компонента и файлом стилей, код пишется значительно быстрее. В чём недостаток?

Ограниченное количество комбинаций, которые можно использовать, и этого недостаточно для долгосрочного проекта. Как только вам понадобится primary outlined small кнопка, вы столкнётесь с таким ограничением.

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

2. Подход через CSS-переменные

.btn {
  /* базовый класс */
  @apply inline-flex items-center justify-center rounded px-4 py-2 font-body;

  /* используем переменную для определения состояний */
  background-color: var(--btn-color);
  color: var(--btn-text, white); /* по умолчанию белый текст */
}

.btn:hover {
  /* на 15% темнее для любого цвета */
  background-color: color-mix(in srgb, var(--btn-color), black 15%);
}

.btn:active {
  /* на 10% светлее */
  filter: brightness(1.1);
}

/* варианты задают конкретные цвета */
.btn-primary {
  --btn-color: theme('colors.blue.600');
}
.btn-whatever {
  --btn-color: …
}

Лично мне этот подход нравится. Он выглядит элегантно и эффективно. Основная идея — менять значение переменной внутри варианта кнопки вместо того, чтобы переписывать стиль.

Однако такое решение не связано конкретно с фреймворком Tailwind. Его можно реализовать на чистом CSS и использовать с любой UI-библиотекой. Если же вы выбираете Tailwind CSS при таком подходе, то вы теряете его главное преимущество — скорость, потому что не можете писать стили прямо в файле компонента.

3. Директива @utility

@utility btn-* {
  font-size: --value(--text-ui-*, [length]);
  background-color: --value(--color-ui-*, [color]);
  color: --value(--color-ui-*-text, [color]);
}

Ещё одно интересное решение. Оно создаёт утилитарные классы по определённым правилам, после чего можно использовать CSS-переменные с заданными именами для кастомизации этих вариантов.

Таким образом, можно переиспользовать классы вместе с модификаторами Tailwind: для разных состояний или адаптивного дизайна. Но использовать btn-focus класс для средних или маленьких разрешений не имеет особого смысла — btn-primary или btn-focus функциональные классы.

Функция utility, даже если выстроить вокруг неё систему, — это лишь костыль. Да, можно создать 5 основных типов кнопок на её основе и даже быстро добавлять новые. Но что насчёт других типов или комбинаций этих типов между собой?

Допустим, нужно создать кнопки разных размеров или тип outline кнопки, который можно комбинировать с основными типами. Как, например, задать стили для primary small кнопки с outline рамкой? Можно создать вариации в компоненте кнопки и попытаться кастомизировать там, но вся система становится очень хрупкой.

Заключение

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