: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.
: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.
/* 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:
const capas = document.querySelectorAll('.livro > .capa');
for (const capa of capas) capa.parentElement.classList.add('tem-capa');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.
Dentro do parêntese vai um seletor relativo: ele pode começar por um combinador, e esse combinador se refere ao alvo.
.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>.
<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:
.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:
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'));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.
<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>.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:
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:
.ficha:has(:user-invalid) { border-color: 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.
<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>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:
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.
<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>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)'));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:
/* 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":
<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:
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.
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)');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.
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.
@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:
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.
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;| 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:
/* 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); }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.
Perguntas frequentes
Todo navegador já entende :has()?
Dá para escrever :has() dentro do querySelector do JavaScript?
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()?
@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?
&: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?
: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.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
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
- MDN — :has() — developer.mozilla.org
- Selectors Level 4 — The Relational Pseudo-class: :has() — w3.org
- MDN — :user-invalid — developer.mozilla.org


