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

Especificidade no CSS: por que a sua regra não vence

Como o navegador conta o peso de cada seletor em três colunas, o que !important realmente faz e como sair da guerra de especificidade sem gambiarra.

Rodolfo Mori11 min de leitura

O navegador não aplica a última regra que você escreveu. Ele dá a cada seletor uma pontuação de três números, compara coluna por coluna e aplica a regra que ganhou. A ordem no arquivo só decide quando a pontuação empata.

Todos os exemplos deste artigo saem da mesma página: o boletim on-line da Escola Aurora. Este é o trecho de HTML que vai aparecer o tempo inteiro.

html
<section id="painel-matricula" class="painel">
  <article class="card-aluno destaque">
    <span class="nota reprovado">4,5</span>
  </article>
</section>

Se você ainda está reconhecendo classes, IDs e atributos no markup, revise tags e atributos HTML e volte aqui depois.

Você pode traduzir especificidade como o peso do endereço usado para chegar ao elemento. A gente vai manter o nome técnico, mas sempre conferir esse peso no estilo calculado do navegador, onde a disputa deixa de ser opinião.

O crachá que cada seletor leva para a disputa

Pense na entrada de uma escola: dizer “sou aluno” identifica muita gente, dizer “sou da turma 3A” reduz o grupo, e mostrar uma carteirinha com matrícula aponta para uma pessoa. No CSS, a tag é o grupo amplo, a classe é a turma e o id é a identificação individual. O navegador apenas conta essas categorias e compara da mais forte para a mais fraca.

Faça uma previsão antes do primeiro teste: olhe os seletores, anote a tripla de cada um e escolha o vencedor sem abrir o navegador. Depois compare sua escolha com a cor calculada no DevTools. Se divergir, a coluna onde você contou errado mostra qual peça do seletor precisa ser revista.

Toda a especificidade cabe numa tripla (a, b, c). Cada pedaço do seletor soma um ponto em exatamente uma das colunas:

coluna o que soma nela exemplo
a cada #id #painel-matricula
b cada classe, atributo e pseudo-classe .nota, [data-turno], :hover
c cada tipo (tag) e pseudo-elemento span, article, ::before

O que não pontua: o *, os combinadores >, +, ~ e o espaço, e o :where(). Eles selecionam, mas valem zero.

css
span                                { color: crimson; }  /* 0,0,1 */
.nota                               { color: crimson; }  /* 0,1,0 */
#painel-matricula                   { color: crimson; }  /* 1,0,0 */
.card-aluno .nota                   { color: crimson; }  /* 0,2,0 */
#painel-matricula .card-aluno .nota { color: crimson; }  /* 1,2,0 */

Repare que a conta é por ocorrência, não por tipo de peça. .nota.nota.nota é um seletor esquisito, mas legítimo, e vale três classes.

Uma calculadora para não contar no dedo

Contar de cabeça funciona até o seletor ficar comprido. O ecossistema do PostCSS mantém um pacote que faz a conta seguindo a especificação: o @csstools/selector-specificity. Uma dúzia de linhas transforma ele numa calculadora de linha de comando.

js
import { selectorSpecificity } from '@csstools/selector-specificity';
import parser from 'postcss-selector-parser';

const processor = parser();

export function esp(seletor) {
  const raiz = processor.astSync(seletor);
  const { a, b, c } = selectorSpecificity(raiz.nodes[0]);
  return `${a},${b},${c}`;
}

const seletores = process.argv.slice(2);
for (const s of seletores) {
  console.log(`${esp(s).padEnd(7)} ${s}`);
}

Agora dá para jogar os seletores do boletim nela e ver o placar de cada um:

bash
node calc.mjs "span" ".nota" "#painel-matricula" ".card-aluno .nota" \
  "article.card-aluno span.nota" ".nota:hover" ".nota::before" \
  "#painel-matricula .card-aluno .nota" "*" ".nota.nota.nota"
0,0,1 span 0,1,0 .nota 1,0,0 #painel-matricula 0,2,0 .card-aluno .nota 0,2,2 article.card-aluno span.nota 0,2,0 .nota:hover 0,1,1 .nota::before 1,2,0 #painel-matricula .card-aluno .nota 0,0,0 * 0,3,0 .nota.nota.nota

Duas linhas merecem atenção. .nota:hover vale 0,2,0: a pseudo-classe entrou na coluna do meio, como se fosse mais uma classe. Já .nota::before vale 0,1,1, porque pseudo-elemento é tipo, não classe. Um : e dois : pontuam em colunas diferentes, e é por isso que a distinção entre pseudo-classe e pseudo-elemento importa mais do que parece.

A conta não é em base 10

Este é o mal-entendido que mais custa tempo. Muita gente aprendeu que id vale 100, classe vale 10 e tag vale 1, soma tudo e compara o total. Se fosse assim, onze classes (110) ganhariam de um id (100). Dá para medir isso no Chrome.

Carregue o painel do boletim com onze classes de estado, todas ao mesmo tempo, sem tirar o id dele:

html
<section id="painel-matricula"
  class="painel matricula turma-3a manha ativo destaque compacto com-borda tema-claro imprimivel rolavel"></section>

Agora duas regras disputam o mesmo elemento: uma amarra as onze classes de uma vez, a outra usa só o id.

css
.painel.matricula.turma-3a.manha.ativo.destaque.compacto.com-borda.tema-claro.imprimivel.rolavel {
  --vencedor: "as onze classes, 0,11,0";
}
#painel-matricula {
  --vencedor: "o id sozinho, 1,0,0";
}

Para saber qual das duas pegou, cada regra escreve o próprio nome numa variável CSS chamada --vencedor. Custom property passa pela cascata exatamente como color ou margin, então o valor que sobrar em getComputedStyle é, literalmente, o nome da regra vencedora. Essa leitura do estilo calculado também aparece em como alterar CSS com JavaScript. O script abre o Chrome com o Playwright, monta a página e lê a variável:

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

const navegador = await chromium.launch();
const pagina = await navegador.newPage();

async function vencedor(css, html, alvo) {
  await pagina.setContent(
    `<!doctype html><html><head><style>${css}</style></head><body>${html}</body></html>`,
  );
  return pagina.evaluate(
    (s) => getComputedStyle(document.querySelector(s)).getPropertyValue('--vencedor').trim(),
    alvo,
  );
}
Chrome 151.0.7922.34

[1] onze classes x um id vence: “o id sozinho, 1,0,0”

O id ganhou. A comparação é coluna por coluna, da esquerda para a direita, e a primeira diferença encerra o assunto: 1 é maior que 0 na coluna a, e as onze classes nem chegam a ser olhadas. Não existe número de classes que alcance um id.

:is(), :not() e :where(): onde a conta surpreende

Estas três pseudo-classes recebem outros seletores como argumento, e cada uma trata esse argumento de um jeito. Passando pela calculadora:

bash
node calc.mjs ":is(#painel-matricula, .painel) .nota" \
  ":where(#painel-matricula, .painel) .nota" \
  ".nota:not(.aprovado)" ".nota:not(#painel-matricula)" \
  ".card-aluno:has(.reprovado)"
1,1,0 :is(#painel-matricula, .painel) .nota 0,1,0 :where(#painel-matricula, .painel) .nota 0,2,0 .nota:not(.aprovado) 1,1,0 .nota:not(#painel-matricula) 0,2,0 .card-aluno:has(.reprovado)

A regra é curta: :is(), :not() e :has() assumem a especificidade do argumento mais pesado da lista. :where() é a exceção que existe para isso: ela sempre vale zero, não importa o que você coloque dentro.

Isso explica dois resultados que costumam pegar gente. :is(#painel-matricula, .painel) .nota casa com a nota tanto de quem tem o id quanto de quem tem só a classe .painel — mas cobra o preço do id nos dois casos, mesmo quando foi a classe que casou. E .nota:not(#painel-matricula), um seletor cujo trabalho é justamente excluir o id, carrega o peso dele mesmo assim.

O Chrome confirma as três pontas. Primeiro, o :where() zerando de verdade: um id dentro dele perde para duas classes.

css
:where(#painel-matricula) .nota { --vencedor: ":where(#id) .nota, 0,1,0"; }
.card-aluno .nota               { --vencedor: ".card-aluno .nota, 0,2,0"; }
[2] :where(#id) .nota x .card-aluno .nota vence: ".card-aluno .nota, 0,2,0"

Depois, o :is() cobrando o preço do argumento mais caro, e por isso ganhando de duas classes mesmo estando escrito antes:

css
.card-aluno .nota                     { --vencedor: ".card-aluno .nota, 0,2,0"; }
:is(#painel-matricula, .painel) .nota { --vencedor: ":is(#id, .painel) .nota, 1,1,0"; }
[3] :is(#id, .painel) .nota x .card-aluno .nota vence: ":is(#id, .painel) .nota, 1,1,0"

Por fim, o :not() cobrando o mesmo preço cheio: com um id dentro, ele também ganha de duas classes mesmo estando escrito antes.

css
.nota:not(#painel-matricula) { --vencedor: ".nota:not(#id), 1,1,0"; }
.nota:not(.aprovado)         { --vencedor: ".nota:not(.aprovado), 0,2,0"; }
[3b] .nota:not(.aprovado) x .nota:not(#painel-matricula) vence: ".nota:not(#id), 1,1,0"

Empate: fica quem chegou por último

Quando duas regras têm a mesma pontuação, na mesma camada e na mesma origem, o critério final é a posição no documento. Vence a que o navegador leu por último — considerando a ordem das folhas de estilo no <head> e, dentro de cada folha, a ordem das linhas.

css
.reprovado { --vencedor: ".reprovado, escrita na linha 1"; }
.nota      { --vencedor: ".nota, escrita na linha 2"; }
[6] empate 0,1,0 x 0,1,0 vence: ".nota, escrita na linha 2"

Repare no que não importa: a ordem das classes no atributo class do HTML. O elemento é class="nota reprovado", e ainda assim .nota ganhou, porque está depois no CSS. O HTML não tem voto nenhum nessa disputa.

!important não é mais forte, ele joga outro campeonato

!important não soma pontos ao seletor. Ele tira a declaração da disputa de especificidade e move para um patamar acima, onde só existem outras declarações com !important. Por isso uma classe consegue derrubar um id:

css
#painel-matricula .nota { --vencedor: "#painel-matricula .nota, 1,1,0"; }
.nota                   { --vencedor: ".nota com !important, 0,1,0" !important; }
[4] .nota com !important x #id .nota normal vence: ".nota com !important, 0,1,0"

Funcionou — e é exatamente aí que mora o problema. A partir do momento em que o !important entrou, a especificidade volta a valer, só que agora entre os importantes. Se amanhã alguém precisar sobrescrever essa nota, o único jeito é outro !important com pontuação maior. O arquivo entra numa escalada em que cada rodada custa mais que a anterior, e não existe volta sem refatoração.

A ordem completa que o navegador usa para decidir é esta, de cima para baixo. Só se um critério empatar é que o de baixo é consultado:

1. Origem e importância (!important) 2. Atributo style do HTML 3. Camada declarada com @layer 4. Especificidade (a, b, c) 5. Ordem no arquivo empatou? empatou? empatou? empatou?

Especificidade é o quarto critério, não o primeiro — e as duas seções seguintes tratam justamente dos degraus 2 e 3, que passam na frente dela. É por isso que discutir seletor não resolve um conflito causado por !important: a disputa nem chegou nesse degrau.

O atributo style ganha de qualquer seletor

Estilo escrito direto no HTML, no atributo style, não tem seletor e por isso não tem pontuação. Ele entra na cascata acima de todas as regras normais da sua folha, inclusive das que usam id:

html
<section id="painel-matricula" style='--vencedor: "o atributo style do HTML"'></section>
css
#painel-matricula { --vencedor: "#painel-matricula na folha"; }
[5a] style="" x #id na folha vence: "o atributo style do HTML"

Uma única coisa derruba o style: um !important na folha.

css
#painel-matricula { --vencedor: "#painel-matricula com !important" !important; }
[5b] style="" x #id com !important vence: "#painel-matricula com !important"

Esse é o motivo real pelo qual tanta gente acaba com !important espalhado: alguma biblioteca escreveu no style do elemento, e não sobrou outra saída. A correção durável é impedir a escrita inline, não vencer ela. Para acompanhar como uma folha externa é ligada ao documento, veja os recursos carregados pelo head.

@layer: sair da disputa em vez de tentar vencê-la

@layer cria camadas ordenadas dentro da sua folha. A comparação de camada acontece antes da especificidade, e isso muda o jogo: dentro de uma camada, a pontuação decide; entre camadas diferentes, quem decide é a ordem das camadas.

Primeiro, o detalhe que surpreende quem começa: CSS fora de qualquer camada ganha de CSS dentro de camada, por mais pesado que ele seja.

css
@layer base {
  #painel-matricula .nota { --vencedor: "#id .nota, dentro de @layer base"; }
}
.nota { --vencedor: ".nota, fora de qualquer layer"; }
[7] #id dentro de @layer x classe fora de layer vence: ".nota, fora de qualquer layer"

Um seletor 1,1,0 perdeu para um 0,1,0. Não tem nada de errado: o código sem camada é tratado como a camada mais alta de todas.

Agora com as duas regras dentro de camadas declaradas na ordem reset, base, tema:

css
@layer reset, base, tema;

@layer base {
  #painel-matricula .nota { --vencedor: "#id .nota, na layer base"; }
}
@layer tema {
  .nota { --vencedor: ".nota, na layer tema"; }
}
[8] @layer base (#id .nota) x @layer tema (.nota) vence: ".nota, na layer tema"

Uma classe simples derrubou um id, sem !important e sem gambiarra, só porque a camada dela foi declarada depois. É este o valor prático do @layer: a primeira linha do seu CSS declara a ordem de prioridade do projeto inteiro, e o resto do arquivo para de brigar.

Um uso clássico é embrulhar o CSS de terceiro. @layer terceiros { ... } em volta do arquivo de uma biblioteca coloca o seletor mais pesado dela abaixo do seu código, e você volta a estilizar com classes normais.

O que a camada não alcança é o degrau 2 da escada. Colocando o mesmo <section> com style da seção anterior contra um id na camada mais alta declarada, o style do HTML ainda ganha:

css
@layer base, tema;

@layer tema {
  #painel-matricula { --vencedor: "#id na layer tema"; }
}
[9] style="" x #id na @layer mais alta vence: "o atributo style do HTML"

Como está a especificidade do seu CSS hoje

Dá para medir. O mesmo pacote da calculadora, ligado ao PostCSS, percorre uma folha inteira e conta quantos seletores existem em cada faixa:

js
const processor = parser();
const raiz = postcss.parse(readFileSync(process.argv[2], 'utf8'));
const contagem = new Map();
let importantes = 0;

raiz.walkRules((regra) => {
  if (regra.parent?.name === 'keyframes') return;
  for (const seletor of regra.selectors) {
    const { a, b, c } = selectorSpecificity(processor.astSync(seletor).nodes[0]);
    const chave = `${a},${b},${c}`;
    contagem.set(chave, (contagem.get(chave) ?? 0) + 1);
  }
  regra.walkDecls((d) => {
    if (d.important) importantes++;
  });
});

Rodei em cima do prose.css deste blog — o arquivo que formata o texto que você está lendo agora:

bash
node auditar.mjs src/styles/prose.css
arquivo: prose.css seletores: 76 declaracoes com !important: 0 0,1,1 27 ########################### 0,1,0 13 ############# 0,2,0 11 ########### 0,1,2 8 ######## 0,2,1 5 ##### 0,3,0 4 #### 0,2,2 3 ### 0,1,3 2 ## 0,3,1 1 # 0,2,3 1 # 0,5,0 1 #

Setenta e seis seletores, nenhum com id na primeira coluna e nenhum !important. O pico está em 0,1,1 — uma classe mais uma tag, o formato de .prose h2, que é literalmente a primeira regra dessa faixa no arquivo. O seletor mais pesado do arquivo inteiro é o único 0,5,0: :root[data-theme="light"] .prose .callout-atencao .callout-titulo, cinco peças na coluna do meio porque :root e [data-theme="light"] também pontuam ali, junto com as três classes.

Esse formato é o que você quer perseguir: uma folha achatada, em que quase tudo mora entre 0,1,0 e 0,2,0. Quando a distribuição fica achatada, qualquer regra nova sobrescreve qualquer regra antiga com uma classe a mais — e a pergunta “por que isso não está pegando?” simplesmente para de aparecer.

Três hábitos levam até lá:

  • Id fica para o JavaScript e para a âncora do link. No CSS, use classe. Um id no seletor cria um teto que nenhuma classe alcança depois.
  • Selecione o elemento, não o caminho até ele. .nota diz o que a coisa é; #painel-matricula .card-aluno span.nota descreve onde ela estava no dia em que você escreveu, e quebra na primeira mudança de HTML.
  • Declare as camadas antes de precisar delas. Uma linha @layer reset, base, componentes, utilitarios; no topo do projeto resolve antecipadamente uma discussão que, sem ela, vira !important.

E vale separar dois conceitos que costumam ser confundidos: especificidade resolve conflito entre regras que casam com o mesmo elemento. Quando a propriedade nem foi declarada para aquele elemento, quem responde é a herança do CSS, que é outro mecanismo.

O que vem depois

O próximo degrau da trilha de CSS é o box model — sair de “qual regra vence” e entrar em “quanto espaço cada caixa ocupa”. Para observar essas disputas dentro de componentes, continue por CSS no React.

Prefere aprender em vídeo?

Tem uma aula sobre este assunto no nosso canal.

Ver todos os vídeos do canal
  • css
  • especificidade
  • seletores
  • important
  • layer

Perguntas frequentes

Especificidade é a mesma coisa que ordem no arquivo?
Não. A ordem só entra como último critério de desempate, quando duas regras chegam com a mesma pontuação e na mesma camada. Uma regra escrita na linha 3 com pontuação maior continua ganhando de outra escrita na linha 900.
Usar id no CSS é errado?
Não é errado, é caro. Um id vale 1,0,0 e nenhuma quantidade de classes alcança isso, então cada id no seletor tira de você a chance de sobrescrever depois com uma classe simples. Guarde o id para o JavaScript e para a âncora do link.
Dá para diminuir a especificidade de um seletor que eu não controlo?
Dá, envolvendo o seletor de terceiro em uma camada com @layer, ou embrulhando as partes pesadas em :where(). As duas técnicas rebaixam o peso sem mudar quais elementos casam.
O !important do navegador do usuário ganha do meu?
Ganha. Na ordem de origens, o !important de uma folha de estilo do usuário fica acima do !important do autor da página. É assim que uma folha de acessibilidade consegue forçar contraste em qualquer site.

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 Node 24.16.0 e Chrome 151.0.7922.34, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. MDN — Specificity — developer.mozilla.org
  2. CSS Cascading and Inheritance Level 5 — Cascade Sorting Order — w3.org
  3. Selectors Level 4 — Calculating a selector's specificity — w3.org

Continue por aqui