Всем привет! Меня зовут Даниил Николаев и я 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 так:
|
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 |
Проблема начинается там, где нужна не подстановка значения, а другая структура правил. 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 выглядит примерно так:
|
Способ вставки, как у 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. Что выбрали и почему.

