Всем привет! Меня зовут Даниил Николаев и я frontend‑разработчик в компании «Исходный Код».

На kickoff снова всплывает один и тот же спор. Один тянет Tailwind. Второй — CSS Modules. Третий тащит привычный CSS‑in‑JS. Кто‑то молча открывает.scss и выходит из разговора. Разбор ниже — в контексте React, для middle и middle+.

Способов писать стили слишком много. У каждого есть громкие адепты и не менее громкие хейтеры. Обычно команда выбирает то, что уже умеет, а не то, что дешевле живет в браузере.

Я хочу разобрать основные подходы: как каждый работает, в чем плюсы и минусы, что происходит на сборке, в рантайме и в браузере, и когда что применять. К концу должна остаться рабочая картина, а не мода месяца.

Базовые вещи вроде CSS‑in‑JS объясню коротко. На азах останавливаться не буду.

Напряжение, из которого растет выбор

Очевидное решение выглядит так: пишем стили там, где удобнее автору. В JS рядом с компонентом, в утилитах прямо в разметке или в привычном CSS‑файле. На маленьком экране это работает.

Ломается это в другом месте. Не в споре про красоту синтаксиса, а в том, кто платит за динамику: сборка или браузер. Runtime CSS‑in‑JS дает живые пропсы и темы. Цена — парсинг, инъекция и лишние пересчеты стилей на каждом рендере.

Дальше разберу инструменты по очереди, затем внутренности, затем браузер. В конце — моя матрица выбора. Это мнение из практики, не истина.

Обзор подходов: плюсы и минусы

CSS Modules

Это обычные.css‑файлы с автоматическим скоупингом. Класс.button на сборке становится чем‑то вроде .button__a1b2c. Коллизии имен исчезают сами. Пишете привычный CSS, импортируете его как объект с именами классов.

Плюсы. Нативный CSS без рантайма. Предсказуемый и простой. Минимальный overhead в JS. Совместимость с любым фреймворком.

Минусы. Нельзя напрямую передать значение из JS внутрь стиля. Нельзя посчитать ширину в компоненте и сразу подставить ее в CSS‑правило. Динамику закрывают CSS‑переменные: значение уходит через style={{ '‑x': value }}, в.module.css читается как var(‑x).

Еще два неудобства. Темизация: светлую и темную тему приходится собирать вручную через CSS‑переменные. Готового механизма тем, как в CSS‑in‑JS, нет. Композиция: взять набор стилей и переопределить пару свойств можно через composes или дополнительные классы. Это работает, но многословнее, чем сборка объектов прямо в коде.

Когда использовать. Статичные компоненты, небольшие и средние проекты, долгоживущее легаси. CSS Modules не привязаны к конкретному фреймворку.

CSS‑in‑JS: Emotion

Стили пишутся в JS через шаблонные литералы или объекты. Библиотека инъектит их в DOM во время рендера.

Плюсы. Полноценные динамические стили. Удобная темизация. Автоматические вендорные префиксы вроде -webkit- и -moz-. Компонентный подход.

Минусы. Runtime overhead. Bundle чуть больше. При частых перерендерах возможны просадки.

Статус. Библиотеку давно не обновляют. Пакет @emotion/react стоит на версии 11.14.0. Последний релиз был около двух лет назад. Библиотека не мертвая и по‑прежнему широко используется. Активно развивающейся ее назвать нельзя.

Когда использовать. Когда реально нужны динамические стили, темы и SSR.

Коротко про SSR, раз он попал в список. SSR — это рендеринг HTML на сервере, чтобы браузер сразу получил готовую разметку. Для CSS‑in‑JS это отдельная задача: стили рождаются в JS, их нужно собрать на сервере и вставить в HTML. Иначе первая отрисовка мелькнет без стилей. Это FOUC. Emotion умеет извлекать критический CSS на сервере и класть его в разметку.

SSR не эксклюзив Emotion. styled-components делает это через ServerStyleSheet. CSS Modules извлекают стили на сборке, поэтому с SSR проблем нет. У zero‑runtime вроде Linaria CSS статический: на сервере ничего собирать не надо.

CSS‑in‑JS: styled‑components

Идеологически близко к Emotion, но с акцентом на компоненты. Создаете styled.button и работаете с ним как с обычным React‑компонентом.

Плюсы. Приятный API. Хороший developer experience. Большое сообщество и много материалов.

Минус важный. 17 марта 2025 года styled‑components официально ушел в maintenance mode. С версии 6.3.0 библиотека работает в серверных компонентах (RSC) без директивы 'use client' и без настройки реестра.

Нюанс с темизацией. ThemeProvider опирается на React Context. В RSC контекста нет, поэтому в серверных компонентах он не действует. Тему задают через CSS‑переменные или новый createTheme. Если нужен классический ThemeProvider, компонент помечают 'use client'.

Дело не в скорости. Развитие остановилось. Совместимость с новыми версиями React под вопросом.

Когда использовать. Поддержка существующего кода — да. Новый проект — нет.

CSS‑in‑JS: Linaria

Это zero‑runtime CSS‑in‑JS. Стили пишете в JS, извлекаются они на сборке, в браузер уезжает обычный статический CSS.

Плюсы. Нет runtime overhead. Минимальный bundle. По сути близко к CSS Modules, но с более приятным авторским опытом.

Минусы. Динамика ограничена и идет через CSS‑переменные. Темизация чуть сложнее.

Когда использовать. Когда production‑производительность критична.

Рядом стоит Vanilla Extract — современная zero‑runtime‑альтернатива. Стили пишутся в.ts‑файлах, типобезопасность есть из коробки. Это направление ощущается живее классического runtime CSS‑in‑JS.

Tailwind CSS

Utility‑first. Свои классы не пишете. Стиль собирается из готовых атомарных утилит прямо в разметке: flex, px-4, text-white и дальше по списку.

Важный момент при выборе. В финальный бандл попадают только реально использованные классы. Движок сканирует исходники, находит имена и генерирует CSS только под них. Остальное в сборку не уезжает. Итоговый CSS‑файл обычно небольшой и почти не растет вместе с проектом. Неиспользуемые стили чистить вручную не нужно.

Плюсы. Очень быстрая разработка. Малый bundle, tree‑shaking автоматический. Консистентность. Сборки на Oxide ощутимо быстрее.

Минусы. Разметка распухает от классов. Команде это переучивание и смена привычек.

Когда использовать. MVP, прототипы, проекты с гибкой, не жестко зафиксированной дизайн‑системой.

Как подходы работают изнутри

Чтобы выбирать осознанно, нужно видеть сборку, рантайм и браузер отдельно. Иначе спор сводится к вкусу.

CSS Modules под капотом

Вся магия случается на сборке. Сборщик (Webpack, Vite и другие) берет.module.css, хеширует имена классов (.button становится.button__a1b2c) и выдает две вещи: CSS‑файл с уникальными классами и JS‑объект с маппингом исходное имя → хешированное. В компоненте вы импортируете объект и берете классы по ключам.

Динамику часто понимают неправильно. Значение из JS попадает в статический CSS так:

// компонент
<div style={{ '--offset': ${offset}px }} className={
styles.box} />

/* styles.module.css */
.box {
  transform: translateX(var(--offset));
}

CSS остается статическим и извлекается на сборке. Значение ‑offset живет и меняется в рантайме. Это не «JS пишет CSS». Это JS подставляет custom property в уже готовое правило.

Emotion под капотом

Здесь все происходит в рантайме. Когда компонент рендерится, Emotion парсит стили (строку или объект), считает cache key по содержимому и кладет результат в кэш. Повторный рендер с теми же стилями повторно не компилирует. Затем CSS вставляется в страницу. Способ вставки зависит от режима сборки. При размонтировании неиспользуемые стили могут вычищаться.

В dev CSS добавляется текстом внутрь <style>‑тега. Стили видно в DevTools, их удобно дебажить. В production включается speedy mode: правила идут напрямую в CSSOM через insertRule. Это быстрее. Такие стили уже нельзя посмотреть и поправить в DevTools.

Болевая точка простая. Если пропсы, влияющие на стили, часто меняются, вы получаете много перекомпиляций и инъекций. Кэш помогает. От постоянно меняющихся значений не спасает.

Общий принцип. Runtime‑инъекция всегда медленнее статически извлеченного CSS. Браузер чаще пересчитывает стили. Сама вставка может оказаться очень медленной, если срабатывает не в тот момент жизненного цикла рендера.

Linaria: гибрид (zero‑runtime)

Linaria берет удобство CSS‑in‑JS и извлечение CSS Modules. Стили пишете в JS. Извлекаются они на сборке. В браузер уезжает статический CSS. JS получает только хешированные имена классов. Парсинга стилей в рантайме нет.

Динамические части идут через CSS‑переменные. То, что должно меняться, прокидывается как custom property. Динамика есть, но не любая.

Работает такое: цвет зависит от пропса и подставляется через переменную.

const Box = styled.div
  color: ${props => props.color};
;

Проблема начинается там, где нужна не подстановка значения, а другая структура правил. CSS‑переменная умеет подставить цвет, размер, отступ. Она не умеет на лету менять селекторы и набор правил.

От пропсов нельзя собрать медиа‑запрос вида @media (min-width: ${props => ...}): брейкпоинт считается на сборке, не в рантайме. В:global() динамика по пропсам не поддерживается вообще. Любой случай, где от значения зависит сам селектор, zero‑runtime не закрывает.

Правило короткое. Подставить другое значение в существующее свойство — Linaria справится через переменную. Поменять структуру CSS (какие селекторы или медиа‑запросы существуют) — потолок zero‑runtime.

Tailwind под капотом

Tailwind устроен иначе. Он не привязан к компонентам и не генерирует классы под конкретный элемент. Набор утилит описан заранее. Задача сборки — оставить в финальном CSS только то, что вы реально используете.

На сборке движок сканирует исходники (HTML, JSX, TSX, Vue и другие) и ищет строки, похожие на имена утилит. Найденные классы генерируются. Остальные в бандл не попадают. Итоговый CSS обычно небольшой и почти не растет с проектом. Рантайма нет. На выходе обычный статический CSS‑файл. Переключение стилей в UI — это смена классов на элементе, без генерации CSS в браузере.

В актуальной версии (v4, январь 2025) работа переехала на движок Oxide, написанный на Rust. Он в один проход делает то, что в v3 было тремя отдельными JS‑этапами: находит файлы, вычленяет классы, генерирует CSS. Для парсинга и минификации используется Lightning CSS. Он же вешает вендорные префиксы, отдельный Autoprefixer не нужен. За счет Rust и многопоточности сборки стали заметно быстрее, особенно инкрементальные при hot‑reload.

Практический нюанс из механики сканирования. Tailwind ищет цельные строки классов. Если собирать имя из кусков в рантайме, например bg-${color}-500, движок этого не увидит и класс в финальный CSS не попадет. Классы пишут целиком. Вариативность закрывают условиями с полными именами.

styled‑components под капотом

Работа похожа на Emotion, но с деталями, которые стоит понимать. Стили не генерируются в момент объявления компонента. Только когда он реально рендерится. В браузер уезжает лишь CSS, который используется на странице. Это замыкания: каждый styled.h1 держит строку со стилями и обращается к ней при рендере.

Процесс при рендере такой. Библиотека берет стили. Если есть интерполяции (функции от пропсов), считает их для текущих пропсов. Хеширует результат в короткое имя класса, исторически через MurmurHash, например dKamQW. Разные пропсы дают разный CSS и разный класс. Затем CSS идет через препроцессор Stylis: вендорные префиксы и вложенность в духе Sass. Правило вставляется в <style> в конце <head>, сгенерированный класс вешается на элемент.

Итоговый HTML выглядит примерно так:

<h1 class="sc-bdVaJa dKamQW">Title</h1>

Способ вставки, как у Emotion, зависит от сборки. В dev правила добавляются через appendChild и видны в DevTools. В production — быстрый insertRule в CSSOM.

Отдельно — componentId. Кроме класса со стилями, на элемент вешается еще один класс‑идентификатор вида sc‑bdVaJa. У него нет собственных правил. Он нужен, чтобы одни styled‑компоненты ссылались на другие в селекторах. Чтобы id был стабильным между сборками и корректно работал SSR, используют официальный Babel/SWC‑плагин.

Болевая точка та же, что у любого runtime CSS‑in‑JS. Интерполяции от часто меняющихся пропсов порождают новый класс на каждое значение. Все это происходит в браузере во время рендера.

Что происходит в браузере при рендере

Инструмент выбран. Дальше любой CSS — статический файл, runtime‑инъекция или zero‑runtime‑экстракция — попадает в один и тот же конвейер браузера. Эту часть полезно понимать отдельно от библиотеки.

CSSOM. Браузер парсит CSS и строит объектную модель стилей. Это аналог DOM, только для стилей.

Specificity и cascade. При конфликте выигрывает одно правило. Остальные проигрывают.

Paint и reflow. Каждое обновление стилей может пересчитать геометрию и перерисовать пиксели. Это недешево, если триггерить постоянно.

Critical rendering path. CSS — render‑blocking‑ресурс. Пока критический CSS не загружен и не распарсен, страница не отрисуется.

Практический вывод. Вкладка Performance в DevTools показывает, где стили упираются: recalculate style, layout, paint. При просадках смотреть сюда первым.

Итоги: мой взгляд на лидеров

Дисклеймер. Ниже личное мнение и наблюдения из практики, не объективная истина. Фронтенд меняется быстро. К моменту чтения что‑то могло сдвинуться.

Runtime CSS‑in‑JS: Emotion / styled‑components + styled‑system

Скажу прямо. Это работающее, но легаси‑решение. styled‑components в maintenance mode. styled‑system не обновлялся шесть лет. Стек отлично держит большой продукт. Выбором на будущее я его не назову.

Он оправдан, когда большой проект уже на этих рельсах и миграция дороже поддержки. Или когда мощная типизированная дизайн‑система нужна прямо сейчас, а переезд нереален.

Tailwind CSS

Для тех, кто хочет писать, а не думать. Работает за счет скорости, автоматического tree‑shaking через Oxide и маленького bundle. Я рекомендую его, когда сроки поджимают, дизайн часто меняется, а команда не хочет тянуть собственную дизайн‑систему.

Избегаю, когда есть сложная гибкая дизайн‑система и требования к чистоте разметки. Utility‑first здесь экономит дни на старте и дорожает в сопровождении разметки.

Zero‑runtime: Linaria и Vanilla Extract

Сюда, на мой взгляд, движется индустрия. Нет рантайма. CSS остается CSS. Динамика через переменные. Хорошая дружба с RSC и Next.js.

Я бы выбирал это для performance‑critical SPA и вообще для нового проекта на современном стеке. Избегал бы, если нужна максимально свободная динамика, которая не укладывается в CSS‑переменные.

CSS Modules

Консервативный, безопасный, долгоживущий вариант. Не привязан к фреймворку. Минимум магии. Вряд ли умрет. Отлично подходит для статики. Динамику закрываем теми же CSS‑переменными.

Матрица выбора

Это снова мое мнение, не таблица стандартов.

Сценарий

Мой выбор

Почему

Большой существующий продукт на CSS‑in‑JS

Оставить Emotion + styled‑system

Миграция дороже поддержки. Стек рабочий.

Новый enterprise с дизайн‑системой

Zero‑runtime (Vanilla Extract) или CSS Modules + design tokens

styled‑system заморожен. В новый проект его не беру.

MVP, нужно быстро

Tailwind

Скорость и малый bundle из коробки.

Performance очень важен

Linaria / Vanilla Extract

Нет рантайма, меньше bundle.

Статичный проект, простота

CSS Modules

CSS это CSS, минимум зависимостей.

Next.js App Router / RSC

Zero‑runtime или CSS Modules

Runtime CSS‑in‑JS плохо дружит с RSC.

Принцип, который я оставляю

Серебряной пули нет. Есть обоснованный выбор под сценарий, и он важнее любого тренда.

Согласованность внутри проекта важнее модного стека. Понимание trade‑off важнее удобного API. Runtime удобен, пока пропсы спокойны. Zero‑runtime дешев в браузере, пока динамика не требует менять структуру CSS. Tailwind быстр, пока команда готова жить в утилитах.

Выбирайте то, что закрывает вашу боль, а не то, что громче хвалят в этом месяце.

На чем пишете стили вы? Особенно интересно услышать тех, кто уже пощупал zero‑runtime в бою или осознанно остался на runtime CSS‑in‑JS. Что выбрали и почему.