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 как перемирие
Заголовок раздела «BEM как перемирие»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() из shadcnimport { 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 знает шкалу утилит и оставляет последнююУстановка и интеграция: v4 — CSS-first
Заголовок раздела «Установка и интеграция: v4 — CSS-first»В Tailwind v4 нет шага npx tailwindcss init и файла tailwind.config.js по умолчанию. Точка входа — обычный CSS-файл, а вся конфигурация живёт в нём же. Для Vite-проекта (React, Vue, SvelteKit) установка выглядит так:
npm install tailwindcss @tailwindcss/viteimport { 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}");Три вещи, которые стоит знать про сканер:
- Он ищет целые строки. Утилита, собранная через конкатенацию
'p-' + size, не найдётся:'p-4'— найдётся. Сканер не исполняет и не парсит твой JS — он работает с текстом. Используй полные имена классов в исходниках. - Базовый путь. В монорепе сборка может запускаться из корня, и автосканирование пойдёт не туда. Зафиксируй базу:
@import "tailwindcss" source("../src");— или отключи автосканирование полностью черезsource(none)и перечисли источники директивами@source. - Репортаж о размере. Итоговый CSS сверяй в CI: рост выше порога — сигнал, что в сканирование попало что-то лишнее или кто-то затащил arbitrary values вместо токенов.
@layer, @utility и @apply: организация собственного CSS
Заголовок раздела «@layer, @utility и @apply: организация собственного CSS»Даже в Tailwind-проекте бывает нужен «обычный» CSS: базовые стили, стилизация сторонних библиотек, сложные кейфреймы. В v4 иерархия выглядит так:
@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 вреден»@apply копирует объявления утилиты в твой класс. Осторожно: каждый @apply-класс — это дублирование CSS. Классическая ошибка — «BEM на Tailwind», когда весь проект переводят на @apply-обёртки и получают обратно специфичность, неудобные переопределения и бОльший бандл. Правило: если класс используется в одном компоненте — пиши утилиты в разметке; если паттерн повторяется в 5+ местах и не тянет на отдельный компонент — допустим @apply.
Дизайн-токены через @theme
Заголовок раздела «Дизайн-токены через @theme»Дизайн-система проекта должна жить в теме, а не в произвольных значениях в квадратных скобках. В v4 тема — это CSS-переменные в директиве @theme, и это не «настройка для генератора», а реальные custom properties, попадающие в итоговый 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; удалит дефолтную палитру целиком, оставив только твою.
Два следствия, которые меняют повседневную работу:
- Токены доступны как обычные переменные. Скомпилированный CSS содержит
:root { --color-brand-600: ... }, поэтому токены можно использовать в произвольном CSS, инлайн-стилях и JS (getComputedStyle), и в arbitrary values:rounded-[calc(var(--radius-card)-1px)]. - Отступы стали динамическими. В v4 вся spacing-шкала вычисляется от одной переменной
--spacing: 0.25rem: любое кратное значение (mt-13,w-17,p-4.5) валидно без расширения темы. Захардкоженная шкала «0, 1, 1.5, 2…» осталась в v3.
Dark mode: custom variant
Заголовок раздела «Dark mode: custom variant»Из коробки вариант 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 — белую вспышку перед тёмной темой.
Плагины и container queries
Заголовок раздела «Плагины и container queries»Плагины в 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-плагины, — и остаётся декларативной.
Arbitrary values: выходной клапан
Заголовок раздела «Arbitrary values: выходной клапан»<!-- когда значения нет в шкале — без правки темы --><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.
Миграция: Tailwind рядом с legacy CSS
Заголовок раздела «Миграция: Tailwind рядом с legacy CSS»Реальные проекты редко стартуют с чистого листа: старый CSS на BEM, новые экраны — на Tailwind. Стратегия мирного сосуществования:
/* 1. Все legacy-стили — в собственный слой, раньше утилит */@layer legacy { @import './legacy/vendor.css'; @import './legacy/bem-blocks.css';}
/* 2. Новый код — на утилитах; если совсем без JS-конфига никак, на переходный период можно подтянуть старый: *//* @config "../tailwind.config.js"; */Три правила перехода: новые компоненты — только на утилитах (не размножай BEM дальше); legacy-правки делай минимальными и точечными; при рефакторинге блока переноси его целиком, а не «по классу за раз» — полураспиленный блок хуже любого из вариантов.
Честный CSS-перфоманс
Заголовок раздела «Честный CSS-перфоманс»Что реально влияет на скорость стилизации:
- Размер CSS. Меньше CSS — быстрее загрузка и меньше работы CSSOM. Сканирование Tailwind решает это почти полностью.
- Сложность селекторов. Селектор вида
.a .b .c:hover > .d— это матчинг по дереву на каждой инвалидации стилей. Утилиты (0-1-0) дёшевы. Не создавай глубокую вложенность через CSS-nesting без нужды. - Layout thrashing. CSS сам по себе не тормозит — тормозит чтение layout-параметров в JS после записи стилей. Не читай
offsetHeightмеждуstyle-изменениями. - Animations. Анимируй только
transformиopacity— они вынесены на композиторный поток и не вызывают layout/paint. Анимацияwidth,top,box-shadowработает на главном потоке. - Containment.
content-visibility: auto(утилитаcontent-auto) иcontain: layout paintизолируют поддерево от пересчёта соседей — для длинных списков карточек даёт заметный выигрыш.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»- Динамическая сборка имён классов.
className={'text-' + color + '-500'}— сканер не найдёт классы, в проде стили пропадут. Хорошо: маппинг объектомconst tones = { red: 'text-red-500', green: 'text-green-500' }. - Конфликт классов без
tailwind-merge.cn('px-4', props.className)без merge оставит оба класса, победит не тот. Всегда используйtwMergeвcn(). - Перенос всего в
@apply. Получаешь BEM с худшей поддержкой: специфичность выросла, бандл раздут, варианты не работают.@apply— исключение, не правило. - Сторонний пакет не в сканировании. Компонентная библиотека из
node_modules, написанная на Tailwind, молча не стилизуется —node_modulesигнорируются по умолчанию. Лечится директивой@source "../node_modules/@acme/ui";. !importantповсюду. В v4 важность помечается суффиксом:px-4!(в v3 был префикс!px-4). Использование для победы над сторонней библиотекой — признак того, что нужен@layerили более точечный селектор.- Глобальные стили тегов вне
@layer base.div { ... }без слоя побьёт утилиты в неожиданных местах. Всё глобальное — только в слои. - Класс в
@layer utilitiesвместо@utility. Сам по себе работать будет, ноhover:иmd:на нём молча не сгенерируются — варианты даёт только регистрация через@utility.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Чем утилитарный CSS решает проблему специфичности? Все утилиты равны по весу (0-1-0) и лежат в одном слое каскада, конфликт решается порядком в сгенерированном CSS, а не наращиванием вложенности. Гонка вооружений заканчивается, потому что писать «более специфичный» селектор физически негде.
- Как Tailwind понимает, какие классы генерировать? Сканирует исходники как текст, ищет полные имена классов, генерирует только найденное. В v4 список файлов определяется автоматически (с учётом
.gitignoreиnode_modules), а расширяется директивами@sourceи@source inline(). Поэтому конкатенация имён ломает сборку. - Что такое
@themeи чем он заменилtailwind.config.js? Директива, объявляющая дизайн-токены как CSS-переменные в пространствах имён (--color-*,--font-*,--breakpoint-*). Каждая переменная порождает семейство утилит и одновременно попадает в:rootитогового CSS. JS-конфиг по умолчанию больше не нужен. - Когда
@applyоправдан? Для повторяющегося паттерна без JSX-обвязки (стилизация стороннего виджета, базовые стили) и в@layer base/components. В компонентах — пиши утилиты в разметке. - Что делает
tailwind-mergeи зачем он вcn()? Разрешает конфликты утилит по внутренней шкале Tailwind, оставляя последнюю:cn('px-2', 'px-4')→px-4. Без него остаются оба класса и побеждает порядок в CSS. - Чем
@utilityотличается от класса в@layer utilities?@utilityрегистрирует утилиту в движке: работают варианты (hover:,md:), правильная сортировка, функциональные формы с аргументом. Класс в слое — просто CSS-правило без всего этого. - Как включить ручной dark mode в v4? Переопределить вариант:
@custom-variant dark (&:where(.dark, .dark *));— и тогглить класс наhtmlиз JS. По умолчаниюdark:следуетprefers-color-scheme. - Почему нельзя анимировать
width/top? Они вызывают layout и paint на главном потоке;transformиopacity— композиторные, работают без пересчёта раскладки и не бьют по INP.
Практика
Заголовок раздела «Практика»- Рефакторинг BEM-компонента. Возьми 3–4 BEM-блока из любого проекта и перепиши на утилиты в разметке. Критерий: отдельный CSS-файл удалён или сокращён до
@layer base, визуальная регрессия — нулевая (сравни скриншотами). - Токены проекта. Собери
@themeпод макет pet-проекта: палитра, типографика, радиусы, тени, две анимации с кейфреймами внутри@theme. Критерий: ни одного hex-кода в JSX-файлах — проверь поиском; все токены видны в DevTools как CSS-переменные на:root. - Свои утилиты. Добавь через
@utilityутилитыscrollbar-thinиcontent-auto. Критерий: обе работают с вариантами (md:scrollbar-thin,hover:scrollbar-thin) без единой строчки JS. - Охота за размером. Собери CSS в prod-режиме (
npx @tailwindcss/cli -i src/style.css -o out.css --minify), добавь неиспользуемый компонент с десятками новых утилит, собери снова и сравни размер. Критерий: объясни дельту и выведи размер в CI-пайплайн с порогом +15% к базовой линии. - Анимации на композиторе. Реализуй модальное появление через
opacity+transform: scale()(своя анимация--animate-*) и сравни с вариантом наwidth/heightв DevTools → Performance. Критерий: запись показывает отсутствие Layout/Paint во время анимации.
Что почитать
Заголовок раздела «Что почитать»- Tailwind CSS v4: документация и анонс v4 с разбором Oxide
- Tailwind CSS: theme variables и @theme
- Tailwind CSS: сканирование исходников и @source
- Tailwind CSS: кастомные стили, @utility и @custom-variant
- Tailwind CSS: dark mode
- MDN: Cascade and inheritance
- CSS-Tricks: Cascade Layers
- tailwind-merge: почему и как
- web.dev: content-visibility и contain