javascript

Как писать стили в 2026: CSS Modules, CSS-in-JS, Tailwind и zero-runtime

  • пятница, 2 октября 2026 г. в 00:00:08
https://habr.com/ru/companies/codesrc_it/articles/1087562/

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