Frontend-разработка Архитектура стилей: БЭМ, CSS-modules, CSS-in-JS, Tailwind, дизайн-системы
0%

Архитектура стилей: БЭМ, CSS-modules, CSS-in-JS, Tailwind, дизайн-системы

Архитектура стилей: БЭМ, 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 Что видно при инспектировании Скорость отладки

БЭМ: дисциплина имён вместо инструмента

БЭМ (Блок — Элемент — Модификатор) решает коллизии соглашением: имя класса уникально, потому что содержит имя блока. Три правила делают его работающим — и их же чаще всего нарушают.

  1. Только одноуровневые селекторы. Никаких .card .card__title: один класс = специфичность 0,1,0 у всего проекта, и правила перебиваются порядком, а не гонкой.
  2. Блок не задаёт свои внешние отступы. margin ставит родитель через собственный элемент (.list__item). Иначе блок нельзя переиспользовать в другом окружении.
  3. Элемент не вкладывается в элемент. .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: на офисном ноутбуке проблем не видно. Тема подробно разобрана в статье о производительности фронтенда.

Как выбрать под конкретный проект

Коротко и без фанатизма: библиотека для внешних потребителей — 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 на корне компонента — делает его неперемещаемым; внешние отступы задаёт родитель.
  • Глобальные стили тегов вне слоя basebutton { 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.

Источники

Что дальше

Стили описывают, как элементы выглядят. Дальше — как они живут: дерево DOM, всплытие событий, делегирование и наблюдатели, без которых не работают ни ленивая загрузка, ни виртуализация списков, ни закрытие попапа по клику снаружи.

DOM и события: работа с деревом, делегирование, всплытие, наблюдатели

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов