Архитектура стилей: БЭМ, CSS-modules, CSS-in-JS, Tailwind, дизайн-системы
В предыдущей статье мы разобрали раскладку. Но вёрстка одного экрана и вёрстка продукта на 400 экранов — разные инженерные задачи. Второе ломается не из-за flexbox, а из-за того, что через год никто не может ответить: «если я удалю этот класс, что сломается?». Архитектура стилей — набор ограничений, делающий ответ дешёвым. Правильного решения тут нет: есть подходы с разными ценами, и цена платится в разных валютах — байтами, временем сборки, миллисекундами в главном потоке, скоростью онбординга.
Корень проблемы: CSS — одно глобальное пространство имён
Любой селектор в любом файле применяется ко всему документу. Это не баг, а проектное решение: CSS создавался, чтобы одна таблица оформляла сайт целиком. Следствий четыре, и весь зоопарк методологий вырос именно из них.
Коллизии имён. Два разработчика независимо пишут .card; выигрывает тот, чей файл ниже. Симптом — «поправил профиль, поехала корзина».
Гонка специфичности. Чтобы перебить чужое правило, вы пишете селектор длиннее. Коллега — ещё длиннее. Через год живёт body.checkout .sidebar ul.menu > li a.link--active и десяток !important. Как считается специфичность — в основах CSS; здесь важно, что она необратима: понизить её нельзя, только повышать.
Мёртвый код. Статически доказать, что класс не используется, почти нельзя: имя могло собираться конкатенацией строк. Поэтому CSS только растёт — доля неиспользуемых правил на конкретной странице обычно 60–90 %, проверьте вкладкой Coverage.
Нелокальность. Чтобы понять, почему у кнопки такой отступ, надо держать в голове проект, а не открытый файл.
Дальнейшие подходы решают ровно эти четыре пункта, отличаясь тем, на каком этапе: договором между людьми (БЭМ), сборкой (CSS Modules, Tailwind), рантаймом (styled-components) или самой платформой (@layer, @scope, Shadow DOM).
Семь осей, по которым честно сравнивать подходы
| Критерий | Что означает | Почему важно |
|---|---|---|
| Изоляция | Может ли стиль утечь наружу | Защита от регрессий |
| Цена в рантайме | Работа JS в браузере ради стилей | Влияет на INP и TBT |
| Цена в сборке | Холодный старт и HMR | DX и стоимость CI |
| Размер CSS | Байты по сети | Влияет на LCP |
| Удаляемость | Легко ли доказать, что код мёртв | Борьба с энтропией |
| Динамика | Стилизация от пропсов и данных | Графики, темы, пользовательские цвета |
| Читаемость в DevTools | Что видно при инспектировании | Скорость отладки |
БЭМ: дисциплина имён вместо инструмента
БЭМ (Блок — Элемент — Модификатор) решает коллизии соглашением: имя класса уникально, потому что содержит имя блока. Три правила делают его работающим — и их же чаще всего нарушают.
- Только одноуровневые селекторы. Никаких
.card .card__title: один класс = специфичность0,1,0у всего проекта, и правила перебиваются порядком, а не гонкой. - Блок не задаёт свои внешние отступы.
marginставит родитель через собственный элемент (.list__item). Иначе блок нельзя переиспользовать в другом окружении. - Элемент не вкладывается в элемент.
.card__footer__buttonзапрещён: это либо.card__button, либо отдельный блок.buttonвнутри карточки (микс).
.card {
--card-pad: 16px;
display: grid; gap: 12px; padding: var(--card-pad);
/* & собирает .card__title, а не .card .card__title — специфичность не растёт */
&__title { font-size: 1.125rem; font-weight: 600; }
&--featured { --card-pad: 24px; outline: 2px solid var(--color-accent); }
/* ПЛОХО: даёт .card--featured .card__title, специфичность 0,2,0 */
/* &--featured &__title { color: red; } */
&__title--accent { color: var(--color-accent); } /* ХОРОШО: модификатор элемента */
}
Где выигрывает. Проекты без сборщика (серверный HTML на Django, Rails, Go-шаблонах), вёрстка внутри CMS, email, встраиваемые виджеты. БЭМ ничего не требует от инфраструктуры — это чистое соглашение, и в этом его сила.
Где ломается. Соглашение держится на людях: один новичок и один дедлайн — и появляется .card .title. Автоматической проверки удаляемости нет: удалить блок из HTML легко, удалить его CSS никто не вспомнит. stylelint-selector-bem-pattern помогает, но не спасает.
ITCSS и @layer: порядок каскада как явная архитектура
Вторая половина проблемы — не имена, а порядок. Даже с идеальным БЭМ вопрос «кто кого перебивает» решается физическим порядком импортов. ITCSS предложил раскладывать CSS слоями от общего к конкретному; сегодня эту идею можно выразить прямо в языке.
/* Одна строка задаёт приоритет НАВСЕГДА: порядок объявления слоёв
важнее и порядка загрузки файлов, и специфичности внутри них. */
@layer tokens, reset, base, layout, components, utilities;
@layer components { .page .sidebar .button { background: steelblue; } } /* 0,3,0 */
@layer utilities { .bg-transparent { background: transparent; } } /* 0,1,0 — победит */
/* Лучший способ приручить чужой CSS: он больше не спорит с вашим кодом */
@import url("bootstrap.css") layer(vendor);
/* @scope — нативное ограничение области с «дыркой»: не проникает во вложенные виджеты */
@scope (.article) to (.widget) {
a { color: var(--color-accent); text-decoration-thickness: 2px; }
}
Ключевое: слой сильнее специфичности. Правило из позднего слоя побеждает правило из раннего при любых селекторах. Стили вне слоёв сильнее любого слоя — поэтому «legacy в слой, новое без слоя» работает как стратегия миграции (MDN про @layer). @scope есть во всех актуальных браузерах, но для публичных сайтов сверяйтесь с caniuse — пока это зона прогрессивного улучшения.
CSS Modules: изоляцию делает сборщик
Минимальная надстройка: обычный CSS-файл, имена классов в котором сборщик делает уникальными, отдавая в JS объект-маппинг.
/* Button.module.css */
.root {
display: inline-flex; align-items: center; gap: 8px;
padding: 8px 16px; border: 0; border-radius: var(--radius-control);
font: inherit; cursor: pointer;
}
.primary { background: var(--color-accent); color: var(--color-on-accent); }
.ghost { background: transparent; box-shadow: inset 0 0 0 1px currentColor; }
.danger { composes: root; background: var(--color-danger); color: #fff; } /* без дублей в бандле */
:global(.tippy-box) .root { margin: 0; } /* аварийный выход к внешним классам */
// Button.tsx — styles.root превращается в "Button__root__a1b2" после сборки
import styles from './Button.module.css';
import clsx from 'clsx';
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'primary' | 'ghost' | 'danger' };
export function Button({ variant = 'primary', className, ...rest }: Props) {
return <button className={clsx(styles.root, styles[variant], className)} {...rest} />;
}
// vite.config.ts — читаемые имена в деве, короткий хеш в проде
export default defineConfig({
css: {
modules: {
generateScopedName: process.env.NODE_ENV === 'production'
? '[hash:base64:6]' : '[name]__[local]__[hash:base64:4]',
localsConvention: 'camelCaseOnly', // .my-class -> styles.myClass
},
devSourcemap: true,
},
});
Обязательно типизируйте: без этого styles.rooot молча вернёт undefined, и класс просто не применится. Плагин typescript-plugin-css-modules для редактора плюс генерация .d.ts в CI (npx tcm src --pattern '**/*.module.css' и git diff --exit-code) закрывают дыру. Подробнее о сборке — в статье про тулинг.
Плюсы. Ноль рантайма; работает весь CSS (@media, @container, @supports, вложенность); удаляемость тривиальна — удалили компонент вместе с его .module.css. Идеально дружит с SSR и серверными компонентами. Минусы. Динамика только через переменные (что обычно и правильно: <div className={styles.bar} style={{ '--bar-width': pct + '%' } as React.CSSProperties} />) и неудобство «перекрасить чужой компонент снаружи» — приходится пробрасывать className и полагаться на порядок слоёв.
CSS-in-JS: два принципиально разных зверя под одним именем
Термин объединяет технологии с несопоставимой ценой; различайте их всегда. Рантайм-библиотеки (styled-components, Emotion) на каждом рендере интерполируют пропсы в строку стиля, хешируют её и, если такого стиля ещё нет, вставляют правило в CSSOM через insertRule.
import styled from 'styled-components';
// Каждое новое значение $level даёт новый хеш и новое правило в документе
const Bar = styled.div<{ $level: number }>`
block-size: 8px; border-radius: 4px;
inline-size: ${(p) => p.$level * 100}%;
background: ${(p) => (p.$level > 0.8 ? 'crimson' : 'seagreen')};
`;
Ветка «стиль новый» выполняется в главном потоке, синхронно, внутри React-рендера. На списке из 500 строк с уникальными значениями это тысячи вставок правил и постоянная инвалидация. Отсюда две практические проблемы: INP страдает, если стиль считается в обработчике ввода, и несовместимость с React Server Components — рантайму нужны контекст и useInsertionEffect, значит компонент обязан быть клиентским. Именно это в 2023-м сделало styled-components архитектурным тупиком для новых приложений на App Router; в 2025-м проект официально перешёл в режим поддержки без развития.
Zero-runtime (vanilla-extract, Linaria, Panda CSS, StyleX) даёт тот же авторский опыт — стили в TypeScript, типизированные токены, — но извлекает CSS на этапе сборки: в браузер приезжает статический файл.
// theme.css.ts — типизированный контракт темы
import { createGlobalTheme } from '@vanilla-extract/css';
export const vars = createGlobalTheme(':root', {
color: { accent: 'oklch(0.55 0.19 258)', surface: '#fff', text: '#0f172a' },
space: { sm: '8px', md: '16px' }, radius: { control: '6px' },
});
// Button.css.ts — превращается в статический CSS при сборке
import { style, styleVariants } from '@vanilla-extract/css';
import { vars } from './theme.css';
export const base = style({
display: 'inline-flex', gap: vars.space.sm, border: 0, cursor: 'pointer',
padding: `${vars.space.sm} ${vars.space.md}`, borderRadius: vars.radius.control,
selectors: { '&:disabled': { opacity: 0.5, cursor: 'not-allowed' } },
'@media': { '(prefers-reduced-motion: no-preference)': { transition: 'background 120ms' } },
});
export const variant = styleVariants({
primary: [base, { background: vars.color.accent, color: '#fff' }],
ghost: [base, { background: 'transparent', boxShadow: 'inset 0 0 0 1px currentColor' }],
});
Опечатка vars.color.acent теперь ошибка компиляции — ради этого и подключают типы к стилям (о самом языке — в треке TypeScript). Цена: сложнее сборка и медленнее холодный старт, а по-настоящему динамические значения всё равно уходят в CSS-переменные через createVar(). Размен честный: сложность переезжает из браузера пользователя в вашу CI-машину.
Tailwind: ограничения важнее утилит
Про Tailwind спорят «читаемо или нет», но суть в другом: он заменяет открытое множество значений закрытым. Вместо «любой отступ» — шкала из 12 шагов, вместо любого цвета — палитра. Ровно то, что делает хорошая дизайн-система, только принудительно.
<button class="inline-flex items-center gap-2 rounded-md bg-brand-600 px-4 py-2 text-sm
font-medium text-white transition hover:bg-brand-700 focus-visible:outline-2
disabled:pointer-events-none disabled:opacity-50">Сохранить</button>
Четыре реальных преимущества: CSS перестаёт расти (утилиты переиспользуются, файл выходит на плато в 10–20 КБ, 5–8 КБ после gzip, почти независимо от числа экранов); удаляемость идеальна (генератор сканирует исходники, невстреченное просто не выводится); состояния и адаптив без придумывания имён (hover:, focus-visible:, md:, dark:, group-hover:, has-[:checked]: покрывают то, ради чего в БЭМ плодят модификаторы); нет налога на именование. В v4 конфиг живёт прямо в CSS:
@import "tailwindcss";
@theme {
--color-brand-600: oklch(0.55 0.19 258);
--color-brand-700: oklch(0.48 0.19 258);
--font-display: "Inter Variable", system-ui, sans-serif;
--radius-control: 6px;
--breakpoint-3xl: 120rem;
}
/* Собственные утилиты — тем же механизмом, что и встроенные */
@utility text-pretty-hyphens { text-wrap: pretty; hyphens: auto; }
Честные минусы. Разметка визуально шумная, diff в ревью читается хуже, правка «во всех карточках padding 20 вместо 16» требует поиска по разметке. И главный антипаттерн — @apply: .btn { @apply inline-flex rounded-md bg-brand-600 px-4 py-2 } возвращает вас к именованию, оставляя оба мира худшей стороной. Если хочется @apply — нужен компонент: абстракция во фронтенде живёт в компонентах, а не в классах.
Варианты компонентов: cva + tailwind-merge
Как только у кнопки появляются 3 размера и 4 вида, наивная склейка строк ломается: px-2 из пропса и px-4 из базы конфликтуют, а победит тот, что ниже в CSS, а не в строке.
// lib/cn.ts — единственная правильная склейка: twMerge знает семантику утилит
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
// Button.tsx — варианты как данные, а не как ветвление в JSX
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from './lib/cn';
const button = cva(
'inline-flex items-center justify-center gap-2 rounded-control font-medium ' +
'transition-colors focus-visible:outline-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
intent: {
primary: 'bg-brand-600 text-white hover:bg-brand-700',
ghost: 'bg-transparent text-brand-700 hover:bg-brand-50',
danger: 'bg-red-600 text-white hover:bg-red-700',
},
size: { sm: 'h-8 px-3 text-sm', md: 'h-10 px-4 text-sm', lg: 'h-12 px-6 text-base' },
block: { true: 'w-full' },
},
compoundVariants: [{ intent: 'ghost', size: 'lg', class: 'px-5' }],
defaultVariants: { intent: 'primary', size: 'md' },
},
);
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof button>;
export function Button({ intent, size, block, className, ...rest }: ButtonProps) {
return <button className={cn(button({ intent, size, block }), className)} {...rest} />;
}
cva — просто генератор строк классов, его спокойно используют и с CSS Modules. Ключевая идея: варианты описаны декларативно и типизированы, а не размазаны по тернарникам. Отдельное правило — поведение и стили разделяйте. Логику доступных попапов, меню и диалогов берите готовой (Radix Primitives, React Aria, Headless UI), стили пишите свои; почему самописный <div role="dialog"> почти всегда сломан, разобрано в гиде по доступности.
Дизайн-токены: слой, который переживёт смену фреймворка
Дизайн-система — это не библиотека компонентов, а договор о значениях. Компоненты вы перепишете при смене фреймворка, токены — нет.
@layer tokens {
:root {
/* Слой 1: примитивы — меняются только при ребрендинге */
--blue-600: oklch(0.55 0.19 258); --gray-50: oklch(0.98 0.002 250);
--gray-950: oklch(0.17 0.01 260); --space-4: 1rem; --radius-2: 6px;
/* Слой 2: семантика — здесь и только здесь живёт тема */
--color-accent: var(--blue-600); --color-surface: var(--gray-50);
--color-text: var(--gray-950); --radius-control: var(--radius-2);
color-scheme: light dark;
}
/* Тёмная тема переопределяет ТОЛЬКО семантический слой */
[data-theme="dark"] {
--color-surface: var(--gray-950); --color-text: var(--gray-50);
--color-accent: oklch(0.72 0.15 258); /* светлее ради контраста на тёмном */
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --color-surface: var(--gray-950); --color-text: var(--gray-50); }
}
}
Три практических правила. Компонент никогда не ссылается на примитив: background: var(--blue-600) в кнопке означает, что тема не переключится. Тема переключается атрибутом на <html>, а не состоянием React: каскад пересчитает потомков сам, без ре-рендеров. Источник правды один — файл токенов в формате DTCG, из которого Style Dictionary генерирует и CSS, и TS-константы, и файлы для Figma. Флэш нестилизованной темы на SSR лечится синхронным инлайн-скриптом до <body> — без defer/async, иначе будет мигание:
<script>
try { var t = localStorage.getItem('theme');
if (t) document.documentElement.dataset.theme = t; } catch (e) {}
</script>
Дизайн-система без метрики «доля экранов, использующих её компоненты» превращается в хобби-проект. Считайте эту долю линтером по импортам — дешевле любых опросов.
Почему стили тормозят и как это увидеть
Стили влияют на все три Core Web Vitals, и по-разному.
LCP — блокирующий CSS. Любой <link rel="stylesheet"> в <head> блокирует первую отрисовку: браузер не покажет ничего, пока не построит CSSOM (механика — в статье как работает браузер). Практика: критический CSS инлайном (до ~14 КБ, чтобы уместиться в первый пакет после TCP slow start), остальное — асинхронно через media="print" с onload="this.media='all'".
CLS — шрифты и неизвестные размеры. Подмена шрифта сдвигает текст; лечится метриками фолбэка и резервированием места (aspect-ratio для медиа, min-block-size под асинхронный контент):
@font-face {
font-family: "Inter Fallback"; src: local("Arial");
size-adjust: 107%; /* подгоняем метрики под основной шрифт */
ascent-override: 90%; descent-override: 22%;
}
body { font-family: "Inter Variable", "Inter Fallback", sans-serif; }
INP — стоимость Recalculate Style. Пересчёт идёт в главном потоке перед кадром, а его стоимость грубо пропорциональна число затронутых узлов × число правил-кандидатов. Браузер не проверяет все правила подряд: он индексирует селекторы по самой правой простой части (id, class, tag) и матчит справа налево. Следствия: .menu li a дороже .menu__link, потому что правая часть a даёт огромное множество кандидатов, и для каждого браузер идёт вверх по дереву; * и голые теговые селекторы в глубоком дереве — самое дорогое, что можно написать; :has() мощный, но его инвалидация распространяется вверх по дереву, и на горячих контейнерах его надо замерять, а не предполагать; смена класса на <html> инвалидирует документ целиком — нормально для темы, недопустимо в обработчике scroll. Инструменты изоляции:
/* Внутренности не влияют на внешнее — соседей можно не пересчитывать */
.card { contain: layout style paint; }
/* Не рендерим то, что за экраном; intrinsic-size не даёт скроллбару прыгать */
.feed__item { content-visibility: auto; contain-intrinsic-size: auto 220px; }
Измерять, а не гадать. Три приёма стоит освоить руками. Selector Stats: Performance → шестерёнка → «Enable CSS selector stats», записать профиль, кликнуть событие Recalculate Style → вкладка Selector Stats покажет «Elapsed / Match Attempts / Match Count» по каждому селектору; десятки тысяч попыток при нуле совпадений — прямой кандидат на удаление (документация). Coverage: Ctrl+Shift+P → «Show Coverage» → перезагрузка; если 90 % красное на всех маршрутах, у вас проблема с разделением стилей по чанкам. Полевые метрики: лабораторные замеры врут, нужен RUM.
import { onCLS, onINP, onLCP } from 'web-vitals';
const send = (m: { name: string; value: number; id: string }) =>
navigator.sendBeacon('/rum', JSON.stringify({
...m, path: location.pathname, // атрибуция важнее самого числа
conn: (navigator as any).connection?.effectiveType,
}));
onLCP(send); onINP(send); onCLS(send);
Все локальные замеры повторяйте с throttling 4x CPU: на офисном ноутбуке проблем не видно. Тема подробно разобрана в статье о производительности фронтенда.
Как выбрать под конкретный проект
компонентный фреймворк?"} A -->|Нет| BEM["БЭМ + каскадные слои
+ CSS-переменные"] A -->|Да| B{"Серверные компоненты или
жёсткий бюджет JS?"} B -->|Да| C{"Команда принимает
utility-классы?"} B -->|Нет| D{"Нужна тяжёлая динамика
от данных в рантайме?"} C -->|Да| TW["Tailwind + cva + tailwind-merge"] C -->|Нет| CM["CSS Modules + @layer
+ токены в переменных"] D -->|Да| VE["vanilla-extract или Panda:
типы без рантайма"] D -->|Нет| CM TW --> DS["Поверх любого выбора:
слой дизайн-токенов"] CM --> DS VE --> DS BEM --> DS DS --> LINT["stylelint + бюджет размера CSS в CI"]
Коротко и без фанатизма: библиотека для внешних потребителей — CSS Modules или vanilla-extract с токенами (потребитель не обязан ставить ваш Tailwind-конфиг); продуктовое приложение, одна команда, скорость важнее всего — Tailwind; дизайн-система на несколько продуктов — DTCG-токены плюс zero-runtime или CSS Modules с версионированием и кодмодами; виджет для встраивания в чужие страницы — Shadow DOM, единственный способ не пострадать от чужого CSS и не сломать хозяина; legacy на jQuery — @layer вокруг старого кода. Почти всегда плохая идея в 2026-м — начинать новый проект на рантайм-CSS-in-JS: не потому, что «медленно», а потому, что он конфликтует с серверным рендерингом, которого требует современный стек (стратегии рендеринга).
Миграция без «перепишем всё за квартал»
Большой рефакторинг стилей целиком почти всегда проваливается. Работает инкремент: заморозить старое — весь legacy в @layer legacy, новый код вне слоёв выигрывает автоматически, без !important и правок в старом коде; выделить токены — собрать реальные цвета и отступы (npx css-analyzer покажет, что у вас 57 оттенков серого), свести к шкале, заменить литералы на переменные (польза есть, даже если дальше вы никуда не переедете); мигрировать по компонентам, а не по файлам — тронули по задаче, перевели, а линтер запрещает импорт старых миксинов в новых директориях; поставить бюджет в CI; удалять смело — визуальные регрессионные тесты превращают удаление CSS из лотереи в обычный рефакторинг (тестирование фронтенда).
// .size-limit.json — сборка падает, если стили распухли
[
{ "path": "dist/assets/*.css", "limit": "18 kB", "gzip": true },
{ "path": "dist/assets/index-*.js", "limit": "160 kB", "gzip": true }
]
Типичные ошибки
!importantкак решение — симптом отсутствия слоёв. Законны только утилиты и hotfix с тикетом.marginна корне компонента — делает его неперемещаемым; внешние отступы задаёт родитель.- Глобальные стили тегов вне слоя
base—button { padding: 12px }в середине проекта стоит команде часов отладки. - Магические числа.
margin-top: 37pxозначает, что кто-то подгонял пиксели вместо починки раскладки. - Три источника правды — цвет в Figma, в JS-константах и в CSS расходятся за полгода.
z-index: 9999— заведите шкалу (--z-dropdown: 100,--z-modal: 400) и изолирующие контексты наложения.- Тема через состояние React с ре-рендером дерева — это атрибут на
<html>, каскад справится сам. @applyвместо компонента и анимации безprefers-reduced-motion.
Мини-итог
Все методологии решают одну задачу — превратить глобальный каскад в набор локальных предсказуемых решений. БЭМ решает её соглашением, CSS Modules и Tailwind — сборкой, @layer и @scope — средствами платформы, рантайм-CSS-in-JS — ценой работы в браузере, и эта цена сегодня редко оправдана. Слой дизайн-токенов ортогонален выбору инструмента и переживает его, поэтому архитектуру стилей начинают именно с токенов. @layer — самый недооценённый инструмент: делает приоритет явным и убивает гонку специфичности одной строкой. А производительность стилей измеряется, а не обсуждается: Coverage, Selector Stats, Performance-панель и полевые Core Web Vitals.
Источники
- MDN:
@layerи@scope - Методология БЭМ, getbem.com, SMACSS
- Спецификация CSS Modules, vanilla-extract, Panda CSS
- Документация Tailwind CSS, cva, tailwind-merge
- Design Tokens Community Group, Style Dictionary
- web.dev: Core Web Vitals, content-visibility, Chrome DevTools: Selector Stats
- Radix Primitives, Lea Verou «CSS Secrets», Heydon Pickering Every Layout
Что дальше
Стили описывают, как элементы выглядят. Дальше — как они живут: дерево DOM, всплытие событий, делегирование и наблюдатели, без которых не работают ни ленивая загрузка, ни виртуализация списков, ни закрытие попапа по клику снаружи.
DOM и события: работа с деревом, делегирование, всплытие, наблюдатели