Ir para o conteúdo
·24 min de leitura

CSS Moderno em 2026: Os Recursos Que Mudaram Como Eu Escrevo Estilos

Container queries, CSS layers, :has(), color-mix(), nesting, animações por scroll e anchor positioning. Os recursos CSS que me fizeram parar de recorrer ao JavaScript.

Compartilhar:X / TwitterLinkedIn

Deletei meu último arquivo Sass há seis meses. Não porque estava fazendo uma declaração. Porque genuinamente não precisava mais.

Por mais de uma década, CSS era a linguagem pela qual nos desculpávamos. Precisávamos de pré-processadores para nesting e variáveis. Precisávamos de JavaScript para dimensionamento baseado em container, animações vinculadas ao scroll, seleção de pai, e metade dos padrões de layout que designers nos entregavam. Construímos sistemas runtime inteiros — bibliotecas CSS-in-JS, frameworks de utilidades, cadeias de plugins PostCSS — para compensar o que a linguagem não conseguia fazer nativamente.

Essa era acabou. Não "quase acabou." Não "chegando lá." Acabou.

Os recursos que chegaram aos navegadores entre 2024 e 2026 não apenas adicionaram conveniência. Eles mudaram o modelo mental. CSS não é mais uma linguagem de estilização contra a qual você luta. É uma linguagem de estilização que realmente pensa em componentes, gestão de especificidade, design responsivo no nível do elemento, e animação sem runtime.

Aqui está o que mudou, por que importa, e o que você pode parar de fazer por causa disso.

Container Queries: O Fim de "Qual É a Largura do Viewport?"#

Este é o que fundamentalmente alterou como eu penso em design responsivo. Por vinte anos, tivemos media queries. Media queries perguntam: "qual é a largura do viewport?" Container queries perguntam: "qual é a largura do container onde este componente vive?"

Essa distinção parece sutil. Não é. É a diferença entre componentes que só funcionam em um contexto de layout e componentes que funcionam em qualquer lugar.

O Problema Que Existiu por Duas Décadas#

Considere um componente de card. Em uma sidebar, ele deveria empilhar verticalmente com uma imagem pequena. Em uma área de conteúdo principal, deveria ficar horizontal com uma imagem maior. Em uma seção hero full-width, deveria ser algo completamente diferente.

Com media queries, você escreveria algo assim:

css
/* O jeito antigo: acoplando estilos de componente ao layout da página */
.card {
  display: flex;
  flex-direction: column;
}
 
@media (min-width: 768px) {
  .sidebar .card {
    /* Ainda vertical na sidebar */
  }
 
  .main-content .card {
    flex-direction: row;
    /* Horizontal na área principal */
  }
}
 
@media (min-width: 1200px) {
  .hero .card {
    /* Mais um layout */
  }
}

O card sabe sobre .sidebar, .main-content e .hero. Ele sabe sobre a página. Não é mais um componente — é um fragmento ciente da página. Mova para uma página diferente e tudo quebra.

Container Queries Resolvem Isso Completamente#

css
/* O jeito container query: componente sabe apenas sobre si mesmo */
.card-wrapper {
  container-type: inline-size;
  container-name: card;
}
 
.card {
  display: grid;
  grid-template-columns: 1fr;
  gap: 1rem;
}
 
@container card (min-width: 400px) {
  .card {
    grid-template-columns: 200px 1fr;
  }
}
 
@container card (min-width: 700px) {
  .card {
    grid-template-columns: 300px 1fr;
    gap: 2rem;
    font-size: 1.125rem;
  }
}

O card não sabe onde vive. Não se importa. Coloque em uma sidebar de 300px e é vertical. Coloque em uma área principal de 700px e é horizontal. Jogue em uma seção full-width e ele se adapta. Zero conhecimento do layout da página necessário.

inline-size vs size#

Quase sempre você vai querer container-type: inline-size. Isso habilita queries no eixo inline (largura em modos de escrita horizontais). Usar container-type: size habilita queries em ambos os eixos inline e block, mas requer que o container tenha dimensionamento explícito em ambas as dimensões, o que quebra o fluxo normal do documento na maioria dos casos.

css
/* Isso é o que você quer 99% das vezes */
.wrapper {
  container-type: inline-size;
}
 
/* Isso requer altura explícita — raramente o que você quer */
.wrapper-both {
  container-type: size;
  height: 500px; /* obrigatório, ou colapsa */
}

Containers Nomeados para Contextos Aninhados#

Quando você aninha containers, nomeação se torna essencial:

css
.page-layout {
  container-type: inline-size;
  container-name: layout;
}
 
.sidebar {
  container-type: inline-size;
  container-name: sidebar;
}
 
/* Alvo a sidebar especificamente, não o ancestral mais próximo */
@container sidebar (max-width: 250px) {
  .nav-item {
    font-size: 0.875rem;
    padding: 0.25rem;
  }
}
 
/* Alvo o layout da página */
@container layout (min-width: 1200px) {
  .page-header {
    font-size: 2.5rem;
  }
}

Sem nomes, @container consulta o ancestral mais próximo com containment. Com aninhamento, frequentemente não é o container que você quer. Nomeie-os. Sempre.

Unidades de Container Query#

Este é subestimado. Unidades de container query (cqi, cqb, cqw, cqh) permitem dimensionar coisas relativas ao container, não ao viewport:

css
@container (min-width: 400px) {
  .card-title {
    font-size: clamp(1rem, 4cqi, 2rem);
  }
}

4cqi é 4% do tamanho inline do container. O título escala com o container, não com a janela. É isso que tipografia fluida deveria ter sido desde o início.

CSS Layers: A Guerra de Especificidade Acabou#

Se container queries mudaram como eu penso em design responsivo, @layer mudou como eu penso em arquitetura CSS. Pela primeira vez, temos uma maneira sã e declarativa de gerenciar especificidade em um projeto inteiro.

O Problema#

Especificidade CSS é um sistema de pontos que não se importa com suas intenções. Uma classe utilitária como .text-red perde para .card .title porque o último tem maior especificidade. A solução era sempre a mesma: torne seus seletores mais específicos, adicione !important, ou reestruture tudo.

Construímos metodologias inteiras (BEM, SMACSS, ITCSS) e toolchains apenas para evitar conflitos de especificidade. Tudo isso era um workaround para um recurso ausente da linguagem.

Ordenação de Layers#

@layer permite declarar a ordem em que grupos de estilos são considerados, independente da especificidade dentro desses grupos:

css
/* Declare a ordem dos layers — esta única linha controla tudo */
@layer reset, base, components, utilities, overrides;
 
@layer reset {
  *,
  *::before,
  *::after {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
  }
}
 
@layer base {
  body {
    font-family: system-ui, sans-serif;
    line-height: 1.6;
    color: oklch(20% 0 0);
  }
 
  h1, h2, h3 {
    line-height: 1.2;
    text-wrap: balance;
  }
}
 
@layer components {
  .card {
    background: white;
    border-radius: 0.5rem;
    padding: 1.5rem;
    box-shadow: 0 1px 3px oklch(0% 0 0 / 0.12);
  }
 
  .card .title {
    font-size: 1.25rem;
    font-weight: 600;
    color: oklch(25% 0.05 260);
  }
}
 
@layer utilities {
  .text-red {
    color: oklch(55% 0.25 25);
  }
 
  .hidden {
    display: none;
  }
 
  .sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
  }
}

Mesmo que .card .title tenha maior especificidade que .text-red, a utilidade vence porque o layer utilities é declarado depois de components. Sem !important. Sem hacks de especificidade. A ordem dos layers é a palavra final.

Como o Tailwind CSS v4 Usa Layers#

Tailwind v4 se apoia fortemente em @layer. Quando você escreve @import "tailwindcss", obtém:

css
@layer theme, base, components, utilities;

Toda utilidade Tailwind vive no layer utilities. Seus estilos de componente customizados vão em components. É por isso que uma classe text-red-500 pode sobrescrever a cor de um componente sem precisar de !important — está em um layer posterior.

Se você está construindo seu próprio design system sem Tailwind, copie essa arquitetura. É a correta:

css
@layer reset, tokens, base, layouts, components, utilities, overrides;

Sete layers é suficiente. Nunca precisei de mais.

Estilos Sem Layer Vencem Tudo#

Um detalhe: estilos que não estão em nenhum layer têm a maior prioridade. Isso é realmente útil — significa que seus overrides específicos de página automaticamente vencem:

css
@layer components {
  .modal {
    background: white;
  }
}
 
/* Não está em nenhum layer — vence sobre tudo nos layers */
.special-page .modal {
  background: oklch(95% 0.02 260);
}

Mas também significa que CSS de terceiros que não é ciente de layers pode sobrescrever seu sistema inteiro. Envolva estilos de terceiros em um layer para controlá-los:

css
@layer third-party {
  @import url("some-library.css");
}

O Seletor :has(): O Seletor de Pai Que Sempre Quisemos#

Por literalmente décadas, desenvolvedores pediram um seletor de pai. "Quero estilizar um pai baseado nos seus filhos." A resposta era sempre "CSS não consegue fazer isso" seguida de um workaround em JavaScript. :has() muda isso completamente, e acontece que é ainda mais poderoso do que o que pedimos.

Seleção Básica de Pai#

css
/* Estilizar um form group quando seu input tem foco */
.form-group:has(input:focus) {
  border-color: oklch(55% 0.2 260);
  box-shadow: 0 0 0 3px oklch(55% 0.2 260 / 0.15);
}
 
/* Estilizar um card diferentemente quando contém uma imagem */
.card:has(img) {
  padding-top: 0;
}
 
.card:has(img) .card-content {
  padding: 1.5rem;
}
 
/* Um card sem imagem recebe tratamento diferente */
.card:not(:has(img)) {
  border-left: 4px solid oklch(55% 0.2 260);
}

Estados de Validação de Formulário Sem JavaScript#

É aqui que :has() fica genuinamente empolgante. Combinado com pseudo-classes de validação HTML, você pode construir UIs de formulário que respondem ao estado de validade com zero JavaScript:

css
/* O wrapper do campo reage à validade do seu input */
.field:has(input:invalid:not(:placeholder-shown)) {
  --field-color: oklch(55% 0.25 25);
}
 
.field:has(input:valid:not(:placeholder-shown)) {
  --field-color: oklch(55% 0.2 145);
}
 
.field {
  --field-color: oklch(70% 0 0);
  border: 2px solid var(--field-color);
  border-radius: 0.5rem;
  padding: 0.75rem;
  transition: border-color 0.2s;
}
 
.field label {
  color: var(--field-color);
  font-size: 0.875rem;
  font-weight: 500;
}
 
.field .error-message {
  display: none;
  color: oklch(55% 0.25 25);
  font-size: 0.8rem;
  margin-top: 0.25rem;
}
 
.field:has(input:invalid:not(:placeholder-shown)) .error-message {
  display: block;
}

A parte :not(:placeholder-shown) é crucial — impede que estilos de validação apareçam em campos vazios que ainda não foram tocados.

Layout Baseado na Contagem de Filhos#

Este padrão é absurdamente útil e era genuinamente impossível antes de :has():

css
/* Ajustar colunas do grid baseado em quantos itens existem */
.grid-auto:has(> :nth-child(4)) {
  grid-template-columns: repeat(2, 1fr);
}
 
.grid-auto:has(> :nth-child(7)) {
  grid-template-columns: repeat(3, 1fr);
}
 
.grid-auto:has(> :nth-child(13)) {
  grid-template-columns: repeat(4, 1fr);
}

O grid muda sua contagem de colunas baseado em quantos filhos tem. Sem JavaScript. Sem ResizeObserver. Sem toggle de classes.

Detecção de Sidebar#

Um dos meus padrões favoritos — mudar o layout baseado em se uma sidebar existe:

css
.page-layout {
  display: grid;
  grid-template-columns: 1fr;
  gap: 2rem;
}
 
/* Se o layout contém uma sidebar, muda para duas colunas */
.page-layout:has(.sidebar) {
  grid-template-columns: 1fr 300px;
}
 
/* Ajustar largura do conteúdo principal quando sidebar está presente */
.page-layout:has(.sidebar) .main-content {
  max-width: 65ch;
}

Adicione um componente sidebar ao DOM, o layout se ajusta. Remova, se ajusta de volta. O CSS é a fonte de verdade, não uma variável de estado.

Combinando :has() com Outros Seletores#

:has() compõe lindamente com tudo mais:

css
/* Estilizar um artigo apenas quando tem uma classe específica de figure */
article:has(figure.full-bleed) {
  overflow: visible;
}
 
/* Uma navegação que muda quando contém um input de busca */
nav:has(input[type="search"]:focus) {
  background: oklch(98% 0 0);
  box-shadow: 0 2px 8px oklch(0% 0 0 / 0.08);
}
 
/* Habilitar dark-mode no nível do componente */
.theme-switch:has(input:checked) ~ main {
  color-scheme: dark;
  background: oklch(15% 0 0);
  color: oklch(90% 0 0);
}

O suporte dos navegadores é excelente agora. Todo navegador moderno suporta :has() desde o início de 2024. Não há razão para não usar.

CSS Nesting: Nativo, Finalmente Estável#

Não vou exagerar neste. CSS nesting é legal. Não é revolucionário como container queries ou :has() são. Mas remove uma das últimas razões para usar Sass, e isso importa.

A Sintaxe#

css
.card {
  background: white;
  border-radius: 0.5rem;
  padding: 1.5rem;
 
  .title {
    font-size: 1.25rem;
    font-weight: 600;
    margin-bottom: 0.5rem;
  }
 
  .description {
    color: oklch(40% 0 0);
    line-height: 1.7;
  }
 
  &:hover {
    box-shadow: 0 4px 12px oklch(0% 0 0 / 0.1);
  }
 
  &.featured {
    border: 2px solid oklch(60% 0.2 260);
  }
}

Isso é CSS válido. Sem etapa de build. Sem pré-processador. Sem plugin PostCSS. O navegador lida nativamente.

Diferenças do Sass#

Existem algumas diferenças de sintaxe que vale a pena conhecer:

css
/* CSS Nesting — funciona agora em todos os navegadores */
.parent {
  /* Aninhamento direto de classe/elemento funciona sem & */
  .child {
    color: red;
  }
 
  /* & é obrigatório para pseudo-classes e seletores compostos */
  &:hover {
    background: blue;
  }
 
  &.active {
    font-weight: bold;
  }
 
  /* Media queries aninhadas — isso é ótimo */
  @media (width >= 768px) {
    flex-direction: row;
  }
}

Nas implementações iniciais, você precisava de & antes de seletores de elemento (como & p ao invés de apenas p). Essa restrição foi relaxada. A partir de 2025, todos os navegadores principais suportam aninhamento bare de elemento: .parent { p { ... } } funciona perfeitamente.

Media Queries Aninhadas#

Este é o recurso matador do CSS nesting, na minha opinião. Não o aninhamento de seletores — a capacidade de colocar media queries dentro de um bloco de regra:

css
.hero {
  padding: 2rem;
  font-size: 1rem;
 
  @media (width >= 768px) {
    padding: 4rem;
    font-size: 1.25rem;
  }
 
  @media (width >= 1200px) {
    padding: 6rem;
    font-size: 1.5rem;
  }
}

Compare com o jeito antigo onde seus estilos .hero estavam espalhados por três blocos @media diferentes, possivelmente centenas de linhas separados. Nesting mantém o comportamento responsivo co-localizado com o componente. A legibilidade melhora dramaticamente.

Não Aninhe Demais#

Um aviso: só porque você pode aninhar seis níveis de profundidade não significa que deveria. O mesmo conselho do Sass se aplica aqui. Se seu aninhamento cria seletores como .page .section .card .content .text .highlight, você criou um monstro de especificidade e um pesadelo de manutenção. Dois ou três níveis é o ponto ideal.

css
/* Bom — dois níveis */
.nav {
  .link {
    color: inherit;
 
    &:hover {
      color: oklch(55% 0.2 260);
    }
  }
}
 
/* Ruim — pesadelo de especificidade */
.page {
  .layout {
    .sidebar {
      .nav {
        .list {
          .item {
            .link {
              color: red; /* boa sorte sobrescrevendo isso */
            }
          }
        }
      }
    }
  }
}

Funções de Cor: oklch() e color-mix() Mudam Tudo#

hsl() teve uma boa trajetória. Era mais intuitivo que rgb(). Mas tem uma falha fundamental: não é perceptualmente uniforme. Um hsl(60, 100%, 50%) (amarelo) parece dramaticamente mais claro ao olho humano que hsl(240, 100%, 50%) (azul), mesmo tendo o mesmo valor de luminosidade.

Por Que oklch() Vence#

oklch() é perceptualmente uniforme. Valores iguais de luminosidade parecem igualmente luminosos. Isso importa enormemente ao gerar paletas de cores, criar temas e garantir contraste acessível:

css
:root {
  /* oklch(luminosidade croma matiz) */
  --color-primary: oklch(55% 0.2 260);     /* azul */
  --color-secondary: oklch(55% 0.2 330);   /* roxo */
  --color-success: oklch(55% 0.2 145);     /* verde */
  --color-danger: oklch(55% 0.25 25);      /* vermelho */
  --color-warning: oklch(75% 0.18 85);     /* amarelo — L mais alto para percepção igual */
 
  /* Todos estes parecem igualmente "médios" ao olho humano */
  /* Com HSL, você precisaria de valores de luminosidade diferentes para cada matiz */
}

Os três valores são intuitivos quando você os entende:

  • Luminosidade (0% a 100%): Quão claro ou escuro. 0% é preto, 100% é branco.
  • Croma (0 a ~0.37): Quão vívido. 0 é cinza, maior é mais saturado.
  • Matiz (0 a 360): O ângulo da cor. 0/360 é vermelho rosado, 145 é verde, 260 é azul.

color-mix() para Cores Derivadas#

color-mix() permite criar cores a partir de outras cores em tempo de execução. Sem função darken() do Sass. Sem JavaScript. Apenas CSS:

css
:root {
  --brand: oklch(55% 0.2 260);
 
  /* Clarear misturando com branco */
  --brand-light: color-mix(in oklch, var(--brand) 30%, white);
 
  /* Escurecer misturando com preto */
  --brand-dark: color-mix(in oklch, var(--brand) 70%, black);
 
  /* Criar um fundo sutil */
  --brand-bg: color-mix(in oklch, var(--brand) 8%, white);
 
  /* Versão semi-transparente */
  --brand-overlay: color-mix(in oklch, var(--brand) 50%, transparent);
}
 
.button {
  background: var(--brand);
  color: white;
 
  &:hover {
    background: var(--brand-dark);
  }
 
  &:active {
    background: color-mix(in oklch, var(--brand) 60%, black);
  }
}
 
.button.secondary {
  background: var(--brand-bg);
  color: var(--brand-dark);
 
  &:hover {
    background: color-mix(in oklch, var(--brand) 15%, white);
  }
}

A parte in oklch importa. Misturar em srgb dá cores intermediárias turvas. Misturar em oklch dá resultados perceptualmente uniformes. Sempre misture em oklch.

Construindo uma Paleta Completa com oklch()#

Veja como eu gero uma paleta inteira de tons a partir de uma única matiz:

css
:root {
  --hue: 260;
  --chroma: 0.2;
 
  --color-50:  oklch(97% calc(var(--chroma) * 0.1) var(--hue));
  --color-100: oklch(93% calc(var(--chroma) * 0.2) var(--hue));
  --color-200: oklch(85% calc(var(--chroma) * 0.4) var(--hue));
  --color-300: oklch(75% calc(var(--chroma) * 0.6) var(--hue));
  --color-400: oklch(65% calc(var(--chroma) * 0.8) var(--hue));
  --color-500: oklch(55% var(--chroma) var(--hue));
  --color-600: oklch(48% var(--chroma) var(--hue));
  --color-700: oklch(40% calc(var(--chroma) * 0.9) var(--hue));
  --color-800: oklch(32% calc(var(--chroma) * 0.8) var(--hue));
  --color-900: oklch(25% calc(var(--chroma) * 0.7) var(--hue));
  --color-950: oklch(18% calc(var(--chroma) * 0.5) var(--hue));
}

Mude --hue para 145 e você tem uma paleta verde. Mude para 25 e tem vermelho. Os passos de luminosidade são perceptualmente uniformes. O croma diminui nos extremos para que os tons mais claros e escuros não fiquem supersaturados. Este é o tipo de coisa que costumava exigir uma ferramenta de design ou uma função Sass. Agora são oito linhas de CSS.

Animações por Scroll: Sem JavaScript Necessário#

Este é o recurso que me fez deletar mais JavaScript. Animações vinculadas ao scroll — barras de progresso, efeitos parallax, animações de revelação, headers fixos com transições — costumavam exigir IntersectionObserver, event listeners de scroll, ou uma biblioteca como GSAP. Agora é CSS.

Indicador de Progresso de Scroll#

A clássica "barra de progresso de leitura" no topo de uma página de artigo:

css
.progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  height: 3px;
  background: oklch(55% 0.2 260);
  transform-origin: left;
  z-index: 1000;
 
  animation: grow-progress linear both;
  animation-timeline: scroll();
}
 
@keyframes grow-progress {
  from {
    transform: scaleX(0);
  }
  to {
    transform: scaleX(1);
  }
}

É isso. O indicador inteiro de progresso de leitura. Sem JavaScript. Sem event listener de scroll. Sem requestAnimationFrame. Sem cálculo de "porcentagem scrollada". O binding animation-timeline: scroll() faz tudo.

Revelação no Scroll#

Elementos que aparecem com fade conforme entram no viewport:

css
.reveal {
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}
 
@keyframes reveal {
  from {
    opacity: 0;
    transform: translateY(30px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

animation-timeline: view() vincula a animação à visibilidade do elemento no viewport. animation-range: entry 0% entry 100% significa que a animação roda do momento que o elemento começa a entrar no viewport até estar totalmente visível.

Parallax Sem Biblioteca#

css
.parallax-bg {
  animation: parallax linear both;
  animation-timeline: scroll();
}
 
@keyframes parallax {
  from {
    transform: translateY(-20%);
  }
  to {
    transform: translateY(20%);
  }
}
 
.parallax-section {
  overflow: hidden;
  position: relative;
}

A imagem de fundo se move em uma taxa diferente do scroll, criando um efeito parallax. Suave, performático (o navegador pode compositar na GPU), e zero JavaScript.

Timelines de Scroll Nomeadas#

Para mais controle, você pode nomear timelines de scroll e referenciá-las de outros elementos:

css
.scroller {
  overflow-y: auto;
  scroll-timeline-name: --my-scroller;
  scroll-timeline-axis: block;
}
 
/* Um elemento em qualquer lugar do DOM pode referenciar essa timeline */
.indicator {
  animation: progress linear both;
  animation-timeline: --my-scroller;
}
 
@keyframes progress {
  from { width: 0%; }
  to { width: 100%; }
}

Isso funciona mesmo quando o indicador e o scroller não são pai/filho. Qualquer elemento pode se vincular a qualquer timeline de scroll nomeada. Isso é poderoso para UIs de dashboard onde um painel scrollável controla um indicador em um header fixo.

Anchor Positioning: Tooltips e Popovers do Jeito Certo#

Antes do anchor positioning, conectar um tooltip ao seu elemento gatilho exigia JavaScript. Você calculava posições com getBoundingClientRect(), lidava com offsets de scroll, gerenciava colisões de viewport, e recalculava no resize. Bibliotecas como Popper.js (agora Floating UI) existiam especificamente porque isso era tão difícil de acertar.

CSS anchor positioning torna isso declarativo:

css
.trigger {
  anchor-name: --my-trigger;
}
 
.tooltip {
  position: fixed;
  position-anchor: --my-trigger;
 
  /* Posicionar o centro-topo do tooltip no centro-baixo do gatilho */
  top: anchor(bottom);
  left: anchor(center);
  translate: -50% 0;
 
  /* Posicionamento de fallback se transbordar o viewport */
  position-try-fallbacks: flip-block, flip-inline;
}

Tratamento Automático de Colisão com Viewport#

A propriedade position-try-fallbacks é a parte que teria levado 200 linhas de JavaScript. Ela diz ao navegador: "se o tooltip transbordar o viewport na parte inferior, vire para o topo. Se transbordar na direita, vire para a esquerda."

css
.dropdown-menu {
  position: fixed;
  position-anchor: --menu-button;
 
  /* Padrão: abaixo do botão, alinhado à borda esquerda */
  top: anchor(bottom);
  left: anchor(left);
 
  /* Se não cabe embaixo, tente acima. Se não cabe alinhado à esquerda, tente à direita */
  position-try-fallbacks: flip-block, flip-inline;
 
  /* Adicionar espaço entre a âncora e o dropdown */
  margin-top: 4px;
}

Fallbacks de Posição Nomeados#

Para mais controle sobre posições de fallback, você pode definir opções try customizadas:

css
@position-try --above-right {
  bottom: anchor(top);
  right: anchor(right);
  top: auto;
  left: auto;
  margin-top: 0;
  margin-bottom: 4px;
}
 
@position-try --left-center {
  right: anchor(left);
  top: anchor(center);
  left: auto;
  bottom: auto;
  translate: 0 -50%;
  margin-top: 0;
  margin-right: 4px;
}
 
.tooltip {
  position: fixed;
  position-anchor: --trigger;
  top: anchor(bottom);
  left: anchor(center);
  translate: -50% 0;
  margin-top: 4px;
 
  position-try-fallbacks: --above-right, --left-center;
}

O navegador tenta cada fallback em ordem até encontrar um que mantenha o elemento dentro do viewport. Este é o tipo de raciocínio espacial que era genuinamente doloroso em JavaScript.

Ancoragem com a API Popover#

Anchor positioning combina perfeitamente com a nova API Popover:

html
<button popovertarget="my-popover" style="anchor-name: --btn">Settings</button>
 
<div popover id="my-popover" style="
  position-anchor: --btn;
  top: anchor(bottom);
  left: anchor(center);
  translate: -50% 0;
  margin-top: 8px;
">
  Conteúdo do popover aqui
</div>

Sem JavaScript para mostrar/esconder (a API Popover cuida disso). Sem JavaScript para posicionamento (anchor positioning cuida disso). Sem JavaScript para comportamento de light-dismiss (a API Popover cuida disso também). O padrão inteiro de tooltip/popover/dropdown — um padrão que alimentava pacotes npm inteiros — agora é HTML e CSS.

CSS Grid Subgrid: Alinhamento Aninhado Que Realmente Funciona#

Grid é poderoso, mas tinha uma limitação frustrante: um grid filho não conseguia alinhar seus itens ao grid pai. Se você tinha uma fileira de cards e queria que o título, conteúdo e rodapé de cada card se alinhassem entre os cards, sem sorte. O grid interno de cada card era independente.

Subgrid resolve isso.

O Problema de Alinhamento de Cards#

css
/* Grid pai */
.card-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1.5rem;
}
 
/* Cada card se torna um subgrid, alinhando linhas ao pai */
.card {
  display: grid;
  grid-row: span 3; /* Card ocupa 3 linhas no pai */
  grid-template-rows: subgrid;
  gap: 0; /* Card controla seu próprio gap interno */
}
 
.card-title {
  font-weight: 600;
  padding: 1rem 1rem 0.5rem;
}
 
.card-body {
  padding: 0 1rem;
  color: oklch(40% 0 0);
}
 
.card-footer {
  padding: 0.5rem 1rem 1rem;
  margin-top: auto;
  border-top: 1px solid oklch(90% 0 0);
}

Agora os títulos entre os três cards se alinham na mesma linha. Os corpos se alinham. Os rodapés se alinham. Mesmo quando um card tem um título de duas linhas e outro tem um título de uma linha, o alinhamento é mantido pelo grid pai.

Antes do Subgrid: Os Gambiarras#

Sem subgrid, alcançar esse alinhamento exigia:

  1. Alturas fixas (frágil, quebra com conteúdo dinâmico)
  2. Medição com JavaScript (lento, flash de desalinhamento)
  3. Desistir e aceitar o desalinhamento (comum, feio)
css
/* A gambiarra antiga — frágil e quebra com conteúdo dinâmico */
.card-title {
  min-height: 3rem; /* reze para que nenhum título exceda isso */
}
 
.card-body {
  min-height: 8rem; /* mais rezas ainda */
}

Subgrid torna a gambiarra desnecessária. O grid pai distribui alturas de linha baseado no conteúdo mais alto em cada linha entre todos os cards.

Subgrid de Colunas para Layouts de Formulário#

Subgrid funciona em colunas também, o que é perfeito para layouts de formulário:

css
.form {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0.75rem 1rem;
}
 
.form-row {
  display: grid;
  grid-column: 1 / -1;
  grid-template-columns: subgrid;
  align-items: center;
}
 
.form-row label {
  grid-column: 1;
  text-align: right;
  font-weight: 500;
}
 
.form-row input {
  grid-column: 2;
  padding: 0.5rem;
  border: 1px solid oklch(80% 0 0);
  border-radius: 0.375rem;
}

Todos os labels se alinham. Todos os inputs se alinham. A coluna de label auto-dimensiona para o label mais largo. Sem larguras hardcoded.

API View Transitions: Navegação Tipo SPA Sem Framework#

Este é o recurso mais ambicioso desta lista. A API View Transitions permite animar entre navegações de página — incluindo navegações cross-document (cliques normais em links em um app multi-página). Seu site HTML estático agora pode ter transições de página suaves e animadas.

Transições Cross-Document#

Para habilitar transições de view cross-document, você adiciona uma única regra CSS em ambas as páginas, antiga e nova:

css
@view-transition {
  navigation: auto;
}

É isso. O navegador agora fará cross-fade entre páginas ao navegar. A transição padrão é um fade de opacidade suave. Sem JavaScript. Sem framework. Apenas duas linhas de CSS em cada página.

Customizando a Transição#

Você pode customizar quais transições acontecem nomeando elementos específicos:

css
/* Em ambas as páginas */
.page-header {
  view-transition-name: header;
}
 
.main-content {
  view-transition-name: content;
}
 
.hero-image {
  view-transition-name: hero;
}
 
/* Animações de transição */
::view-transition-old(content) {
  animation: slide-out 0.3s ease-in both;
}
 
::view-transition-new(content) {
  animation: slide-in 0.3s ease-out both;
}
 
::view-transition-old(hero) {
  animation: fade-out 0.2s ease-in both;
}
 
::view-transition-new(hero) {
  animation: none; /* O novo hero apenas aparece */
}
 
@keyframes slide-out {
  to {
    transform: translateX(-100%);
    opacity: 0;
  }
}
 
@keyframes slide-in {
  from {
    transform: translateX(100%);
    opacity: 0;
  }
}
 
@keyframes fade-out {
  to {
    opacity: 0;
  }
}

Ao navegar entre duas páginas que ambas têm um .hero-image com view-transition-name: hero, o navegador automaticamente anima a imagem da sua posição na página antiga para sua posição na página nova. É o padrão "shared element transition" do desenvolvimento mobile, agora no navegador.

View Transitions em SPA#

Para single-page apps (React, Vue, Svelte, etc.), a API JavaScript é direta:

css
/* Lado CSS — defina suas animações de transição */
::view-transition-old(root) {
  animation: fade-out 0.2s ease-in;
}
 
::view-transition-new(root) {
  animation: fade-in 0.2s ease-out;
}
javascript
// Lado JS — envolva sua atualização do DOM em startViewTransition
document.startViewTransition(() => {
  // Atualize o DOM aqui — React render, troca de innerHTML, etc.
  updateContent(newPageData);
});

O navegador faz snapshot do estado antigo, roda sua atualização, faz snapshot do estado novo, e anima entre eles. Elementos nomeados recebem transições individuais; todo o resto recebe um crossfade padrão.

Respeitando Preferências do Usuário#

Sempre respeite prefers-reduced-motion:

css
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

O Que Você Pode Parar de Usar#

Esses recursos coletivamente eliminam muitas ferramentas, bibliotecas e padrões dos quais dependemos por anos. Aqui está o que eu removi ou parei de usar:

Sass/SCSS para Nesting e Variáveis#

CSS tem nesting nativo. CSS tem custom properties (e já tem há anos). As duas principais razões pelas quais as pessoas recorriam ao Sass agora estão na linguagem. Se você ainda usa Sass apenas para $variables e nesting, pode parar.

css
/* Antes: Sass */
$primary: #3b82f6;
 
.card {
  background: white;
  border: 1px solid lighten($primary, 40%);
 
  &:hover {
    border-color: $primary;
  }
 
  .title {
    color: darken($primary, 15%);
  }
}
 
/* Depois: CSS Nativo */
.card {
  --primary: oklch(55% 0.2 260);
 
  background: white;
  border: 1px solid color-mix(in oklch, var(--primary) 20%, white);
 
  &:hover {
    border-color: var(--primary);
  }
 
  .title {
    color: color-mix(in oklch, var(--primary) 70%, black);
  }
}

A versão CSS é mais poderosa — oklch dá manipulação de cor perceptualmente uniforme, color-mix funciona em tempo de execução (o lighten do Sass é apenas em tempo de compilação), e custom properties podem ser mudadas dinamicamente via JavaScript ou media queries.

JavaScript para Animações de Scroll#

Delete seu código IntersectionObserver de revelação no scroll. Delete seu JavaScript de barra de progresso de scroll. Delete seu handler de parallax. animation-timeline: scroll() e animation-timeline: view() lidam com tudo isso com melhor performance (thread do compositor, não thread principal).

javascript
// Antes: JavaScript que você pode deletar
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        entry.target.classList.add('visible');
        observer.unobserve(entry.target);
      }
    });
  },
  { threshold: 0.1 }
);
 
document.querySelectorAll('.reveal').forEach((el) => observer.observe(el));
css
/* Depois: CSS que substitui todo o código acima */
.reveal {
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}
 
@keyframes reveal {
  from { opacity: 0; transform: translateY(20px); }
  to { opacity: 1; transform: translateY(0); }
}

Popper.js / Floating UI para Posicionamento#

Se você usa Floating UI (anteriormente Popper.js) apenas para posicionamento básico de tooltip/popover, CSS anchor positioning substitui. Você perde alguns recursos avançados (elementos virtuais, middleware customizado), mas para os 90% de uso — "coloque este popover perto daquele botão e mantenha dentro do viewport" — CSS faz nativamente agora.

Hacks de Centralização com margin: auto#

Isso não é novo, mas ainda vejo margin: 0 auto em todo lugar. Ferramentas modernas de layout tornam desnecessário na maioria dos casos:

css
/* Jeito antigo */
.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 1rem;
}
 
/* Melhor: CSS Grid ou Flexbox */
.page {
  display: grid;
  place-items: center;
}
 
/* Ou: container queries tornam o próprio container responsivo */
.content {
  container-type: inline-size;
  width: min(100% - 2rem, 1200px);
  margin-inline: auto; /* OK, ainda auto, mas margin-inline é mais claro */
}

margin: 0 auto ainda funciona perfeitamente. Mas se você se pega usando para centralização vertical ou alinhamento complexo, use flexbox ou grid.

Media Queries para Responsividade no Nível do Componente#

Este é o grande. Se você está escrevendo @media queries para tornar um componente responsivo, provavelmente está fazendo errado agora. Container queries devem ser seu padrão para responsividade no nível do componente. Reserve @media queries para decisões no nível da página: mudanças de layout, padrões de navegação, estilos de impressão.

css
/* Antes: Media query para estilização de componente (escopo errado) */
@media (min-width: 768px) {
  .product-card {
    flex-direction: row;
  }
}
 
/* Depois: Container query (escopo correto) */
.product-card-wrapper {
  container-type: inline-size;
}
 
@container (min-width: 400px) {
  .product-card {
    flex-direction: row;
  }
}

O Quadro Geral#

Esses recursos não existem isoladamente. Eles se compõem. Container queries + :has() + nesting + oklch() + layers — usados juntos — dão uma experiência de autoria CSS que seria irreconhecível cinco anos atrás:

css
@layer components {
  .card-wrapper {
    container-type: inline-size;
  }
 
  .card {
    --accent: oklch(55% 0.2 260);
 
    display: grid;
    gap: 1rem;
    padding: 1.5rem;
    border-radius: 0.5rem;
    background: white;
    border: 1px solid oklch(90% 0 0);
 
    &:has(img) {
      padding-top: 0;
 
      img {
        border-radius: 0.5rem 0.5rem 0 0;
        width: 100%;
        aspect-ratio: 16 / 9;
        object-fit: cover;
      }
    }
 
    &:has(.badge.new) {
      border-color: var(--accent);
    }
 
    .title {
      font-size: 1.125rem;
      font-weight: 600;
      color: oklch(20% 0 0);
      text-wrap: balance;
    }
 
    .meta {
      font-size: 0.875rem;
      color: oklch(50% 0 0);
    }
 
    @container (min-width: 500px) {
      grid-template-columns: 200px 1fr;
 
      &:has(img) {
        padding: 0;
 
        img {
          border-radius: 0.5rem 0 0 0.5rem;
          height: 100%;
          aspect-ratio: auto;
        }
      }
 
      .content {
        padding: 1.5rem;
      }
    }
  }
}

Aquele único bloco lida com:

  • Layout responsivo no nível do componente (container queries)
  • Estilização condicional baseada em conteúdo (:has())
  • Organização limpa de seletores (nesting)
  • Cores perceptualmente uniformes (oklch)
  • Gestão previsível de especificidade (@layer)

Sem pré-processador. Sem runtime CSS-in-JS. Sem classes utilitárias para isso. Apenas CSS, fazendo o que CSS deveria ter feito o tempo todo.

A plataforma alcançou. Finalmente. E valeu a espera.

Posts Relacionados