CSS-in-JS vs CSS Modules vs Vanilla CSS: Qual Estratégia de Estilização Escolher em 2025

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
O custo que ninguém mostra no tutorial de estilização
A escolha de estratégia de estilização parece uma decisão de gosto pessoal. Não é. Ela afeta bundle size, tempo de hidratação, cache de assets e a capacidade do time de refatorar componentes sem medo. E o cenário mudou: o React Server Components tornou CSS-in-JS runtime um problema real de performance, enquanto o CSS nativo ganhou features como @layer, nesting e container queries que eliminam boa parte da motivação original de soluções como styled-components.
Este post compara três estratégias com critérios explícitos, código funcional e uma matriz de decisão para você fechar a escolha sem ficar preso em thread do Twitter.
As três abordagens em código real
Antes de comparar, vale nivelar o que cada abordagem produz de fato. O componente é o mesmo: um card de produto com título, preço e badge de desconto.
CSS-in-JS com styled-components
// ProductCard.tsx
import styled from 'styled-components';
// Transient prop ($hasDiscount) evita que a prop vaze para o DOM
const Badge = styled.span<{ $hasDiscount: boolean }>`
display: ${({ $hasDiscount }) => ($hasDiscount ? 'inline-block' : 'none')};
background: #e53e3e;
color: #fff;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
`;
const Card = styled.article`
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px;
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
`;
const Price = styled.p`
font-size: 1.25rem;
font-weight: 700;
color: #1a202c;
`;
interface ProductCardProps {
title: string;
price: number;
discountPercent?: number;
}
export function ProductCard({ title, price, discountPercent }: ProductCardProps) {
const hasDiscount = (discountPercent ?? 0) > 0;
return (
<Card>
<h3>{title}</h3>
<Price>R$ {price.toFixed(2)}</Price>
<Badge $hasDiscount={hasDiscount}>{discountPercent}% OFF</Badge>
</Card>
);
}CSS Modules
/* ProductCard.module.css */
.card {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px;
transition: box-shadow 0.2s ease;
}
.card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.price {
font-size: 1.25rem;
font-weight: 700;
color: #1a202c;
}
.badge {
display: none;
background: #e53e3e;
color: #fff;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
}
/* Classe condicional separada: mais fácil de rastrear no DevTools */
.badgeVisible {
display: inline-block;
}// ProductCard.tsx
import styles from './ProductCard.module.css';
interface ProductCardProps {
title: string;
price: number;
discountPercent?: number;
}
export function ProductCard({ title, price, discountPercent }: ProductCardProps) {
const hasDiscount = (discountPercent ?? 0) > 0;
return (
<article className={styles.card}>
<h3>{title}</h3>
<p className={styles.price}>R$ {price.toFixed(2)}</p>
<span className={`${styles.badge} ${hasDiscount ? styles.badgeVisible : ''}`}>
{discountPercent}% OFF
</span>
</article>
);
}Vanilla CSS com data attributes
/* product-card.css */
/* @layer garante que estilos de componente não conflitem com reset global */
@layer components {
.product-card {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px;
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
}
.product-card__price {
font-size: 1.25rem;
font-weight: 700;
color: #1a202c;
}
.product-card__badge {
display: none;
background: #e53e3e;
color: #fff;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
/* data attribute como API de estado: semântico e pesquisável */
&[data-visible='true'] {
display: inline-block;
}
}
}// ProductCard.tsx
import './product-card.css';
interface ProductCardProps {
title: string;
price: number;
discountPercent?: number;
}
export function ProductCard({ title, price, discountPercent }: ProductCardProps) {
const hasDiscount = (discountPercent ?? 0) > 0;
return (
<article className="product-card">
<h3>{title}</h3>
<p className="product-card__price">R$ {price.toFixed(2)}</p>
<span className="product-card__badge" data-visible={hasDiscount}>
{discountPercent}% OFF
</span>
</article>
);
}Comparação com critérios explícitos
A tabela usa cinco critérios que afetam projetos reais. "DX de colocation" mede o quão perto o estilo fica do componente. "Custo de runtime" mede trabalho no browser durante renderização.
| Critério | CSS-in-JS (runtime) | CSS Modules | Vanilla CSS |
|---|---|---|---|
| Custo de runtime | Alto: serializa estilos e injeta <style> a cada render | Zero: CSS extraído em build | Zero: CSS estático |
| Colocation | Total: estilo vive no mesmo arquivo | Parcial: arquivo .module.css ao lado | Baixa a média: arquivo separado |
| Escopo automático | Sim, por geração de classe única | Sim, por hash do módulo | Não (precisa de convenção BEM ou @layer) |
| Compatibilidade com RSC | Quebra: requer contexto de client | Funciona em Server Components | Funciona em Server Components |
| Theming dinâmico | Nativo via props e ThemeProvider | Requer CSS custom properties | Requer CSS custom properties |
| Cache de CDN | Não cacheia (inline no JS) | Cacheia como .css separado | Cacheia como .css separado |
| Bundle size adicional | ~12-15 kB (styled-components gzipped) | 0 kB | 0 kB |
O elefante na sala: React Server Components
Com o App Router do Next.js e a adoção de Server Components vs Client Components, CSS-in-JS runtime virou um problema estrutural. styled-components e Emotion dependem de React.createContext para injetar estilos. Server Components não suportam contexto.
Isso força uma de duas saídas ruins:
- Marcar todo componente estilizado como
'use client', perdendo os ganhos de RSC. - Adotar uma camada de extração em build (como o compilador do Pigment CSS ou StyleX), que é CSS-in-JS apenas na autoria, não no runtime.
Se o projeto usa Next.js 13+ com App Router, CSS-in-JS runtime não é uma opção viável para componentes de servidor. CSS Modules funcionam sem nenhuma configuração extra, e Vanilla CSS funciona com um import direto.
Quando CSS-in-JS (ainda) faz sentido
CSS-in-JS runtime não morreu. Faz sentido em dois cenários:
Design systems com theming pesado: quando o componente precisa reagir a 5+ variações de tema em runtime (modo claro, escuro, alto contraste, marca A, marca B), a API de props do styled-components reduz boilerplate comparado a manipular dezenas de custom properties manualmente.
SPAs client-side puras: se o projeto é um dashboard interno sem SSR, sem preocupação com SEO e sem Server Components, o custo de runtime é irrelevante. O time já conhece a API, a DX é boa, e migrar não traria ganho mensurável.
Fora desses dois cenários, o custo não se paga.
O que NÃO fazer
Anti-pattern 1: CSS-in-JS com estilos que não dependem de props
// ERRADO: estilo 100% estático gerando custo de runtime sem necessidade
import styled from 'styled-components';
const Wrapper = styled.div`
display: flex;
gap: 16px;
max-width: 1200px;
margin: 0 auto;
padding: 0 16px;
`;
// Cada render serializa esse CSS e reconcilia o <style> injetado.
// Nenhuma prop dinâmica justifica esse custo./* CORRETO: CSS Module ou Vanilla CSS para estilos estáticos */
/* Layout.module.css */
.wrapper {
display: flex;
gap: 16px;
max-width: 1200px;
margin: 0 auto;
padding: 0 16px;
}A regra é simples: se o estilo não depende de estado do componente, ele não precisa existir no runtime do JavaScript.
Anti-pattern 2: className com template literal sem tipagem
// ERRADO: string mágica, sem autocomplete, sem erro de compilação
export function Button({ variant }: { variant: string }) {
return <button className={`btn btn-${variant}`}>Click</button>;
}
// Se alguém passa variant="primay" (typo), nenhum erro aparece.
// O botão renderiza sem estilo e ninguém percebe até o QA reclamar.// CORRETO: CSS Modules com mapa de variantes tipado
import styles from './Button.module.css';
const variantMap = {
primary: styles.primary,
secondary: styles.secondary,
danger: styles.danger,
} as const;
type Variant = keyof typeof variantMap;
export function Button({ variant }: { variant: Variant }) {
return <button className={`${styles.base} ${variantMap[variant]}`}>Click</button>;
}
// TypeScript rejeita variant="primay" em compilação.Anti-pattern 3: misturar duas estratégias sem fronteira clara
Projetos que usam styled-components para componentes "importantes" e CSS Modules para "o resto" criam confusão cognitiva. O dev novo não sabe qual padrão seguir. A regra: escolha uma estratégia primária e use a segunda apenas em uma camada bem definida (por exemplo, CSS Modules para o app, Vanilla CSS para o design system importado como pacote).
Matriz de decisão
Use esta matriz como ponto de partida. Leia da esquerda para a direita e pare na primeira condição verdadeira:
-
Projeto usa Next.js App Router com Server Components? Use CSS Modules. Funciona em server e client sem configuração. Se precisar de theming, combine com CSS custom properties.
-
Projeto é SPA client-side pura (CRA, Vite) com design system que tem 3+ temas dinâmicos? CSS-in-JS runtime (styled-components, Emotion) ainda entrega boa DX. Avalie migrar para Pigment CSS ou StyleX quando o compilador estabilizar.
-
Projeto é SPA simples sem theming complexo? CSS Modules. Menor overhead, escopo automático, zero config no Vite e no webpack.
-
Projeto prioriza performance máxima e o time domina convenções CSS (BEM,
@layer)? Vanilla CSS. Zero abstração, cache perfeito de CDN, funciona com qualquer framework. Combine com container queries e animações nativas de scroll para eliminar JavaScript de apresentação. -
Projeto legado com styled-components e sem plano de migração? Mantenha, mas pare de criar novos styled components. Novos componentes entram com CSS Modules. Migre incrementalmente.
CSS custom properties como camada de theming universal
Independente da estratégia escolhida, CSS custom properties funcionam como API de theming que atravessa qualquer abordagem:
/* theme.css */
:root {
--color-primary: #3182ce;
--color-danger: #e53e3e;
--spacing-sm: 8px;
--spacing-md: 16px;
--radius-base: 8px;
}
/* Tema escuro via atributo no html: sem JavaScript de runtime */
[data-theme='dark'] {
--color-primary: #63b3ed;
--color-danger: #fc8181;
}// ThemeToggle.tsx
'use client';
import { useCallback } from 'react';
export function ThemeToggle() {
const toggle = useCallback(() => {
const current = document.documentElement.getAttribute('data-theme');
document.documentElement.setAttribute(
'data-theme',
current === 'dark' ? 'light' : 'dark'
);
}, []);
return <button onClick={toggle}>Alternar tema</button>;
}Essa abordagem funciona com CSS Modules, Vanilla CSS e até com styled-components (via var(--color-primary)). É a cola que permite migrar de estratégia sem reescrever tokens de design.
Performance: o que os números dizem
O custo de CSS-in-JS runtime aparece em dois momentos: serialização inicial (montar a string CSS a partir do template literal) e inserção no CSSOM (injetar a tag <style>). Em componentes que re-renderizam com frequência, como itens de lista virtualizados, cada render recalcula se o estilo mudou.
CSS Modules e Vanilla CSS não têm esse custo. O CSS é extraído em build, servido como arquivo estático, cacheado pelo browser e parseado uma vez pelo engine de CSS. A diferença é mensurável em aplicações com 200+ componentes estilizados na mesma página: styled-components adiciona entre 50-150ms ao Time to Interactive em dispositivos mid-range (Moto G Power, Galaxy A13), segundo benchmarks reproduzíveis com o Lighthouse em throttling 4x CPU.
Para dashboards internos acessados em MacBook Pro, essa diferença é imperceptível. Para produtos B2C acessados em celulares de entrada, ela importa. Contexto define a resposta.
A posição deste blog
CSS Modules é a escolha padrão mais segura para projetos React em 2025. Funciona com Server Components, tem escopo automático, zero runtime, cache de CDN, e a DX de colocation é boa o suficiente (arquivo .module.css ao lado do componente). Combine com CSS custom properties para theming e com as features nativas do CSS moderno (nesting, @layer, container queries) para cobrir casos que antes exigiam JavaScript.
CSS-in-JS runtime é uma ferramenta legítima para design systems com theming pesado em SPAs client-side. Fora desse nicho, o custo de runtime e a incompatibilidade com RSC tornam a escolha difícil de defender tecnicamente.
Vanilla CSS é a aposta de longo prazo para times que investem em arquitetura frontend limpa e querem zero dependência de abstração. Exige disciplina de convenção, mas recompensa com a melhor performance possível e a menor superfície de breaking changes entre versões de framework.
FAQ
CSS-in-JS com extração em build (como StyleX ou Pigment CSS) resolve o problema de runtime?
Sim, parcialmente. Essas ferramentas mantêm a DX de escrever estilos em JavaScript/TypeScript, mas compilam para CSS estático em build. O trade-off é a complexidade do toolchain: você adiciona um compilador Babel/SWC plugin, e bugs nesse compilador são difíceis de diagnosticar. Se o time aceita essa complexidade, é uma alternativa viável. Mas CSS Modules entrega resultado similar com zero configuração extra.
Tailwind CSS não apareceu na comparação. Por quê?
Tailwind é uma estratégia de autoria (utility-first), não uma estratégia de escopo. Ele gera Vanilla CSS no output. A decisão "Tailwind ou não" é ortogonal a "CSS Modules ou Vanilla CSS". Dá para usar Tailwind com CSS Modules (via @apply, embora o próprio time do Tailwind desencoraje). Tailwind merece um post próprio com análise de trade-offs específicos.
Como migrar incrementalmente de styled-components para CSS Modules?
Crie uma regra no linter do projeto (ESLint com no-restricted-imports) que avisa ao importar styled-components em arquivos novos. Componentes existentes continuam funcionando. Novos componentes nascem com CSS Modules. Quando um componente antigo precisa de refatoração significativa, migre o estilo junto. Não faça uma migração big-bang: ela para no meio e deixa o codebase pior.
CSS Modules suportam nesting nativo?
Sim, desde que o bundler (Vite, webpack com css-loader 7+, Next.js 15+) processe o CSS com um parser que entende nesting nativo. O suporte de browsers para nesting nativo está acima de 90% em dezembro de 2024, segundo dados do Can I Use. Se o projeto precisa suportar browsers mais antigos, o PostCSS com o plugin postcss-nesting faz o downlevel automaticamente.
Qual o impacto de estilização na nota do Core Web Vitals?
CSS-in-JS runtime afeta Interaction to Next Paint (INP) porque adiciona trabalho JavaScript no main thread durante interações que causam re-render. CSS estático (Modules ou Vanilla) não tem esse impacto. Para Largest Contentful Paint (LCP), CSS-in-JS pode atrasar a pintura porque os estilos só existem depois que o JavaScript executa, enquanto CSS estático é parseado em paralelo com o HTML. Se Core Web Vitals é meta do projeto, CSS estático é a escolha direta.

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.


