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

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

В краткой версии ты уже почувствовал главное в Tailwind — скорость. Но скорость здесь следствие: Tailwind радикально меняет модель работы с каскадом — вместо борьбы с его природой ты отказываешься от большей его части. Эта глава — про механику отказа: почему BEM родился и умер, как v4 генерирует CSS из разметки, где теперь живут дизайн-токены без tailwind.config.js и что реально влияет на перфоманс стилей в браузере.

Продакшен-контекст: в проекте на 200+ компонентов классический CSS превращается в «спагетти из переопределений» или в жёсткий BEM. Когда команда растёт и дизайн меняется еженедельно, стоимость правки каждого селектора взлетает; 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-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 знает шкалу утилит и оставляет последнюю

В Tailwind v4 нет шага npx tailwindcss init и файла tailwind.config.js по умолчанию. Точка входа — обычный CSS-файл, а вся конфигурация живёт в нём же. Для Vite-проекта (React, Vue, SvelteKit) установка выглядит так:

Окно терминала
npm install tailwindcss @tailwindcss/vite
vite.config.ts
import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [tailwindcss()],
});
/* src/style.css — весь «конфиг» начинается с одной строки */
@import "tailwindcss";

Этот @import разворачивается в четыре слоя каскада: theme (переменные-токены), base (preflight — сброс стилей), components и utilities. Если сборщик не Vite, есть официальный PostCSS-плагин @tailwindcss/postcss и отдельный CLI-пакет @tailwindcss/cli — например, для сборки CSS вне бандлера:

Окно терминала
# postcss.config.mjs → plugins: { "@tailwindcss/postcss": {} }
# или напрямую через CLI:
npx @tailwindcss/cli -i src/style.css -o dist/out.css --minify

Под капотом: Oxide и автоматическое сканирование

Заголовок раздела «Под капотом: Oxide и автоматическое сканирование»

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

Главное отличие v4 — конфигурировать content больше не нужно. Сканер автоматически обходит проект от рабочей директории и пропускает: файлы из .gitignore, node_modules, бинарные файлы, CSS-файлы и lock-файлы пакетных менеджеров.

Явное управление сканированием нужно в трёх случаях:

@import "tailwindcss";
/* 1. Сторонний пакет с tailwind-классами (node_modules по умолчанию игнорируется) */
@source "../node_modules/@acme/ui";
/* 2. Исключить тяжёлую legacy-директорию из сканирования */
@source not "../src/components/legacy";
/* 3. Safelist: классы приходят из базы или пользовательского ввода —
генерируем принудительно, с вариантами и диапазонами (brace expansion) */
@source inline("{hover:,}bg-{red,green,blue}-{100,700}");

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

  1. Он ищет целые строки. Утилита, собранная через конкатенацию 'p-' + size, не найдётся: 'p-4' — найдётся. Сканер не исполняет и не парсит твой JS — он работает с текстом. Используй полные имена классов в исходниках.
  2. Базовый путь. В монорепе сборка может запускаться из корня, и автосканирование пойдёт не туда. Зафиксируй базу: @import "tailwindcss" source("../src"); — или отключи автосканирование полностью через source(none) и перечисли источники директивами @source.
  3. Репортаж о размере. Итоговый CSS сверяй в CI: рост выше порога — сигнал, что в сканирование попало что-то лишнее или кто-то затащил arbitrary values вместо токенов.

@layer, @utility и @apply: организация собственного CSS

Заголовок раздела «@layer, @utility и @apply: организация собственного CSS»

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

globals.css
@import "tailwindcss"; /* слои theme, base, components, 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;
}
}
/* кастомная утилита — полноправный гражданин фреймворка */
@utility scrollbar-thin {
scrollbar-width: thin;
}

Разница между @layer utilities и @utility принципиальная. Класс в @layer utilities — просто CSS-правило: варианты (hover:scrollbar-thin, md:scrollbar-thin) на нём не работают, во внутренней сортировке он не участвует. Директива @utility регистрирует утилиту в движке: варианты применяются автоматически, поддерживаются функциональные формы с аргументом через --value(). В v3 для этого писали JS-плагин с addUtilities — теперь хватает CSS.

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

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

Дизайн-система проекта должна жить в теме, а не в произвольных значениях в квадратных скобках. В v4 тема — это CSS-переменные в директиве @theme, и это не «настройка для генератора», а реальные custom properties, попадающие в итоговый CSS:

globals.css
@import "tailwindcss";
@theme {
/* семантические токены: bg-brand-600, text-brand-100 и т.д. */
--color-brand-50: #eef2ff;
--color-brand-100: #e0e7ff;
--color-brand-500: #6366f1;
--color-brand-600: #4f46e5;
--color-brand-700: #4338ca;
--color-brand-900: #312e81;
--color-surface: #ffffff;
--color-surface-muted: #f8fafc;
--font-sans: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
--radius-card: 0.875rem;
--shadow-card: 0 1px 2px rgb(15 23 42 / 0.06), 0 4px 12px rgb(15 23 42 / 0.06);
/* кейфреймы объявляются прямо внутри @theme */
--animate-fade-up: fade-up 300ms ease-out both;
@keyframes fade-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
}

Теперь в разметке — bg-brand-600 shadow-card animate-fade-up rounded-card, и смена бренда — правка одного файла. Пространства имён предсказуемы: --color-* порождает цветовые утилиты, --font-* — семейства, --breakpoint-* — адаптивные варианты, --animate-* — анимации. Переопределение симметрично: --breakpoint-sm: 30rem; сдвинет вариант sm:, а --color-*: initial; удалит дефолтную палитру целиком, оставив только твою.

Два следствия, которые меняют повседневную работу:

  1. Токены доступны как обычные переменные. Скомпилированный CSS содержит :root { --color-brand-600: ... }, поэтому токены можно использовать в произвольном CSS, инлайн-стилях и JS (getComputedStyle), и в arbitrary values: rounded-[calc(var(--radius-card)-1px)].
  2. Отступы стали динамическими. В v4 вся spacing-шкала вычисляется от одной переменной --spacing: 0.25rem: любое кратное значение (mt-13, w-17, p-4.5) валидно без расширения темы. Захардкоженная шкала «0, 1, 1.5, 2…» осталась в v3.

Из коробки вариант dark: в v4 работает через prefers-color-scheme — без всякой настройки. Для ручного переключателя темы (класс на html или data-атрибут) вариант переопределяется одной строкой:

@import "tailwindcss";
/* dark: срабатывает по классу выше по дереву */
@custom-variant dark (&:where(.dark, .dark *));
/* альтернатива — по data-атрибуту */
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
<html class="dark">
<body class="bg-white text-slate-900 dark:bg-slate-950 dark:text-slate-100">
<!-- ... -->
</body>
</html>

Сам переключатель — обычный JS, который тогглит класс на document.documentElement и синкает выбор в localStorage (с фолбэком на matchMedia('(prefers-color-scheme: dark)') для трёхпозиционной схемы «светлая / тёмная / системная»). Inline-скрипт в head выполняй до первой отрисовки, иначе поймаешь FOUC — белую вспышку перед тёмной темой.

Плагины в v4 подключаются из CSS директивой @plugin:

@import "tailwindcss";
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
  • @tailwindcss/typography — класс prose для длинного текста (статьи, документация, markdown-рендер). Без него будешь вручную стилизовать каждый h2, p, ul внутри контента.
  • @tailwindcss/forms — сброс дефолтных стилей форм до редактируемых. Без него кастомизация чекбоксов и селектов на разных платформах — боль.
  • Container queries — больше не плагин. В v4 они в ядре: помечаешь родителя классом @container и используешь варианты @sm:, @md: (и @max-md: для обратного условия):
<!-- карточка перестраивается от ширины контейнера, а не вьюпорта -->
<div class="@container">
<article class="flex flex-col gap-2 @md:flex-row @md:gap-6">
<img class="w-full @md:w-48" src="cover.jpg" alt="" />
<div class="min-w-0"><!-- текст --></div>
</article>
</div>

Подробно про механику container queries — в главе CSS Modules, PostCSS и современный CSS. Свой «плагин» в v4 чаще всего вообще не нужен: связка @theme + @utility + @custom-variant покрывает то, для чего в v3 писали JS-плагины, — и остаётся декларативной.

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

В v4 для ссылки на CSS-переменную есть короткая форма: bg-(--brand-color) эквивалентно bg-[var(--brand-color)], а подсказка типа пишется в тех же скобках — text-(length:--size) против text-(color:--tone). Пробелы внутри значения заменяй подчёркиваниями (grid-cols-[1fr_2fr_auto] развернётся с пробелами).

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

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

/* 1. Все legacy-стили — в собственный слой, раньше утилит */
@layer legacy {
@import './legacy/vendor.css';
@import './legacy/bem-blocks.css';
}
/* 2. Новый код — на утилитах; если совсем без JS-конфига никак,
на переходный период можно подтянуть старый: */
/* @config "../tailwind.config.js"; */

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

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

  1. Размер CSS. Меньше CSS — быстрее загрузка и меньше работы CSSOM. Сканирование Tailwind решает это почти полностью.
  2. Сложность селекторов. Селектор вида .a .b .c:hover > .d — это матчинг по дереву на каждой инвалидации стилей. Утилиты (0-1-0) дёшевы. Не создавай глубокую вложенность через CSS-nesting без нужды.
  3. Layout thrashing. CSS сам по себе не тормозит — тормозит чтение layout-параметров в JS после записи стилей. Не читай offsetHeight между style-изменениями.
  4. Animations. Анимируй только transform и opacity — они вынесены на композиторный поток и не вызывают layout/paint. Анимация width, top, box-shadow работает на главном потоке.
  5. Containment. content-visibility: auto (утилита content-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. Сторонний пакет не в сканировании. Компонентная библиотека из node_modules, написанная на Tailwind, молча не стилизуется — node_modules игнорируются по умолчанию. Лечится директивой @source "../node_modules/@acme/ui";.
  5. !important повсюду. В v4 важность помечается суффиксом: px-4! (в v3 был префикс !px-4). Использование для победы над сторонней библиотекой — признак того, что нужен @layer или более точечный селектор.
  6. Глобальные стили тегов вне @layer base. div { ... } без слоя побьёт утилиты в неожиданных местах. Всё глобальное — только в слои.
  7. Класс в @layer utilities вместо @utility. Сам по себе работать будет, но hover: и md: на нём молча не сгенерируются — варианты даёт только регистрация через @utility.
  1. Чем утилитарный CSS решает проблему специфичности? Все утилиты равны по весу (0-1-0) и лежат в одном слое каскада, конфликт решается порядком в сгенерированном CSS, а не наращиванием вложенности. Гонка вооружений заканчивается, потому что писать «более специфичный» селектор физически негде.
  2. Как Tailwind понимает, какие классы генерировать? Сканирует исходники как текст, ищет полные имена классов, генерирует только найденное. В v4 список файлов определяется автоматически (с учётом .gitignore и node_modules), а расширяется директивами @source и @source inline(). Поэтому конкатенация имён ломает сборку.
  3. Что такое @theme и чем он заменил tailwind.config.js? Директива, объявляющая дизайн-токены как CSS-переменные в пространствах имён (--color-*, --font-*, --breakpoint-*). Каждая переменная порождает семейство утилит и одновременно попадает в :root итогового CSS. JS-конфиг по умолчанию больше не нужен.
  4. Когда @apply оправдан? Для повторяющегося паттерна без JSX-обвязки (стилизация стороннего виджета, базовые стили) и в @layer base/components. В компонентах — пиши утилиты в разметке.
  5. Что делает tailwind-merge и зачем он в cn()? Разрешает конфликты утилит по внутренней шкале Tailwind, оставляя последнюю: cn('px-2', 'px-4') → px-4. Без него остаются оба класса и побеждает порядок в CSS.
  6. Чем @utility отличается от класса в @layer utilities? @utility регистрирует утилиту в движке: работают варианты (hover:, md:), правильная сортировка, функциональные формы с аргументом. Класс в слое — просто CSS-правило без всего этого.
  7. Как включить ручной dark mode в v4? Переопределить вариант: @custom-variant dark (&:where(.dark, .dark *)); — и тогглить класс на html из JS. По умолчанию dark: следует prefers-color-scheme.
  8. Почему нельзя анимировать width/top? Они вызывают layout и paint на главном потоке; transform и opacity — композиторные, работают без пересчёта раскладки и не бьют по INP.
  1. Рефакторинг BEM-компонента. Возьми 3–4 BEM-блока из любого проекта и перепиши на утилиты в разметке. Критерий: отдельный CSS-файл удалён или сокращён до @layer base, визуальная регрессия — нулевая (сравни скриншотами).
  2. Токены проекта. Собери @theme под макет pet-проекта: палитра, типографика, радиусы, тени, две анимации с кейфреймами внутри @theme. Критерий: ни одного hex-кода в JSX-файлах — проверь поиском; все токены видны в DevTools как CSS-переменные на :root.
  3. Свои утилиты. Добавь через @utility утилиты scrollbar-thin и content-auto. Критерий: обе работают с вариантами (md:scrollbar-thin, hover:scrollbar-thin) без единой строчки JS.
  4. Охота за размером. Собери CSS в prod-режиме (npx @tailwindcss/cli -i src/style.css -o out.css --minify), добавь неиспользуемый компонент с десятками новых утилит, собери снова и сравни размер. Критерий: объясни дельту и выведи размер в CI-пайплайн с порогом +15% к базовой линии.
  5. Анимации на композиторе. Реализуй модальное появление через opacity + transform: scale() (своя анимация --animate-*) и сравни с вариантом на width/height в DevTools → Performance. Критерий: запись показывает отсутствие Layout/Paint во время анимации.