CSS-in-JS глубоко: styled-components, Emotion, Linaria, vanilla-extract
CSS-in-JS — самый спорный подход в истории стилизации фронтенда. В 2016–2020 он казался будущим: стили живут рядом с компонентом, темизация — через контекст, мёртвый код удаляется сам, всё типизируется. В 2024–2026 его обвиняют в тормозах гидратации и избыточном рантайме. Обе стороны правы — в своих контекстах. Чтобы понимать, когда CSS-in-JS уместен, а когда нет, надо разобраться, как он работает под капотом: где выполняется стилизация (в браузере или на сборке), чем это оплачивается и какие проблемы он вообще решал.
В этой главе — полный разбор: от проблем «до», через runtime-реализации (styled-components, Emotion), к zero-runtime (Linaria, vanilla-extract), сравнительная таблица и честный ответ на вопрос «стоит ли брать сегодня». Глава намеренно длинная: это категория, которую ты гарантированно встретишь в legacy-коде, и понимание её механики — обязательная часть фундамента.
Что такое CSS-in-JS и какие проблемы решал
Заголовок раздела «Что такое CSS-in-JS и какие проблемы решал»Идея: стили описываются внутри JavaScript и привязываются к компоненту, а не живут в отдельном CSS-файле. В классическом runtime-варианте CSS генерируется в браузере во время рендеринга. Исторический контекст — болезни эпохи 2014–2016:
- Глобальное пространство имён CSS. Класс
.buttonв одном файле ломал.buttonв другом; БЭМ-конвенция (block__element--modifier) была обходом, а не решением. CSS-in-JS генерирует уникальные имена классов автоматически — изоляция бесплатно. - Мёртвый код. CSS растёт монолитно: удалил компонент — стили остались. В CSS-in-JS стили живут в JS-модуле компонента: удали импорт — стили не попадают в бандл (в идеальной реализации).
- Динамические стили. «Кнопка красная в состоянии ошибки, иначе серая» в CSS — борьба с классами-состояниями. В CSS-in-JS — обычный тернарный оператор над пропсами.
- Темизация и зависимость от пропсов. Токены темы как JS-объект, доступный в любом стиле через контекст. В чистом CSS 2016 года — только Custom Properties (ещё свежие) или CSS-переменные через препроцессоры.
- Критический CSS и SSR. Разметка рождается в JS — можно на сервере собрать использованные стили и вставить в
<style>первого ответа.
Эти проблемы решались реально — поэтому взлёт был стремительным. Расплата пришла позже, и о ней — в разборе под капотом.
styled-components: runtime-стилизация как стандарт
Заголовок раздела «styled-components: runtime-стилизация как стандарт»styled-components (2016, Макс Штойбер / Глен Маддерн) — эталонный синтаксис категории: «компонент = тег + стилевая функция». Официальная документация покрывает API, темизацию и SSR.
npm i styled-componentsimport styled, { ThemeProvider } from 'styled-components';
// Базовый синтаксис: styled.тег`стили`const Title = styled.h1` font-size: 2rem; color: #1e293b; margin-bottom: 1rem;`;
// Интерполяция функции — стили зависят от пропсовconst Button = styled.button<{ $primary?: boolean }>` padding: 0.5rem 1rem; border: none; border-radius: 8px; cursor: pointer;
/* пропс $primary: $ — конвенция, чтобы styled-components не прокидывал его в DOM (transient props) */ background: ${(p) => (p.$primary ? '#7c3aed' : 'transparent')}; color: ${(p) => (p.$primary ? 'white' : '#7c3aed')}; border: 1px solid #7c3aed;
&:hover { background: ${(p) => (p.$primary ? '#6d28d9' : '#f5f3ff')}; }`;
// Композиция: styled(Компонент) наследует стилиconst IconButton = styled(Button)` padding: 0.5rem; border-radius: 50%;`;Под капотом на каждый рендер работает сериализатор: шаблонные строки компилируются Babel-плагином в массивы [strings[], interpolations[]], интерполяции вычисляются с пропсами, результат скармливается CSS-парсеру (stylis), который генерирует валидный CSS и заменяет вложенность на плоские селекторы. Из хэша CSS получается имя класса (sc-xyz123), правило вставляется в <style>-тег в <head> (или в SSR-стрим). Всё это — в рантайме, в браузере, при каждом первом рендере каждого уникального набора стилей.
Темизация через ThemeProvider
Заголовок раздела «Темизация через ThemeProvider»Тема — объект в контексте, доступный в каждой интерполяции:
// theme.ts — контракт темы (в идеале типизирован)export const lightTheme = { colors: { bg: '#ffffff', surface: '#f8fafc', text: '#1e293b', primary: '#7c3aed', }, spacing: (n: number) => `${n * 4}px`, radii: { md: '8px', lg: '12px' },};
// styled.d.ts — расширение дефолтной темы (TypeScript)declare module 'styled-components' { export interface DefaultTheme { colors: { bg: string; surface: string; text: string; primary: string }; spacing: (n: number) => string; radii: { md: string; lg: string }; }}<ThemeProvider theme={lightTheme}> <Card>…</Card></ThemeProvider>const Card = styled.div` background: ${(p) => p.theme.colors.surface}; color: ${(p) => p.theme.colors.text}; padding: ${(p) => p.theme.spacing(4)}; border-radius: ${(p) => p.theme.radii.lg};`;Переключение темы — замена объекта в ThemeProvider: все styled-компоненты перерендериваются с новой темой. Это работает, но обрати внимание на цену: смена темы = полный ре-рендер всего дерева + пересерилизация всех стилей. В чистом CSS смена data-theme — перекраска браузером без единой строчки JS (см. Bootstrap color modes и Tailwind-токены).
Компонент кнопки с вариантами: полный пример
Заголовок раздела «Компонент кнопки с вариантами: полный пример»import styled, { css } from 'styled-components';
type Variant = 'primary' | 'outline' | 'danger';type Size = 'sm' | 'md' | 'lg';
interface ButtonProps { $variant?: Variant; $size?: Size;}
const variantStyles = { primary: css` background: #7c3aed; color: #fff; &:hover:not(:disabled) { background: #6d28d9; } `, outline: css` background: transparent; color: #7c3aed; border: 1px solid #7c3aed; &:hover:not(:disabled) { background: #f5f3ff; } `, danger: css` background: #dc2626; color: #fff; &:hover:not(:disabled) { background: #b91c1c; } `,};
const sizeStyles = { sm: css`padding: 0.375rem 0.75rem; font-size: 0.875rem;`, md: css`padding: 0.5rem 1rem; font-size: 1rem;`, lg: css`padding: 0.75rem 1.5rem; font-size: 1.125rem;`,};
export const Button = styled.button<ButtonProps>` display: inline-flex; align-items: center; gap: 0.5rem; border: none; border-radius: 8px; font-weight: 500; cursor: pointer; transition: background 0.15s;
&:disabled { opacity: 0.5; cursor: not-allowed; }
${(p) => variantStyles[p.$variant ?? 'primary']} ${(p) => sizeStyles[p.$size ?? 'md']}`;
// Использование<Button $variant="danger" $size="sm" onClick={handleDelete}> Удалить</Button>Это полноценная замена компонентной библиотеке на уровне одного компонента: вариативность типизирована, стили локальны, мёртвый код не выживет. Заметь карту variantStyles — она важна: при такой структуре styled-components может вычислить на сервере, какие стили понадобятся (все три варианта, а не один), что влияет на SSR.
Emotion: css prop и конкурентная механика
Заголовок раздела «Emotion: css prop и конкурентная механика»Emotion (2017, Кай Хоэнбергер) — второй гигант, синтаксически близкий, но с флагманской фичей: css prop на любом элементе. Документация — emotion.sh.
npm i @emotion/react @emotion/styled/** @jsxImportSource @emotion/react */ // либо настройка в tsconfigimport { css } from '@emotion/react';
const highlight = css` background: #fef9c3; padding: 0 4px; border-radius: 4px;`;
export function Note({ tone }: { tone: 'info' | 'warn' }) { return ( <p css={css` border-left: 3px solid ${tone === 'warn' ? '#f59e0b' : '#3b82f6'}; padding-left: 12px; color: #334155; ${highlight} // композиция стилей `} > Текст заметки </p> );}Механика идентична styled-components: сериализация в рантайме, класс из хэша, вставка в <style>. Отличия:
- Сериализация быстрее за счёт другого парсера и кэширования (Emotion historically быстрее styled-components на больших списках).
- Стили как данные: объект
cssможно передавать, комбинировать, вычислять — гибче шаблонных строк. - @emotion/css — фреймворко-независимое ядро, можно в Vue/Svelte.
На практике styled-components и Emotion взаимозаменяемы; выбор — вкус команды и экосистема (например, Chakra UI исторически строился на Emotion, MUI — на собственном движке, похожем по механике).
Linaria: zero-runtime, как это работает
Заголовок раздела «Linaria: zero-runtime, как это работает»Linaria (2017, Callstack) — попытка сохранить удобство CSS-in-JS, выкинув рантайм: стили извлекаются в статический CSS на этапе сборки, в рантайме остаётся только привязка классов.
npm i @linaria/core @linaria/react @linaria/babel-presetimport { css } from '@linaria/core';import { styled } from '@linaria/react';
// Статический стиль → обычный CSS-файл на сборкеconst title = css` font-size: 2rem; color: #1e293b;`;
// styled с динамическими пропсами: динамика через CSS-переменные!export const Button = styled.button<{ $primary?: boolean }>` padding: 0.5rem 1rem; border-radius: 8px; background: ${(p) => (p.$primary ? '#7c3aed' : 'transparent')};`;Механика под капотом:
- Babel-плагин на сборке находит
css-вызовы иstyled-теги, вычисляет всё статичное. - Результат записывается в
.linaria-cache/*.css— обычный CSS-файл, который попадает в бандл как любой другой CSS. - Динамические интерполяции (от пропсов) заменяются на CSS Custom Properties:
background: ${p => ...}превращается вbackground: var(--btn-bg), а рантайм-код выставляет значение переменной на элементе черезstyle.
/* То, что реально окажется в CSS-файле после сборки Linaria */.b1x2a { padding: .5rem 1rem; border-radius: 8px; background: var(--b1x2a-bg); }<!-- Рантайм-код выставляет только переменную — дёшево --><button class="b1x2a" style="--b1x2a-bg: #7c3aed;">Кнопка</button>Плюсы: нулевой рантайм-стоимости стилизации, мгновенный первый рендер (CSS уже в <link>), всё удобство шаблонных строк. Грабли:
- Нужен Babel-конвейер — в Vite-эпоху это отдельная точка хрупкости (плагин для Vite существует, но исторически отставал от esbuild-сборки).
- Не всё можно вычислить статически: стили, зависящие от внешнего состояния (значение пропса извне в момент рендера), либо падают в ошибку сборки, либо идут через CSS-переменные с ограничениями (нельзя вложить медиа-запрос в переменную).
- Темизация через JS-контекст невозможна как в styled-components: тема должна быть CSS-переменными, значит вся мощь ThemeProvider заменяется ручным выставлением
---переменных. - Горячая перезагрузка и дебаг — через Babel-плагин, дополнительная сложность против нативного CSS HMR.
vanilla-extract: TypeScript-стили как контракт
Заголовок раздела «vanilla-extract: TypeScript-стили как контракт»vanilla-extract (Seek, 2021) — zero-runtime, но с другой философией: стили пишутся не в компоненте, а в отдельных .css.ts файлах как TypeScript-код, компилируются в статический CSS на сборке, а в JS попадают только хэшированные имена классов. Полная типизация + нулевой рантайм.
npm i @vanilla-extract/css @vanilla-extract/recipes @vanilla-extract/sprinklesimport { createTheme, style, globalStyle } from '@vanilla-extract/css';
// 1. Тема = контракт с типамиexport const [themeClass, vars] = createTheme({ color: { primary: '#7c3aed', surface: '#f8fafc', text: '#1e293b', }, spacing: { sm: '8px', md: '16px', lg: '24px' }, radius: { md: '8px' },});
// 2. Стили как типизированные функцииexport const button = style({ padding: `${vars.spacing.sm} ${vars.spacing.md}`, borderRadius: vars.radius.md, border: 'none', cursor: 'pointer', background: vars.color.primary, color: '#fff', selectors: { '&:hover:not(:disabled)': { filter: 'brightness(0.95)' }, '&:disabled': { opacity: 0.5, cursor: 'not-allowed' }, },});
// Варианты через recipe (@vanilla-extract/recipes)import { recipe } from '@vanilla-extract/recipes';
export const buttonRecipe = recipe({ base: button, variants: { variant: { primary: { background: vars.color.primary }, outline: { background: 'transparent', color: vars.color.primary, border: `1px solid ${vars.color.primary}`, }, danger: { background: '#dc2626', color: '#fff' }, }, size: { sm: { padding: '6px 12px', fontSize: '0.875rem' }, md: { padding: '8px 16px' }, lg: { padding: '12px 24px', fontSize: '1.125rem' }, }, }, defaultVariants: { variant: 'primary', size: 'md' },});import { themeClass, buttonRecipe } from './styles/app.css';
export function App() { return ( <div className={themeClass}> <button className={buttonRecipe({ variant: 'danger', size: 'sm' })}> Удалить </button> </div> );}Под капотом: .css.ts файлы компилируются esbuild/SWC-плагином в .css + .css.js (классы — строковые константы). Темы — наборы CSS-переменных, привязанные к классу themeClass. Sprinkles — аналог утилитарного слоя: ты декларируешь атомы (padding, color, display…), а композитор собирает из них классы, как Tailwind, но из твоих токенов:
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const properties = defineProperties({ properties: { padding: { sm: vars.spacing.sm, md: vars.spacing.md }, color: { primary: vars.color.primary, text: vars.color.text }, display: ['block', 'flex', 'none'], },});
export const sprinkles = createSprinkles(properties);// sprinkles({ padding: 'md', color: 'primary', display: 'flex' }) → строка классовПлюсы vanilla-extract: типизация стилей и тем (опечатка в токене — ошибка компиляции), нулевой рантайм, дизайн-токены как единый источник, отличный tree-shaking (неиспользуемые стили не попадают в CSS). Минусы: отдельные .css.ts-файлы разрывают «стили рядом с компонентом», требуется плагин сборки, экосистема меньше Tailwind.
Сравнительная таблица
Заголовок раздела «Сравнительная таблица»| Критерий | styled-components | Emotion | Linaria | vanilla-extract |
|---|---|---|---|---|
| Рантайм | Сериализация в браузере | Сериализация в браузере | Только CSS-переменные | Ноль (чистый CSS) |
| SSR | Вытаскивание стилей из renderToString | Аналогично | CSS-файл из коробки | CSS-файл из коробки |
| Первый рендер | Стиль вставляется в при рендере | Аналогично | CSS уже в бандле | CSS уже в бандле |
| Темизация | ThemeProvider + контекст | ThemeProvider + контекст | CSS-переменные | createTheme, CSS-переменные |
| Типизация | Дженерики пропсов | Аналогично | Средняя | Отличная (контракт темы) |
| Гидратация | Дорогая: стили пересчитываются | Аналогично | Дёшево | Дёшево |
| RSC-совместимость | ❌ Клиентский рантайм | ❌ | ✅ Стили — на сервере | ✅ |
| Экосистема / зрелость | Огромная legacy | Огромная legacy | Средняя | Растущая |
| Зависимость от сборки | Babel-плагин опционален | Аналогично | Babel — обязателен | esbuild/SWC-плагин |
Почему CSS-in-JS потерял популярность: честный разбор
Заголовок раздела «Почему CSS-in-JS потерял популярность: честный разбор»Категория runtime CSS-in-JS действительно ушла на спад. Причины по весу:
1. Server Components и новая модель React. RSC рендерятся на сервере и не имеют рантайм-состояния; styled-components и Emotion — клиентские библиотеки, требующие исполнения в браузере. Гидрировать их нужно всё равно, а значит вся модель «стили рождаются в браузере» конфликтует с направлением React. Next.js App Router прямо рекомендует CSS Modules, Tailwind или zero-runtime решения.
2. Стоимость гидратации. Канонический разбор стоимости рантайма (Sam Magura, инженер Spotify, 2022 — ссылка в «Что почитать») с замерами показывал: на типичной странице runtime CSS-in-JS добавлял десятки килобайт JS и миллисекунды главного потока на сериализацию. Учитывая, что INP — третья метрика Core Web Vitals (подробный разбор INP — на web.dev, см. также web-vitals), это прямая потеря бизнес-метрик.
3. Tailwind съел сценарий «скорость разработки». Большинство команд брали CSS-in-JS ради вариативности и изоляции. Tailwind решает обе проблемы: изоляция через утилиты, вариативность через cva — без рантайм-стоимости и с куда меньшим бандлом JS.
4. Нативный CSS догнал. Custom Properties, @layer, :has(), нативная вложенность — проблемы 2016 года решены стандартом. То, ради чего терпели рантайм, сегодня бесплатно.
Где CSS-in-JS по-прежнему уместен: существующие кодовые базы (миграция дороже выгоды), изолированные виджеты сторонних скриптов (всё в одном JS-файле — фича), небольшие клиентские приложения без SSR, где удобство команды важнее метрик, и zero-runtime ветка (vanilla-extract) там, где нужна типизация токенов.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»- Непрокинутый
$-префикс у transient props.<Button primary>в styled-components кладётprimary="true"в DOM → React warning о неизвестном атрибуте. Используй$primary— styled-components вырежет его из DOM. - Создание styled-компонентов внутри рендера.
const Btn = styled.button`...`в теле компонента создаёт новый класс-компонент на каждый рендер → пересоздание стилей, потеря фокуса, утечки<style>-тегов. Styled-компоненты объявляются на уровне модуля. - Динамическая интерполяция тяжёлых вычислений.
${(p) => computeExpensiveGradient(p)}выполняется на каждый рендер каждого экземпляра. Выноси вычисления наружу, мемоизируй, либо переноси в CSS-переменные. - SSR без вытаскивания стилей. Рендеришь на сервере, но не вставляешь
<style>в HTML → flash of unstyled content (FOUC) до гидратации. styled-components:ServerStyleSheet; Emotion:createEmotionServer/@emotion/server. - Тема как JS-объект вместо CSS-переменных в дизайн-системе. Команда строит «темизацию через ThemeProvider», а потом нужен тёмный режим для виджета, встроенного в чужой сайт по iframe. CSS-переменные решают это декларативно; JS-тема — через прокидывание провайдеров. Правило: дизайн-токены — CSS-переменные, JS-тема — только для значений, реально зависящих от рантайм-логики.
- Linaria: попытка темизировать через контекст. Линария не поддерживает ThemeProvider-паттерн: всё, что зависит от контекста, не вычисляется на сборке. Проектируй тему через CSS-переменные сразу, иначе получишь серию ошибок «cannot evaluate» на сборке.
- Смешивание styled-components и Emotion в одном проекте. Два рантайма сериализации, две копии stylis, двойная стоимость гидратации. Мигрируй одной библиотекой целиком.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Что такое CSS-in-JS и какие проблемы решает? Стили описываются в JS и привязываются к компоненту. Решает: изоляцию имён (генерация уникальных классов), удаление мёртвого кода, динамические стили через пропсы, темизацию через контекст, критический CSS при SSR.
- Как styled-components работает под капотом?
Babel-плагин компилирует шаблонные строки в массивы строк+интерполяций; на рендере интерполяции вычисляются с пропсами, stylis парсит CSS и генерирует класс из хэша; правило вставляется в
<style>в<head>. Всё — в браузере, при первом рендере уникального набора стилей. - Что такое transient props и зачем
$-префикс? Пропсы с$(например,$primary) не прокидываются в DOM — styled-components вырезает их из итоговых атрибутов. Без$нестандартный атрибут попадает в HTML и React выдаёт warning. - Чем Linaria достигает zero-runtime? Чем ограничена? Babel-плагин извлекает статические стили в CSS-файлы на сборке; динамика от пропсов — через CSS Custom Properties, которые рантайм выставляет на элементе. Ограничена: темизация только через CSS-переменные (нет ThemeProvider), сложные динамические стили не компилируются статически, нужен Babel-конвейер.
- Что такое vanilla-extract и чем отличается от Linaria?
Стили в
.css.ts-файлах как TypeScript-код, компилируются в статический CSS + классы-хэши. Отличие от Linaria: строгая типизация тем (createTheme-контракт), встроенный слой атомарных утилит (Sprinkles) и рецепты (recipe), философия «токены как код, а не как интерполяции». - Почему runtime CSS-in-JS конфликтует с React Server Components? RSC не гидрируются и не имеют клиентского состояния; runtime-библиотеки требуют исполнения в браузере для генерации стилей — либо принудительно делают компонент клиентским, либо требуют двойного рендера. Рекомендация React-экосистемы: CSS Modules, Tailwind, zero-runtime.
- Как устроена темизация в styled-components и какова её цена? ThemeProvider кладёт объект темы в контекст; интерполяции читают его через callback-пропсы. Цена: смена темы — полный перерендер дерева с пересериализацией всех стилей; против переключения CSS-переменных, где браузер просто перекрашивает.
- Когда CSS-in-JS всё ещё оправдан в 2026? Legacy-кодовые базы (миграция дороже пользы), изолированные виджеты «всё-в-одном-JS», небольшие клиентские SPA без SSR, и zero-runtime ветка (vanilla-extract) там, где нужна типизация токенов без рантайм-стоимости.
Практика
Заголовок раздела «Практика»- Настрой styled-components проект: компонент кнопки с вариантами (primary/outline/danger) и размерами (sm/md/lg) через карты стилей, типизация пропсов, transient props. Критерий: кнопка рендерится без React warnings, варианты переключаются, мёртвые стили не в бандле.
- Реализуй SSR с styled-components:
renderToString+ServerStyleSheet, вставка<style>в HTML. Критерий: первый ответ сервера содержит стили кнопки из задания 1, нет FOUC. - Перепиши кнопку на Linaria: статические варианты извлекаются в CSS, динамика цвета — через CSS-переменную. Критерий: в продакшен-бандле нет кода Linaria-рантайма, стили — в отдельном CSS-файле.
- Собери мини-дизайн-систему на vanilla-extract:
createThemeс токенами,recipeдля кнопки, Sprinkles для утилит. Критерий: смена темы — замена одного класса на контейнере, опечатка в токене — ошибка TypeScript. - Эксперимент с гидратацией: сравни INP и размер JS на одном и том же списке из 500 элементов, стилизованном (а) styled-components, (б) vanilla-extract. Критерий: замеры в Lighthouse/INP-поле и таблица с разницей.
- Спланируй миграцию условного проекта со styled-components на Tailwind + cva: инвентаризация компонентов, порядок (листья → контейнеры), критерии приёмки. Критерий: план с оценкой и рисками, первый мигрированный компонент.
Что почитать
Заголовок раздела «Что почитать»- styled-components: документация — API, темизация, SSR, tooling.
- Emotion: документация — css prop, серверный рендеринг, производительность.
- Linaria: репозиторий — механика zero-runtime, ограничения, интеграции.
- vanilla-extract: документация — темы, recipes, sprinkles.
- Why We’re Breaking Up with CSS-in-JS (Sam Magura, 2022) — эссе-разбор проблем runtime-подхода с цифрами и контекстом.
- React docs: стилизация и Server Components — официальная позиция по совместимости подходов с RSC.