Перейти к содержимому

Tailwind CSS глубоко: каскад, JIT и дизайн-токены

В краткой версии ты написал первые компоненты на Tailwind и почувствовал главное — скорость. Но скорость здесь следствие, а не причина. Причина в том, что Tailwind радикально меняет модель работы с каскадом: вместо того чтобы писать CSS и бороться с его природой, ты вообще отказываешься от большей части каскада. Эта глава — про механику этого отказа: почему BEM родился и умер, как Tailwind генерирует CSS из твоей разметки, где граница у @apply и что реально влияет на стилизационный перфоманс в браузере.

Продакшен-контекст: в проекте на 200+ компонентов классический CSS неизбежно превращается в либо «спагетти из переопределений», либо в жёсткий BEM с именами вида card__header-title--highlighted. Оба варианта работают, пока проект маленький. Когда команда растёт и дизайн меняется каждую неделю, стоимость изменения каждого селектора становится слишком высокой. Tailwind снижает эту стоимость до перестановки классов в разметке.

Утилитарный CSS против BEM: войны специфичности

Заголовок раздела «Утилитарный CSS против BEM: войны специфичности»

Браузер применяет стили в три этапа: каскад (порядок и источники), специфичность (вес селектора), наследование. Вес считается по тройке (ID, классы/псевдоклассы/атрибуты, элементы). Инлайн-стили и !important — отдельные плоскости, ломающие обычный порядок.

/* специфичность: (0, 1, 0) vs (0, 2, 1) — второе победит */
.card { color: black; } /* 0-1-0 */
.card .card__title span { color: red; } /* 0-2-1 */

Проблема в том, что в реальном проекте специфичность — это гонка вооружений. Кнопку внутри .card внутри .modal не переопределить без ещё более длинного селектора или !important. Каждый фикс повышает планку для следующего.

BEM (Блок, Элемент, Модификатор) — соглашение об именовании, цель которого — держать всю специфичность на уровне одного класса (0-1-0), чтобы побеждал просто порядок в файле:

/* Блок — независимая сущность */
.button { display: inline-flex; padding: 0.5rem 1rem; }
/* Элемент — часть блока, не существует вне его */
.button__icon { margin-right: 0.5rem; }
/* Модификатор — состояние или вариант */
.button--primary { background: #4f46e5; color: #fff; }

BEM работает, но цена — двойная бюрократия: имена классов длинные и хрупкие, а каждый новый элемент — это правка CSS-файла, тесты на регрессию визуала и риск «утечки» стилей блока в элементы. Есть и исторический слой: BEM родился в эпоху, когда CSS писали отдельные «верстальщики», а JS-программисты боялись трогать стили. Когда компонентный подход (React) стёр эту границу, отдельный слой именования потерял оправдание — стили стали частью компонента, а не его внешним контрактом. Исторически эстафету BEM подхватывали CSS-in-JS-библиотеки (styled-components, Emotion): они решали изоляцию через генерацию уникальных классов в рантайме, но платили за это ростом бандла, runtime-стоимостью и усложнением SSR. Tailwind пришёл к тому же результату (изоляция через одноразовые классы) без рантайма: в прод попадает обычный статический CSS.

Tailwind как капитуляция перед каскадом — и победа

Заголовок раздела «Tailwind как капитуляция перед каскадом — и победа»

Tailwind делает радикальную вещь: все утилиты имеют специфичность (0-1-0), конфликт решается порядком классов в сгенерированном CSS, а не в разметке, и ты пишешь стили прямо в компоненте:

// компонент целиком описан в разметке — отдельный CSS-файл не нужен
export function Button({ children }: Props) {
return (
<button className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:opacity-50">
{children}
</button>
);
}

Важно: конфликт px-4 против px-2 не решается тем, что «последний класс в className». Порядок определяется Tailwind’ом при генерации CSS (утилиты отсортированы по внутренней шкале: сначала layout, потом типографика, потом цвета). Управлять конфликтами помогает tailwind-merge:

// lib/utils.ts — стандартный cn() из shadcn
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
cn('px-4', 'px-2'); // → 'px-2' — merge знает шкалу утилит и оставляет последнюю

Классический CSS-фреймворк (Bootstrap) шипит сотни готовых классов, и ты тащишь весь CSS независимо от использования. Tailwind v3+ работает иначе — JIT-компиляция: он сканирует файлы из content, находит строки, похожие на утилиты, и генерирует только нужные правила.

// tailwind.config.ts — ключевая настройка
export default {
content: [
'./src/**/*.{ts,tsx,html}', // твой код
'./node_modules/@acme/ui/dist/**/*.js', // сторонний пакет с tailwind-классами
],
};

Как это работает на практике: запускаешь dev-сервер — Tailwind инкрементально пересобирает CSS при каждом сохранении файла, добавляя новые классы за миллисекунды. В продакшене собирается один минифицированный файл, обычно 8–15 КБ (gzip) для среднего приложения. Для сравнения: Bootstrap — ~30 КБ (gzip) до твоих стилей.

Три вещи, которые стоит знать про сканер:

  1. Он ищет целые строки. Утилита, собранная через конкатенацию 'p-' + size, не найдётся: 'p-4' — найдётся. Используй полные имена классов в исходниках.
  2. Safelisting. Если классы приходят из базы или пользовательского ввода, добавь их в safelist:
safelist: [
{ pattern: /^(bg|text)-(red|green|blue)-(100|700)$/ },
];
  1. Репортаж о размере. npx tailwindcss -i input.css -o output.css --minify покажет итоговый размер — сверяй его в CI, рост выше порога — сигнал добавить что-то лишнее в content.

Даже в Tailwind-проекте бывает нужен «обычный» CSS: базовые стили, стилизация сторонних библиотек, сложные кейфреймы. Для этого есть директивы:

globals.css
@tailwind base; /* reset + твои base-стили */
@tailwind components; /* «компонентные» классы через @apply */
@tailwind utilities; /* все утилиты — всегда последним слоем */
@layer base {
/* переопределяем базу аккуратно, без глобальных тегов-«бомб» */
html { @apply antialiased; }
body { @apply bg-slate-50 text-slate-900; }
}
@layer components {
/* редкий случай: повторяющийся паттерн, который не тянет на компонент */
.field {
@apply rounded-md border border-slate-300 px-3 py-2 text-sm
focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500;
}
}
@layer utilities {
/* кастомная утилита, участвующая в той же системе весов */
.scrollbar-thin { scrollbar-width: thin; }
}

@layer — это современный механизм каскада (Cascade Layers): стили в объявленном позднее слое бьют стили в более раннем слое независимо от специфичности. Tailwind использует его, чтобы утилиты всегда побеждали base/components.

@apply копирует объявления утилиты в твой класс. Осторожно: каждый @apply-класс — это дублирование CSS. Классическая ошибка — «BEM на Tailwind», когда весь проект переводят на @apply-обёртки и получают обратно специфичность, неудобные переопределения и бОльший бандл. Правило: если класс используется в одном компоненте — пиши утилиты в разметке; если паттерн повторяется в 5+ местах и не тянет на отдельный компонент — допустим @apply.

Дизайн-система проекта должна жить в конфиге, а не в произвольных #[ arbitrary values. Полный рабочий пример:

tailwind.config.ts
import type { Config } from 'tailwindcss';
import typography from '@tailwindcss/typography';
import forms from '@tailwindcss/forms';
import containerQueries from '@tailwindcss/container-queries';
export default {
content: ['./src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
// семантические токены, а не голые hex в разметке
brand: {
50: '#eef2ff', 100: '#e0e7ff', 500: '#6366f1',
600: '#4f46e5', 700: '#4338ca', 900: '#312e81',
DEFAULT: '#4f46e5',
},
surface: { DEFAULT: '#ffffff', muted: '#f8fafc' },
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['"JetBrains Mono"', 'ui-monospace', 'monospace'],
},
borderRadius: { xl: '0.875rem', '2xl': '1.25rem' },
boxShadow: {
card: '0 1px 2px rgb(15 23 42 / 0.06), 0 4px 12px rgb(15 23 42 / 0.06)',
},
keyframes: {
'fade-up': {
from: { opacity: '0', transform: 'translateY(8px)' },
to: { opacity: '1', transform: 'translateY(0)' },
},
},
animation: { 'fade-up': 'fade-up 300ms ease-out both' },
},
},
plugins: [typography, forms, containerQueries],
} satisfies Config;

Теперь в разметке — bg-brand-600 shadow-card animate-fade-up, и смена бренда — это правка одного файла.

  • @tailwindcss/typography — класс prose для длинного текста (статьи, документация, markdown-рендер). Без него будешь вручную стилизовать каждый h2, p, ul внутри контента.
  • @tailwindcss/forms — сброс дефолтных стилей форм до редактируемых. Без него кастомизация чекбоксов и селектов на разных платформах — боль.
  • @tailwindcss/container-queries — префиксы @md: для container queries (репозиторий плагина; подробно в главе CSS Modules, PostCSS и современный CSS).

Писать свой плагин — тоже нормально: это функция, добавляющая утилиты/компоненты через API plugin().

<!-- когда значения нет в шкале — без правки конфига -->
<div class="w-[357px] grid-cols-[1fr_2fr_auto] text-[#1a1a2e]">
<!-- ссылки на переменные и calc -->
<div class="h-[calc(100vh-4rem)] text-[length:var(--sidebar-width)]"></div>
<!-- произвольные варианты модификаторов -->
<div class="bg-red-500 [@supports(backdrop-filter:blur(1px))]:bg-red-500/50"></div>
</div>

Arbitrary values — инструмент для редких случаев. Если [#1a1a2e] встречается в пятом месте — это токен, его место в theme.extend.

Реальные проекты редко стартуют с чистого листа. Типовой сценарий: старый CSS на BEM, новые экраны — на Tailwind. Стратегия мирного сосуществования:

/* 1. Все legacy-стили — в собственный слой, раньше утилит */
@layer legacy {
@import './legacy/vendor.css';
@import './legacy/bem-blocks.css';
}
/* 2. База Tailwind не трогает существующие селекторы-«якоря» проекта */
@layer base {
:root { --brand: #4f46e5; } /* токены, на которые опирается старый код */
}

Три правила перехода: новые компоненты — только на утилитах (не размножай BEM дальше); legacy-правки делай минимальными и точечными; при рефакторинге блока переноси его целиком, а не «по классу за раз» — полураспиленный блок хуже любого из вариантов. Заодно это отвечает на вопрос собеседований «как бы ты внедрял Tailwind в существующий проект».

Что реально влияет на скорость стилизации:

  1. Размер CSS. Меньше CSS — быстрее загрузка и меньше работы CSSOM. JIT Tailwind решает это почти полностью.
  2. Сложность селекторов. Селектор вида .a .b .c:hover > .d — это матчинг по дереву на каждый кадр при стиле-инвалидации. Утилиты (0-1-0) дёшевы. Не создавай глубокую вложенность через PostCSS-nesting без нужды.
  3. Layout thrashing. CSS сам по себе не тормозит — тормозит чтение layout-параметров в JS после записи стилей. Не читай offsetHeight между style-изменениями.
  4. Animations. Анимируй только transform и opacity — они вынесены на композиторный поток и не вызывают layout/paint. Анимация width, top, box-shadow — работает на главном потоке.
  5. Containment. content-visibility: auto и contain: layout paint изолируют поддерево от пересчёта соседей — для длинных списков карточек даёт заметный выигрыш.
  1. Динамическая сборка имён классов. className={text-${color}-500} — сканер не найдёт классы, в проде стили пропадут. Хорошо: маппинг объектом const tones = { red: 'text-red-500', green: 'text-green-500' }.
  2. Конфликт классов без tailwind-merge. cn('px-4', props.className) с отсутствующим merge оставит оба класса, победит не тот. Всегда используй twMerge в cn().
  3. Перенос всего в @apply. Получаешь BEM с хуже поддержкой: специфичность выросла, бандл раздут, переиспользовать утилиты нельзя. @apply — исключение, не правило.
  4. Не туда положили сторонний пакет в content. Компонентная библиотека из node_modules, написанная на Tailwind, молча не стилизуется — её dist не входит в сканирование.
  5. !important повсюду. Tailwind имеет конфиг important: true, и есть префикс ! для отдельных утилит (!px-4). Использование для победы над сторонней библиотекой — признак того, что нужен @layer или более точечный селектор.
  6. Глобальные стили тегов вне @layer base. div { ... } без слоя побьёт утилиты в неожиданных местах. Всё глобальное — только в слои.
  1. Чем утилитарный CSS решает проблему специфичности? Все утилиты равны по весу (0-1-0), конфликт решается порядком в сгенерированном CSS через слои, а не наращиванием вложенности. Гонка вооружений заканчивается, потому что писать «более специфичный» селектор физически негде.
  2. Как Tailwind понимает, какие классы генерировать? Сканирует файлы из content регулярными выражениями, ищет полные имена классов, генерирует только найденное (JIT). Поэтому конкатенация имён ломает сборку.
  3. Когда @apply оправдан? Для повторяющегося паттерна без JSX-обвязки (стилизация стороннего виджета, базовые стили) и в @layer base/components. В компонентах — пиши утилиты в разметке.
  4. Что делает tailwind-merge и зачем он в cn()? Разрешает конфликты утилит по внутренней шкале Tailwind, оставляя последнюю: cn('px-2', 'px-4')px-4. Без него остаются оба класса и побеждает порядок в CSS.
  5. Как организовать дизайн-токены? В theme.extend: семантические имена (brand, surface), шкала отступов/радиусов из макета, тени и анимации. В разметке — только токены; arbitrary values — для разовых случаев.
  6. Почему нельзя анимировать width/top? Они вызывают layout и paint на главном потоке; transform и opacity — композиторные, работают без пересчёта раскладки и не бьют по INP.
  7. Что попадает в итоговый CSS при content: ['./src/**/*.tsx']? Только утилиты, чьи полные имена нашлись в файлах, + base + то, что добавлено через @layer/safelist. Всё остальное вырезается.
  1. Рефакторинг BEM-компонента. Возьми 3–4 BEM-блока из любого проекта и перепиши на утилиты в разметке. Критерий: отдельный CSS-файл удалён или сокращён до @layer base, визуальная регрессия — нулевая (сравни скриншотами).
  2. Токены проекта. Собери полный tailwind.config.ts под макет pet-проекта: палитра, типографика, радиусы, тени, две анимации. Критерий: ни одного hex-кода в JSX-файлах — проверь поиском.
  3. Свой плагин. Напиши плагин, добавляющий утилиты text-balance (text-wrap: balance) и scrollbar-thin. Критерий: утилиты работают с модификаторами (md:scrollbar-thin).
  4. Охота за размером. Собери CSS в prod-режиме, добавь неиспользуемый компонент с десятками новых утилит, собери снова и сравни размер. Критерий: объясни дельту и выведи размер в CI-пайплайн с порогом +15% к базовой линии.
  5. Анимации на композиторе. Реализуй модальное появление через opacity + transform: scale() и сравни с вариантом на width/height в DevTools → Performance. Критерий: запись показывает отсутствие Layout/Paint во время анимации.