Pular para o conteúdo
Fantástico Mundo de Jon
RSS

O Tailwind 4 saiu hoje com menos framework entre mim e o CSS

O motor foi reescrito, a configuração saiu do JavaScript e entrou no CSS, e cascade layers, container queries e OKLCH viraram parte da caixa. Os números de build são do anúncio oficial; o ganho que interessa é outro.

Com pressa? Peça o TL;DR ao Claude — ele lê a página e resume.

Todo projeto meu que passa de dois meses tem um tailwind.config.js que ninguém lê inteiro. Ele nasce com três cores e um breakpoint. Meio ano depois tem um extend de quarenta linhas, dois plugins que entraram para resolver um caso pontual e um content array apontando para uma pasta que já foi renomeada duas vezes.

Hoje saiu o Tailwind CSS 4.0, e esse arquivo deixou de ser necessário.

A manchete do anúncio é velocidade, e a velocidade é real: eles publicaram a tabela, e eu volto nela daqui a pouco. Mas o que me fez ler o post duas vezes não foi o build mais rápido. Foi perceber que a versão 4 devolve para o CSS um monte de trabalho que a versão 3 fazia em JavaScript porque, na época, o CSS não sabia fazer. Agora sabe. E quando o framework para de simular o que a plataforma já entrega, sobra menos framework no meio do caminho.

Esse é o assunto do post. Não é um changelog: se você quer a lista completa, ela está no anúncio oficial. O que eu quero explicar é por que essas mudanças, juntas, fazem sentido como arquitetura.

O que a reescrita comprou

O motor novo se chama Oxide, e ele não é novidade de hoje. A equipe abriu o trabalho em março de 2024, num post sobre o progresso da v4, e a frase que resume a decisão é deles: “Rust where it counts”. Ou seja, o framework inteiro continua em TypeScript, para permanecer extensível, e só as partes caras e paralelizáveis foram reescritas em Rust.

A outra metade da história é o Lightning CSS, que virou a única dependência externa do motor. Com ele dentro, o Tailwind passou a resolver @import, prefixo de fornecedor e aninhamento por conta própria. Na v3 isso era uma pilha: PostCSS orquestrando postcss-import, Autoprefixer e o plugin de nesting, cada um relendo e reescrevendo a mesma árvore de CSS. Aquele postcss.config.js com quatro entradas que todo mundo copiava de projeto em projeto era exatamente essa pilha exposta na cara do usuário.

Ilustração abstrata em faixa horizontal: à esquerda, dezenas de pequenos quadrados pretos espalhados de forma irregular sobre fundo claro; da esquerda para a direita eles vão se aproximando e se alinhando até se fundirem, no lado direito, num único bloco verde sólido.
Quatro pacotes de PostCSS coordenados por um arquivo de configuração viraram um binário só com o Lightning CSS embutido. O parser de CSS próprio, segundo o post da alpha, é mais de duas vezes mais rápido que o do PostCSS — e esse é o pedaço que roda em cima de cada arquivo, toda vez.

As duas ilustrações deste post foram geradas aqui, com FLUX na minha própria placa.

Agora os números. Estes são os que a equipe publicou hoje, medidos contra o Catalyst, o kit de UI deles:

Tipo de build v3.4 v4.0 Melhora
Build completo 378 ms 100 ms 3,78x
Rebuild incremental com CSS novo 44 ms 5 ms 8,8x
Rebuild incremental sem CSS novo 35 ms 0,192 ms 182x
Medianas publicadas no anúncio do Tailwind CSS 4.0 em 22/01/2025, medidas pela equipe do Tailwind contra o template Catalyst. Não é medição minha, e o anúncio não declara hardware nem número de repetições. A última linha original diz 192 µs; converti para milissegundos para as colunas alinharem.

O número que interessa é o de baixo, e ele é o menos citado. Build completo três vezes e meia mais rápido é bom, mas build completo você roda uma vez por deploy. O rebuild sem CSS novo é o que acontece o dia inteiro: você salva um arquivo, acrescenta um flex, um gap-4, um font-bold que já existem no projeto, e o Tailwind não precisa compilar nada. Esse caso saiu de 35 ms para 192 microssegundos, e é o que separa um salvamento que você percebe de um que você não percebe.

Vale registrar o que eu não medi. Não rodei nenhum desses builds na minha máquina, então não tenho como confirmar ou desmentir os multiplicadores em projeto real, com outro tamanho de código e outro disco. O que eu posso dizer é que a forma da melhora faz sentido com a arquitetura descrita: o custo que sumiu é o de reprocessar CSS que não mudou.

A configuração desceu do JavaScript para o CSS

Esta é a mudança que mais muda o dia a dia, e ela é mais profunda do que “mudou de arquivo”.

Na v3, o design do projeto morava em dois lugares que não conversavam. Um arquivo JavaScript com os tokens, e um arquivo CSS com três diretivas que não são CSS:

// tailwind.config.js
import type { Config } from 'tailwindcss';

export default {
  content: ['./src/**/*.{astro,html,js,jsx,md,mdx,ts,tsx}'],
  theme: {
    extend: {
      colors: {
        papel: '#ffffff',
        tinta: '#1c211d',
        verde: '#1f5d3a',
      },
      fontFamily: {
        titulo: ['Archivo', 'system-ui', 'sans-serif'],
        corpo: ['"Source Serif 4"', 'Georgia', 'serif'],
      },
      screens: { '3xl': '1920px' },
    },
  },
  plugins: [],
} satisfies Config;
/* entrada.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

Repare no que está acontecendo aí. @tailwind base não é sintaxe de CSS, é um marcador que só existe enquanto o PostCSS está rodando — se você abrir esse arquivo no navegador, ele não significa nada. E as cores estão em hexadecimal dentro de um objeto JavaScript, o que quer dizer que só o build sabe que existe uma cor chamada verde.

Na v4, os dois arquivos viram um, e o que sobra é CSS de verdade:

@import "tailwindcss";

@theme {
  --color-papel: oklch(1 0 0);
  --color-tinta: oklch(0.19 0.012 150);
  --color-verde: oklch(0.36 0.098 145);

  --font-titulo: "Archivo", system-ui, sans-serif;
  --font-corpo: "Source Serif 4", Georgia, serif;

  --breakpoint-3xl: 1920px;
}

Os prefixos não são estilo de nomenclatura, são a interface. --color- diz ao motor para gerar a família de cor inteira a partir daquele token: bg-verde, text-verde, border-verde, ring-verde e o resto. --breakpoint-3xl gera a variante 3xl:. --font-titulo gera font-titulo. Você declara o valor, o Tailwind deriva os utilitários.

E aí vem a parte que eu considero a mais importante do release inteiro: esses tokens saem no CSS final como variáveis de verdade, em :root. Não são um valor que o build inlinou e esqueceu.

:root {
  --color-papel: oklch(1 0 0);
  --color-tinta: oklch(0.19 0.012 150);
  --color-verde: oklch(0.36 0.098 145);
}

Isso resolve um atrito que eu tinha em todo projeto. Sempre existe um pedaço de CSS que não é utilitário: o estilo de um bloco de código gerado por um plugin de markdown, o ::selection, uma folha que o Astro embute dentro de um componente. Na v3, para usar a cor do tema nesses lugares, ou eu repetia o hexadecimal à mão (e ele saía de sincronia na primeira mudança), ou eu importava o tailwind.config.js dentro do build para ler o objeto. As duas saídas são feias. Agora é var(--color-verde) e acabou.

O mesmo vale para JavaScript em tempo de execução. Um gráfico que precisa da cor de acento lê getComputedStyle(document.documentElement).getPropertyValue('--color-verde') e recebe o mesmo valor que o CSS está usando naquele instante, incluindo o que mudou por tema escuro. Na v3, cor de gráfico era um segundo lugar onde a paleta vivia, e eu já vi as duas divergirem em produção.

As camadas de cascata agora são do navegador

O Tailwind sempre teve o conceito de camada. base, components, utilities: essa é a divisão que dá ordem ao CSS gerado e garante que um utilitário ganhe de um estilo de base. Só que, na v3, essa camada era uma ficção do build. O PostCSS lia o marcador @layer, decidia a ordem e emitia um arquivo plano, onde quem ganhava era a combinação de especificidade com posição no arquivo. O navegador nunca soube que existiam camadas.

Na v4, elas são as camadas reais do CSS:

@layer theme, base, components, utilities;

@layer utilities {
  .mx-6 {
    margin-inline: calc(var(--spacing) * 6);
  }
  .bg-blue-500\/50 {
    background-color: color-mix(in oklab, var(--color-blue-500) 50%, transparent);
  }
}
Ilustração abstrata: várias folhas retangulares empilhadas em cascata e vistas de lado, cada uma num tom diferente de verde, deslocadas umas das outras de modo que a ordem da pilha fica visível de fora.
Numa cascade layer nativa, quem está mais acima na pilha vence, independentemente de quantas classes o seletor da folha de baixo tenha. É a única regra de CSS em que a especificidade não decide.

A consequência prática aparece na hora que você escreve o seu próprio CSS. Estilo que não está dentro de nenhuma camada ganha de todo estilo que está — por definição, camadas sem nome vêm depois. Então aquele .cartao { padding: 2rem; } que você escreveu num componente vence p-4 sem precisar de !important e sem depender de qual arquivo o bundler colocou primeiro. Se você já brigou com o Tailwind por causa de um estilo de biblioteca que insistia em ganhar, essa era a briga.

Repare também no color-mix() da segunda regra, porque ele conta a mesma história por outro lado. O modificador de opacidade (bg-blue-500/50) sempre existiu, mas na v3 o framework precisava conhecer a cor em tempo de build para calcular o valor com alfa. Isso deixava de funcionar justamente onde a cor não é conhecida antes: currentColor, uma variável CSS, um valor herdado. Agora quem faz a mistura é o navegador, na hora, com o valor que estiver valendo. O framework saiu do meio.

Há um terceiro item na mesma linha, menos visível: propriedades customizadas registradas com @property. É o que permite animar um gradiente, porque uma variável registrada com syntax: "<color>" deixa de ser texto e passa a ser um valor que o navegador sabe interpolar.

Os três — camadas, color-mix() e @property — têm o mesmo formato de história. O framework fazia à mão, em JavaScript, com uma aproximação. Hoje ele delega, e a versão do navegador é melhor que a aproximação.

O que era plugin virou parte da caixa

Container query é o exemplo mais claro. Era um plugin oficial que você instalava e registrava no config. Agora vem junto:

<div class="@container">
  <div class="grid grid-cols-1 @sm:grid-cols-3 @lg:grid-cols-4">
    <!-- ... -->
  </div>
</div>

E existe a direção contrária, com @max-md:, para o caso em que é mais curto descrever o limite de cima:

<div class="@container">
  <div class="grid grid-cols-3 @max-md:grid-cols-1"></div>
</div>

Isso importa mais do que parece para quem escreve componente. Media query pergunta o tamanho da janela, que é a pergunta errada quase sempre: o mesmo cartão pode aparecer numa coluna estreita da barra lateral e no meio de uma grade larga, na mesma janela. Container query pergunta o tamanho do espaço em que o componente foi colocado, que é o que de fato decide o layout dele.

Transformações 3D também deixaram de precisar de CSS avulso:

<div class="perspective-distant">
  <article class="rotate-x-51 rotate-z-43 transform-3d">
    <!-- ... -->
  </article>
</div>

A paleta padrão foi refeita em OKLCH, aproveitando o gamut P3 dos monitores de hoje. Aqui eu tenho um viés declarado: as cores deste blog já estavam em OKLCH antes disso, porque OKLCH tem uma propriedade que hexadecimal não tem — o primeiro número é claridade percebida, então dá para prever contraste antes de calcular. Ver o padrão do framework migrar para lá foi uma boa notícia pessoal.

E os utilitários dinâmicos são a mudança mais silenciosa da lista. Na v3, se você quisesse mt-17 ou grid-cols-15, tinha que estender a escala no config, porque a escala era um conjunto fechado de valores. Na v4 esses valores são derivados de --spacing na hora, e w-17 funciona sem você ter pedido.

O content array acabou

Aquele array em que você declarava onde ficavam os arquivos de template não existe mais. A v4 detecta as fontes sozinha, com um conjunto de heurísticas: ignora o que está no seu .gitignore, para não sair varrendo node_modules e pasta de build, e ignora extensões binárias como imagem, vídeo e zip.

Quando a heurística erra por baixo, existe uma saída explícita, e ela também é CSS:

@import "tailwindcss";
@source "../node_modules/@my-company/ui-lib";

É um detalhe pequeno com um efeito desproporcional. O content mal configurado é, na minha experiência, a causa número um de “por que essa classe não está funcionando em produção”: alguém move uma pasta, ninguém atualiza o array, o build não reclama, as classes daquele diretório simplesmente não são geradas e você descobre em staging. Sumiu uma classe inteira de bug de configuração silenciosa.

O que isso muda no CSS que eu escrevo

Junte tudo e o padrão fica difícil de não ver.

O Tailwind 3 era, em grande medida, uma camada de JavaScript que gerava CSS. Ele mantinha em memória uma representação do seu design, resolvia opacidade fazendo aritmética de cor, simulava camadas decidindo ordem de saída, e para tudo isso precisava de um objeto JavaScript como fonte da verdade. Fazia sentido: em 2020, o CSS não tinha @layer implementado em toda parte, não tinha color-mix(), não tinha @property, e variável CSS ainda era vista como recurso avançado.

O Tailwind 4 delega ao CSS o que o CSS aprendeu a fazer nesse intervalo. O tema é um bloco de variáveis. As camadas são camadas. A opacidade é color-mix(). O resultado é que a distância entre o que você escreve e o que o navegador executa encolheu. E a parte que continua sendo do framework — gerar milhares de classes utilitárias com nomes consistentes — é exatamente a parte que máquina faz melhor que gente.

Isso conversa direto com um problema que eu descrevi no post de dezembro sobre montar um site pelo chat. Lá, o erro mais irritante era a classe inventada: o modelo devolvia shadow-soft, text-md, scale-102, nomes plausíveis que não existem no framework, e nada emitia erro. A classe não gera regra nenhuma, o elemento fica sem estilo e você corrige o sintoma no elemento errado.

A v4 mexe nessa fronteira, e mexe nos dois sentidos. Metade daquelas invenções deixou de ser invenção: w-17, mt-6, grid-cols-15, todo chute numérico fora da escala antiga agora resolve, porque a escala virou aberta. A outra metade continua exatamente igual. flex-center e shadow-soft não existem, nunca existiram, e continuam sem avisar. O que mudou foi onde a linha passa, não o fato de que ela é invisível.

Então a lição do post de dezembro sobrevive intacta, só que com um alcance diferente: restrição enunciada continua sendo a única coisa que separa o CSS que você quer do CSS médio da internet. O framework ficou mais rápido, mais moderno e mais fino. Ele continua sem opinião sobre a sua paleta, o seu espaçamento e o seu contraste, e essa parte continua sendo trabalho de quem projeta.

O que a migração cobra

Nada disso é de graça, e o custo tem três partes bem diferentes de tamanho.

A primeira é a mais séria, e é por onde eu decidiria se migro:

A segunda parte é a migração em si, e ela é menos dolorosa do que a lista de mudanças sugere, porque veio com ferramenta:

npx @tailwindcss/upgrade

Ela atualiza as dependências, converte o arquivo de configuração para CSS e mexe nos templates. Pede Node 20 ou mais novo. A própria documentação recomenda rodar numa branch nova e revisar o diff com calma, o que eu repito aqui porque é conselho que se dá em toda migração e que quase ninguém segue: uma ferramenta que reescreve template em massa é ótima quando você lê o que ela fez, e é uma fonte de bug estranho e demorado quando você aceita tudo de olhos fechados.

A terceira parte é a que não tem ferramenta: plugin de terceiro que dependia da API JavaScript. Enquanto o autor não publica versão compatível, você tem duas saídas, e nenhuma é boa. Pode reimplementar o que aquele plugin fazia como CSS seu, o que costuma ser mais fácil do que parece agora que utilitário customizado é uma regra em @layer utilities. Ou pode ficar na 3.4 até o ecossistema alcançar. Se o seu projeto depende de três ou quatro plugins da comunidade, essa é a conta que decide a data da sua migração, não a velocidade do build.

Meu plano, para ser concreto: projeto novo nasce na 4.0 a partir de hoje. Projeto em produção com plugin de terceiro espera. Não porque a versão pareça instável, mas porque a única razão para migrar às pressas seria o build mais rápido, e build mais rápido não paga uma tarde caçando por que um dropdown parou de fechar.

Vou migrar este blog quando tiver o dia inteiro para olhar o diff, e aí eu meço o antes e o depois na minha máquina, com o meu volume de CSS, e publico o número aqui.

Fica uma frase que eu levo desta versão para além do Tailwind. A melhor coisa que uma ferramenta pode fazer, depois de anos resolvendo um problema da plataforma, é devolver o problema para a plataforma quando ela finalmente aprende a resolvê-lo. É o oposto do instinto de quem mantém software, que é acumular. Dá para medir a qualidade de uma dependência pelo tanto que ela encolhe quando o mundo embaixo dela melhora.