Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIntermediáriocódigo testado

:has() no CSS: o seletor que olha para o filho

Como estilizar o pai a partir do que ele contém, com card, formulário inválido e menu aberto — mais o custo real do seletor, medido no navegador.

Rodolfo Mori11 min de leitura

:has() é a pseudo-classe que escolhe um elemento pelo que existe dentro dele. Você escreve .livro:has(> .capa) e quem recebe o estilo é o .livro; a capa entra só como condição. É o primeiro seletor do CSS que consegue olhar para cima na árvore.

Os exemplos desta lição vivem todos no mesmo lugar: o catálogo online da Biblioteca Ipê, uma biblioteca de bairro. Card de livro, ficha de reserva, gaveta de filtros. Toda saída colada aqui saiu do Chromium 151 dirigido pelo Playwright — getComputedStyle, querySelectorAll e as métricas de tempo do próprio navegador. Não é o que deveria acontecer: é o que aconteceu.

O CSS só sabia descer

Todo seletor que você viu em seletores e cascata anda no mesmo sentido: do ancestral para o descendente. O espaço desce um nível ou mais, o > desce exatamente um.

css
/* isto sempre funcionou: sair do pai e chegar no filho */
.livro .capa    { border-radius: 6px; }
.livro > .titulo { font-size: 1.05rem; }

/* isto nunca existiu: sair do filho e chegar no pai */
/* .capa < .livro { ... }   <- não há seletor de pai no CSS */

No catálogo da biblioteca isso doía num lugar bem concreto. Livro com capa digitalizada pede um card em duas colunas: imagem à esquerda, texto à direita. Livro sem capa pede uma coluna só. O CSS enxergava a <img>, mas não tinha como avisar o <article> que ela estava ali.

O jeito antigo era mandar o JavaScript fazer a pergunta e traduzir a resposta em classe, usando o classList:

js
const capas = document.querySelectorAll('.livro > .capa');
for (const capa of capas) capa.parentElement.classList.add('tem-capa');
capas encontradas: 500 cards que ganharam .tem-capa: 500 tempo do JavaScript: 1.00ms

Funciona, e é rápido. O problema não é o milissegundo: é que essa varredura roda depois do HTML chegar (o card pisca no layout errado), precisa rodar de novo a cada item que entra na lista, e transforma um detalhe visual em dependência de script. Com o JavaScript desligado, o catálogo fica torto.

O :has() tirou esse trabalho do JavaScript. Safari 15.4 abriu em março de 2022, Chrome 105 em agosto de 2022, e o Firefox 121 fechou a conta em dezembro de 2023 — de lá para cá o seletor é Baseline.

O alvo é quem vem antes do parêntese

A leitura é sempre a mesma: alvo:has(condição). O alvo é o elemento escrito antes do :has(); o que está dentro do parêntese só responde sim ou não. Confundir os dois é o erro número um de quem começa.

.livro :has(> .capa) recebe o estilo é só a pergunta article.livro img.capa h3.titulo o estilo cai aqui a capa só precisa existir

Dentro do parêntese vai um seletor relativo: ele pode começar por um combinador, e esse combinador se refere ao alvo.

css
.livro:has(.capa)          { }  /* tem .capa em qualquer nível abaixo */
.livro:has(> .capa)        { }  /* tem .capa como filha direta */
.livro:has(+ .esgotado)    { }  /* o irmão logo depois é .esgotado */
.livro:has(~ .esgotado)    { }  /* algum irmão depois dele é .esgotado */
.livro:not(:has(.capa))    { }  /* não tem capa nenhuma */

Como o :has() é uma pseudo-classe como qualquer outra — o assunto de pseudo-classes e pseudo-elementos —, ela encaixa em qualquer ponto do seletor, inclusive no meio de um caminho mais longo.

O card que muda de layout quando tem capa

Este é o caso que abriu a lição. O HTML é o mesmo para os dois livros; a única diferença é a <img>.

html
<div class="catalogo">
  <article class="livro" id="a">
    <img class="capa" alt="" width="96" height="140">
    <h3 class="titulo">Grande Sertão: Veredas</h3>
    <p class="autor">João Guimarães Rosa</p>
  </article>
  <article class="livro" id="b">
    <h3 class="titulo">Vidas Secas</h3>
    <p class="autor">Graciliano Ramos</p>
  </article>
</div>

O CSS pergunta pela capa e muda o grid do card:

css
.catalogo { display: grid; gap: 16px; width: 480px; }
.livro { display: grid; grid-template-columns: 1fr; gap: 4px 12px; }
.livro:has(> .capa) { grid-template-columns: 96px 1fr; }
.livro:has(> .capa) .titulo { grid-column: 2; }

Repare na última linha: o :has() está no meio do caminho, e quem recebe grid-column é o .titulo. Lendo o estilo calculado dos dois cards:

js
const g = (id) => getComputedStyle(document.getElementById(id)).gridTemplateColumns;
console.log('#a (tem <img class="capa">) grid-template-columns:', g('a'));
console.log('#b (sem capa)               grid-template-columns:', g('b'));
#a (tem <img class="capa">) grid-template-columns: 96px 372px #b (sem capa) grid-template-columns: 480px

Os 96px 372px provam que a regra pegou: 96 da capa, 12 do gap, 372 do resto. O card sem capa ficou com a coluna única de 480px. Nenhuma linha de JavaScript, nenhuma classe extra no HTML.

O formulário que reage sozinho ao campo inválido

A ficha de reserva tem um campo de e-mail obrigatório. Enquanto ele estiver inválido, a borda avisa, o botão desbota e o texto de erro aparece — tudo a partir do estado que o navegador já calcula sozinho, o mesmo assunto da validação de formulário sem JavaScript.

html
<form class="ficha">
  <label>E-mail <input type="email" name="email" required></label>
  <p class="aviso">Confira o e-mail antes de reservar.</p>
  <button class="enviar">Reservar exemplar</button>
</form>
css
.ficha { border: 2px solid rgb(120,120,120); padding: 16px; }
.ficha:has(:invalid) { border-color: rgb(200,30,30); }
.ficha:has(:invalid) .enviar { opacity: 0.4; }
.aviso { display: none; }
.ficha:has(:invalid) .aviso { display: block; }

Preenchendo o campo em três etapas e lendo o estilo calculado a cada uma:

campo vazio -> {"borda":"rgb(200, 30, 30)","botao":"0.4","aviso":"block"} digitou "ana.bairro" -> {"borda":"rgb(200, 30, 30)","botao":"0.4","aviso":"block"} digitou e-mail completo -> {"borda":"rgb(120, 120, 120)","botao":"1","aviso":"none"}

Três estados visuais mudando juntos, e o CSS nunca precisou saber qual campo estava errado. Só perguntou “existe algum inválido aqui dentro?”.

O detalhe que separa isso de um formulário chato

Olhe de novo a primeira linha da saída: a página abriu já com a borda vermelha. O campo está vazio, e vazio com required é inválido desde o primeiro milissegundo. Ninguém digitou nada e o formulário já está brigando.

A correção é trocar :invalid por :user-invalid, que só passa a valer depois que a pessoa mexeu no campo e saiu dele:

css
.ficha:has(:user-invalid) { border-color: rgb(200,30,30); }
1. pagina abriu, ninguem tocou no campo : rgb(120, 120, 120) 2. digitando "ana.bairro", foco no campo : rgb(120, 120, 120) 3. saiu do campo / tentou enviar : rgb(200, 30, 30)

Cinza no momento 1, cinza ainda durante a digitação, vermelho só quando a pessoa terminou. É a diferença entre um formulário que corrige e um que acusa.

A gaveta de filtros e o hover que apaga os vizinhos

O :has() também enxerga atributo e estado de interação. A gaveta de filtros da biblioteca é um <details>; quando ela abre, a página inteira reorganiza a coluna lateral.

html
<div class="pagina">
  <details class="filtros"><summary>Filtros</summary><p>Assunto, ano, idioma</p></details>
  <div class="catalogo">
    <article class="livro" id="l1"><h3>Dom Casmurro</h3></article>
    <article class="livro" id="l2"><h3>Vidas Secas</h3></article>
  </div>
</div>
css
body { margin: 0; width: 900px; }   /* é desta largura que saem os px lidos abaixo */
.pagina { display: grid; grid-template-columns: 72px 1fr; }
.pagina:has(.filtros[open]) { grid-template-columns: 240px 1fr; }
.catalogo:has(.livro:hover) .livro:not(:hover) { opacity: 0.35; }

Abrindo, fechando e passando o mouse sobre o primeiro card:

gaveta fechada -> grid-template-columns: 72px 828px gaveta aberta -> grid-template-columns: 240px 660px fechou de novo -> grid-template-columns: 72px 828px sem mouse -> opacidade [l1, l2]: ["1","1"] mouse sobre l1 -> opacidade [l1, l2]: ["1","0.35"]

A última regra é a que mais impressiona: :has(.livro:hover) faz o container saber que algum filho está sob o mouse, e o :not(:hover) apaga todos os outros. Antes do :has(), isso era um mouseenter e um mouseleave com classe em cada card.

Com + e ~, o :has() olha para o lado

Quando o parêntese começa por + ou ~, a pergunta deixa de ser sobre o conteúdo e passa a ser sobre a vizinhança. A diferença entre os dois é sutil, e o jeito honesto de resolver é perguntar ao navegador.

html
<ul class="prateleira">
  <li class="livro" data-titulo="Vidas Secas"></li>
  <li class="livro" data-titulo="Dom Casmurro"></li>
  <li class="livro esgotado" data-titulo="Grande Sertão: Veredas"></li>
  <li class="livro" data-titulo="Memórias Póstumas"></li>
</ul>
js
const t = (sel) => [...document.querySelectorAll(sel)].map((e) => e.dataset.titulo);
console.log(t('.livro:has(+ .esgotado)'));
console.log(t('.livro:has(~ .esgotado)'));
console.log(t('.livro:has(.esgotado)'));
.livro:has(+ .esgotado) -> ["Dom Casmurro"] .livro:has(~ .esgotado) -> ["Vidas Secas","Dom Casmurro"] .livro:has(.esgotado) -> []

O + pegou só o vizinho imediatamente anterior ao esgotado. O ~ pegou todos os que vêm antes dele na lista. E a terceira linha é a armadilha: sem combinador, o CSS procurou .esgotado dentro de cada .livro — e não achou nada, porque a classe está no próprio irmão, não em um filho dele.

A especificidade vem do argumento, não do :has()

Aqui mora a surpresa. O :has() em si não pontua nada, mas ele empresta a pontuação do argumento mais específico que aparece na lista — exatamente como o :is(). Três regras disputando o mesmo título, com a pontuação de especificidade contada em cada caso:

css
/* concorrente fixo nos três testes: 0,3,0 */
.prateleira .livro .titulo { color: rgb(0,0,200); }

.livro:has(h3) .titulo                 { color: rgb(200,0,0); }  /* 0,2,1 */
.livro:has(h3, #promo) .titulo         { color: rgb(200,0,0); }  /* 1,2,0 */
.livro:has(:where(h3, #promo)) .titulo { color: rgb(200,0,0); }  /* 0,2,0 */

O HTML do teste é o menor possível. Repare que ele tem um <h3> e nenhum elemento com id="promo":

html
<ul class="prateleira">
  <li class="livro"><h3 class="titulo">Dom Casmurro</h3></li>
</ul>

Rodando cada caso isolado, sempre contra o mesmo concorrente, e lendo a cor final:

A) .livro:has(h3) .titulo -> rgb(0, 0, 200) (a regra com :has perdeu) B) .livro:has(h3, #promo) .titulo -> rgb(200, 0, 0) (a regra com :has venceu) C) .livro:has(:where(h3, #promo)) .titulo -> rgb(0, 0, 200) (a regra com :has perdeu)

O caso B é o que pega todo mundo. O #promo não existe na página: quem fez a regra casar foi o h3. Mesmo assim o seletor carrega a coluna de id inteira, e passa por cima de uma regra com três classes. Um id escrito lá dentro “por precaução” muda o peso da regra ainda que nunca case com nada.

O caso C mostra a saída: :where() zera a pontuação do que estiver dentro dele. :has(:where(...)) continua fazendo a mesma pergunta e devolve o peso ao tamanho real do seletor.

Onde o :has() para

Nem tudo entra no parêntese, e o CSS é radical quando não entende: descarta a regra inteira, em silêncio. Vale conferir no console antes de perder meia hora procurando o erro.

js
const testar = (sel) => {
  try { console.log(sel.padEnd(34), '->', document.querySelectorAll(sel).length, 'elemento(s)'); }
  catch (e) { console.log(sel.padEnd(34), '->', e.name + ': ' + e.message); }
};
testar('.livro:has(> .capa)');
testar('.livro:has(.capa, ::before)');
testar('.livro:has(::before)');
testar('.livro:has(.capa:has(.x))');
testar('.livro::before:has(.capa)');
.livro:has(> .capa) -> 1 elemento(s) .livro:has(.capa, ::before) -> SyntaxError: Failed to execute 'querySelectorAll' on 'Document': '.livro:has(.capa, ::before)' is not a valid selector. .livro:has(::before) -> SyntaxError: Failed to execute 'querySelectorAll' on 'Document': '.livro:has(::before)' is not a valid selector. .livro:has(.capa:has(.x)) -> SyntaxError: Failed to execute 'querySelectorAll' on 'Document': '.livro:has(.capa:has(.x))' is not a valid selector. .livro::before:has(.capa) -> SyntaxError: Failed to execute 'querySelectorAll' on 'Document': '.livro::before:has(.capa)' is not a valid selector.

São quatro seletores recusados de uma vez. Pseudo-elemento não pode ser argumento (::before não é um elemento da árvore, é uma caixa que o CSS inventa). :has() não aninha dentro de :has(). E não dá para pendurar :has() num pseudo-elemento.

A segunda linha é a mais traiçoeira: .capa era perfeitamente válida, mas bastou um item ruim na lista para derrubar o seletor todo. Diferente de :is() e :where(), a lista do :has() não é tolerante. O mesmo vale na folha de estilo: mandando as três regras pelo insertRule, só a primeira entrou.

insertRule aceitou: sim .livro:has(> .capa) { color: red } insertRule .livro:has(::before) { color: red } -> SyntaxError insertRule .livro:has(.capa, ::before) { color: red } -> SyntaxError

Para quem ainda precisa sustentar navegador antigo, a pergunta certa é o @supports com a função selector(): o layout simples fica fora do bloco, e a versão melhorada, dentro.

css
@supports selector(:has(*)) {
  .livro:has(> .capa) { grid-template-columns: 96px 1fr; }
}

É a mesma pergunta que o CSS.supports responde no console — e repare que ela também sabe recusar o argumento proibido:

CSS.supports('selector(:has(a))') -> true CSS.supports('selector(:has(::before))') -> false

Quanto custa: 1.000 livros medidos no Chromium

A fama do :has() é de seletor caro. Fui medir. O catálogo de teste tem 1.000 cards, metade com capa, e as duas versões fazem exatamente a mesma coisa: uma com :has(> .capa), outra com a classe .tem-capa aplicada pelo JavaScript. O número vem do RecalcStyleDuration, a métrica que o próprio Chromium expõe para o “Recalculate Style” — sete execuções, as duas primeiras descartadas como aquecimento.

js
import { chromium } from 'playwright-core';

const navegador = await chromium.launch();
const pagina = await navegador.newPage();
const client = await pagina.context().newCDPSession(pagina);
await client.send('Performance.enable');

// aqui a página do teste é montada e o navegador recalcula o estilo

const { metrics } = await client.send('Performance.getMetrics');
const recalc = metrics.find((m) => m.name === 'RecalcStyleDuration').value;
has | recalc na carga: 2.69ms | recalc na mutacao: 0.45ms | JS carga: 0.00ms | JS mutacao: 2.86ms classe | recalc na carga: 3.22ms | recalc na mutacao: 0.37ms | JS carga: 0.82ms | JS mutacao: 2.38ms
etapa :has(> .capa) classe via JavaScript
Recalculate Style na carga 2,69 ms 3,22 ms
JavaScript na carga 0 ms 0,82 ms
Recalculate Style ao tirar 200 capas 0,45 ms 0,37 ms

O :has() ganhou na carga. Faz sentido: a versão com classe paga o recalc duas vezes, uma na primeira montagem e outra depois que o script pendura a classe. Na mutação a diferença é de 0,08 ms em 1.000 cards. Para esse formato de regra, a fama não se sustenta.

Só que existe um formato em que ela se sustenta. Quanto mais alto na árvore estiver o alvo, maior o pedaço de página que o navegador precisa revisitar a cada mudança lá embaixo. Medi o mesmo gesto — ligar e desligar uma classe num único título, 100 vezes — com três folhas de estilo diferentes:

css
/* nenhuma regra com :has() */
.titulo.esgotado { color: rgb(180,0,0); }

/* o alvo é o card, um nível acima */
.livro:has(.esgotado) { opacity: .5; }

/* o alvo é o catálogo inteiro, com 1.000 cards dentro */
.catalogo:has(.esgotado) { outline: 1px solid rgb(180,0,0); }
sem :has() 100 toggles -> 0.18ms total, 0.002ms por troca .livro:has(.esgotado) 100 toggles -> 2.81ms total, 0.028ms por troca .catalogo:has(.esgotado) 100 toggles -> 6.46ms total, 0.065ms por troca

Aqui está o custo real, e ele não está no :has() — está na distância entre o alvo e a condição. Trocar uma classe custou 0,002 ms sem :has(), 0,028 ms quando o alvo era o card e 0,065 ms quando o alvo era o catálogo inteiro: 32 vezes mais caro. E ainda assim, 0,065 ms é menos de meio por cento dos 16,7 ms que um quadro tem a 60 fps.

situação o que fazer por quê
alvo é o pai direto ou o card use :has() sem pensar o custo é indistinguível do de uma classe
alvo é o body ou um container gigante use :has(), mas evite condições que mudam a cada frame o recalc revisita a subárvore inteira
condição muda dezenas de vezes por segundo (arrastar, rolar) classe via JavaScript, aplicada no elemento certo você escolhe o escopo da invalidação

Máquina do teste: MacBook com Apple Silicon, macOS 27, Chromium 151.0.7922.34 dirigido pelo Playwright 1.62.1 sobre o Node 24.16.0. Os números mudam de máquina para máquina; a proporção entre as três linhas é o que interessa.

O que vem depois

Sempre que você pensar “preciso de um if no CSS”, pergunte se o if cabe dentro de um :has(). Card com selo, linha de tabela com checkbox marcado, página com modal aberto, rodapé que some quando a lista está vazia: é a mesma forma repetida.

O próximo passo natural da trilha de CSS são as unidades e as variáveis — porque :has() decide quando aplicar, e elas decidem quanto. Se quiser ver onde cada assunto entra na ordem de estudo, o guia completo de CSS tem o mapa inteiro.

  • css
  • has
  • seletores
  • formulário
  • css moderno

Perguntas frequentes

Todo navegador já entende :has()?
Sim, nos navegadores atualizados. O Safari 15.4 abriu em março de 2022, o Chrome 105 veio em agosto de 2022 e o Firefox 121 fechou a conta em dezembro de 2023 — desde então o seletor é Baseline. Quem precisa sustentar navegador anterior a isso continua precisando do plano B.
Dá para escrever :has() dentro do querySelector do JavaScript?
Dá, e é o mesmo motor de seleção. document.querySelectorAll('.livro:has(> .capa)') devolve os mesmos elementos que a folha de estilo pintaria. Isso é ótimo para conferir um seletor no console antes de escrever a regra.
Como escrever um plano B para quem não tem :has()?
Com a regra @supports selector(:has(*)). Você escreve o layout simples fora dela e a versão melhorada dentro. No Chromium 151, CSS.supports('selector(:has(a))') devolve true, e é essa mesma pergunta que o @supports faz.
:has() funciona dentro do Sass?
Funciona, porque para o pré-processador é um seletor comum. Escrever &:has(> .capa) dentro do bloco .livro compila para .livro:has(> .capa) sem tratamento especial. O Sass não valida o conteúdo do parêntese — quem valida é o navegador.
Posso usar :has() para saber se um elemento está vazio?
Para "não tem filho nenhum" existe :empty, que é mais barato e mais direto. O :has() entra quando a pergunta é específica — "não tem capa, mas tem resenha" vira .livro:not(:has(.capa)):has(.resenha).

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

Todo o código deste artigo foi executado em Chromium 151.0.7922.34 via Playwright 1.62.1, Node 24.16.0, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. MDN — :has() — developer.mozilla.org
  2. Selectors Level 4 — The Relational Pseudo-class: :has() — w3.org
  3. MDN — :user-invalid — developer.mozilla.org

Continue por aqui