Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

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

Pseudo-classes e pseudo-elementos: :hover e ::before

A diferença entre os dois-pontos e os quatro: estados como :hover e :focus, posição com :nth-child e conteúdo criado com ::before e ::after.

Rodolfo Mori13 min de leitura

Pseudo-classe é uma condição sobre um elemento que já existe no HTML: :hover diz “quando o mouse estiver em cima”, :nth-child(3) diz “quando for o terceiro filho”. Pseudo-elemento é uma caixa que o CSS desenha e que não está no seu HTML: ::before e ::after são as mais usadas. Dois-pontos seleciona; quatro pontos cria.

Todos os exemplos aqui são do site de uma livraria de bairro, a Página Sete: a prateleira de novidades, a ficha de um livro e o formulário do clube do livro. O código roda em navegador de verdade — Chromium 151, e nos pontos em que os motores discordam também Firefox 153 e WebKit 26.5, todos dirigidos pelo Playwright. Cada saída abaixo foi copiada dessas execuções.

Dois-pontos é estado; quatro-pontos é uma peça nova

A prateleira de novidades da livraria tem doze livros numa lista:

html
<ul class="prateleira">
  <li>Grande Sertão: Veredas</li>
  <li>Vidas Secas</li>
  <li>A Hora da Estrela</li>
  <li>Memórias Póstumas de Brás Cubas</li>
  <li>O Cortiço</li>
  <li>Quarto de Despejo</li>
  <li>Torto Arado</li>
  <li>O Alienista</li>
  <li>Capitães da Areia</li>
  <li>Ponciá Vicêncio</li>
  <li>Iracema</li>
  <li>Macunaíma</li>
</ul>

Agora as duas coisas, lado a lado. A pseudo-classe escolhe alguns dos li que já existem; o pseudo-elemento acrescenta um pedaço dentro de cada um:

css
/* pseudo-classe: filtra o que o HTML já tem */
.prateleira li:nth-child(3n + 1) { background: rgb(255, 236, 179); }

/* pseudo-elemento: cria conteúdo que não está no HTML */
.prateleira li::before { content: "novo "; font-weight: 700; }

A diferença fica óbvia quando você conta os nós da página antes e depois:

js
const contar = (sel) => document.querySelectorAll(sel).length;
const nos = () => contar('.prateleira *');

console.log('<li> no HTML                       ', contar('.prateleira li'));
console.log('li:nth-child(3n + 1)  (pseudo-CLASSE)', contar('.prateleira li:nth-child(3n + 1)'));
console.log('nós dentro da prateleira           ', nos());

const s = document.createElement('style');
s.textContent = '.prateleira li::before { content: "novo "; font-weight: 700 }';
document.head.append(s);

console.log('\ndepois de adicionar li::before (pseudo-ELEMENTO):');
console.log('nós dentro da prateleira           ', nos());
console.log('li::before no DOM                  ', contar('.prateleira li::before'));
console.log('primeiro <li>.textContent          ',
  JSON.stringify(document.querySelector('.prateleira li').textContent));
console.log('getComputedStyle(li, "::before")   ',
  getComputedStyle(document.querySelector('.prateleira li'), '::before').content);
<li> no HTML 12 li:nth-child(3n + 1) (pseudo-CLASSE) 4 nós dentro da prateleira 12

depois de adicionar li::before (pseudo-ELEMENTO): nós dentro da prateleira 12 li::before no DOM 0 primeiro <li>.textContent “Grande Sertão: Veredas” getComputedStyle(li, “::before”) “novo “

Leia a saída com calma, porque ela contém a lição inteira. A pseudo-classe devolveu 4 dos 12 elementos — um subconjunto do que já existia. O pseudo-elemento não criou nó nenhum: a prateleira continua com 12 nós, o textContent do primeiro li continua sendo só o título do livro, e mesmo assim a palavra “novo” aparece na tela. Ela existe na pintura, não na árvore.

pseudo-classe — li:nth-child(2) Grande Sertão: Veredas Vidas Secas A Hora da Estrela 3 elementos no HTML, 3 nós no DOM. O seletor só aponta para o do meio. pseudo-elemento — li::before ::before Vidas Secas 1 elemento no HTML, 1 nó no DOM. A caixa tracejada só existe na pintura.

Você vai ver por aí li:before, com um dois-pontos só. Não é erro de digitação: os quatro pseudo-elementos mais antigos — ::before, ::after, ::first-line e ::first-letter — ainda aceitam a forma curta, por compatibilidade com o CSS 2, de 1998, quando a forma dupla ainda não existia. E a exceção acaba aí. Todo pseudo-elemento criado depois disso, como ::placeholder e ::marker, só existe com os dois dois-pontos. Escrito com um só, o navegador entende que você pediu uma pseudo-classe chamada placeholder, não encontra nenhuma com esse nome e joga fora o seletor inteiro:

js
document.querySelectorAll('input:placeholder'); // e não ::placeholder
SyntaxError: Failed to execute 'querySelectorAll' on 'Document': 'input:placeholder' is not a valid selector.

Os estados de interação: :hover, :focus, :focus-visible e :active

O botão “Comprar” da livraria muda de tom em três situações. :hover é o mouse por cima, :active é o instante entre apertar e soltar, e :focus-visible é o foco que o navegador julga que precisa aparecer.

css
.comprar { background: rgb(23, 116, 70); color: white; border: 0; padding: 12px 20px; }
.comprar:hover  { background: rgb(17, 87, 53); }
.comprar:active { background: rgb(11, 58, 35); }
.comprar:focus-visible { outline: 3px solid rgb(255, 179, 0); }

Para provar que cada estado entra na hora certa, o teste passa o mouse, aperta o botão, e depois foca o mesmo botão de dois jeitos: com clique e com a tecla Tab.

js
const ler  = (prop)   => pagina.$eval('.comprar', (b, p) => getComputedStyle(b)[p], prop);
const casa = (pseudo) => pagina.$eval('.comprar', (b, p) => b.matches(p), pseudo);
const foco = async (rotulo) =>
  console.log(`${rotulo} :focus ${await casa(':focus')} | :focus-visible ${await casa(':focus-visible')}` +
              ` | outline: ${await ler('outlineStyle')} ${await ler('outlineColor')}`);

console.log('parado      background:', await ler('backgroundColor'));
await pagina.hover('.comprar');
console.log('com :hover  background:', await ler('backgroundColor'));
await pagina.mouse.down();                // o dedo ainda está apertado
console.log('com :active background:', await ler('backgroundColor'));
await pagina.mouse.up();                  // soltou: o foco chegou pelo mouse

await foco('foco pelo mouse  ->');
await pagina.keyboard.press('Shift+Tab'); // sai do botão
await pagina.keyboard.press('Tab');       // e volta, agora pelo teclado
await foco('foco pelo teclado->');
parado background: rgb(23, 116, 70) com :hover background: rgb(17, 87, 53) com :active background: rgb(11, 58, 35)

foco pelo mouse -> :focus true | :focus-visible false | outline: none rgb(255, 255, 255) foco pelo teclado-> :focus true | :focus-visible true | outline: solid rgb(255, 179, 0)

As duas últimas linhas são o motivo de :focus-visible existir. Nos dois casos o botão está focado — :focus é true nos dois. Mas quem clicou com o mouse já sabe onde está e não precisa da moldura amarela; quem chegou pelo Tab precisa. O navegador decide isso sozinho, e o outline: none da primeira linha contra o outline: solid da segunda é essa decisão acontecendo.

A fórmula an+b do :nth-child() sem decorar

:nth-child() recebe uma fórmula an + b, onde n começa em zero e vai subindo. a é o tamanho do passo e b é o ponto de partida. Em 3n + 1, os valores são 1, 4, 7, 10 — de três em três, começando no primeiro.

css
.prateleira li:nth-child(3n + 1) { background: rgb(255, 236, 179); }
.prateleira li:nth-child(odd)    { border-left: 3px solid rgb(23, 116, 70); }
.prateleira li:nth-child(-n + 3) { font-weight: 700; }
.prateleira li:nth-last-child(2) { opacity: 0.6; }

Em vez de conferir no olho, o teste percorre a prateleira de doze livros e imprime a posição de cada item que casou:

js
const formulas = [
  'li:nth-child(3n + 1)',
  'li:nth-child(odd)',
  'li:nth-child(2n)',
  'li:nth-child(-n + 3)',
  'li:nth-child(n + 10)',
  'li:nth-last-child(2)',
];

for (const formula of formulas) {
  const posicoes = await pagina.$$eval(`.prateleira ${formula}`, (nos) =>
    nos.map((no) => [...no.parentElement.children].indexOf(no) + 1),
  );
  console.log(`${formula.padEnd(22)} -> ${posicoes.join(', ')}`);
}
li:nth-child(3n + 1) -> 1, 4, 7, 10 li:nth-child(odd) -> 1, 3, 5, 7, 9, 11 li:nth-child(2n) -> 2, 4, 6, 8, 10, 12 li:nth-child(-n + 3) -> 1, 2, 3 li:nth-child(n + 10) -> 10, 11, 12 li:nth-last-child(2) -> 11

títulos com fundo amarelo: Grande Sertão: Veredas Memórias Póstumas de Brás Cubas Torto Arado Ponciá Vicêncio

Repare nas duas fórmulas que quase ninguém decora e que resolvem metade dos casos reais. -n + 3 pega os três primeiros: como n cresce, -n diminui, e a conta só dá um número válido nas três primeiras voltas. n + 10 faz o contrário e pega do décimo em diante. E nth-last-child conta a partir do fim, o que é a única forma de acertar “o penúltimo” sem saber quantos itens a lista tem.

fórmula o que pega uso típico na livraria
2n ou even 2, 4, 6, 8… listrar a tabela de pedidos
2n + 1 ou odd 1, 3, 5, 7… a outra metade das listras
3n + 1 1, 4, 7, 10 destacar o primeiro de cada trio da grade
-n + 3 os 3 primeiros “mais vendidos da semana”
n + 10 do 10º em diante esconder o excedente até clicar em “ver mais”
nth-last-child(2) o penúltimo tirar a borda antes do último item

O erro clássico: :first-child não é :first-of-type

Esta é a pegadinha que mais aparece em código de quem está começando. A ficha de um livro tem um selo, um título e dois parágrafos:

html
<article class="ficha">
  <span class="selo">Novidade</span>
  <h3>Torto Arado</h3>
  <p>Itamar Vieira Junior</p>
  <p>R$ 54,90</p>
</article>

Você quer deixar o título em destaque e escreve h3:first-child. Não pinta nada. Veja o que cada seletor realmente encontra:

js
const testar = async (seletor) => {
  const achados = await pagina.evaluate(
    (s) => [...document.querySelectorAll(s)].map((n) => n.textContent),
    seletor,
  );
  console.log(`${seletor.padEnd(26)} -> ${achados.length ? achados.join(' | ') : 'nada'}`);
};

await testar('.ficha h3:first-child');
await testar('.ficha h3:first-of-type');
await testar('.ficha p:first-child');
await testar('.ficha p:first-of-type');
await testar('.ficha :first-child');
await testar('.ficha p:last-of-type');
await testar('.ficha :only-of-type');
await testar('.ficha :nth-child(1 of p)');
await testar('.ficha :nth-child(2 of p)');
.ficha h3:first-child -> nada .ficha h3:first-of-type -> Torto Arado .ficha p:first-child -> nada .ficha p:first-of-type -> Itamar Vieira Junior .ficha :first-child -> Novidade .ficha p:last-of-type -> R$ 54,90 .ficha :only-of-type -> Novidade | Torto Arado .ficha :nth-child(1 of p) -> Itamar Vieira Junior .ficha :nth-child(2 of p) -> R$ 54,90

h3:first-child quer dizer “um h3 que seja o primeiro filho”. O primeiro filho da ficha é o span do selo, então a condição nunca é satisfeita. h3:first-of-type quer dizer “o primeiro h3 entre os irmãos” — e esse existe.

A regra vale para toda a família: :nth-child() conta todos os irmãos e depois verifica o tipo; :nth-of-type() conta só os irmãos daquela mesma tag. Sempre que o seu seletor tiver uma tag na frente e uma posição atrás, pare e pergunte qual das duas contagens você quer.

As duas últimas linhas da saída são uma terceira opção, mais nova e quase desconhecida. Em :nth-child(1 of p), o filtro deixa de ser a tag e passa a ser um seletor qualquer: a contagem só enxerga os irmãos que casam com p. É o :nth-of-type() generalizado. Com ele, :nth-child(2 of .esgotado) pega o segundo livro esgotado da prateleira — e nenhuma das duas formas antigas faz isso: :nth-of-type() só sabe contar por tag, e li:nth-child(2).esgotado quer dizer outra coisa (“o segundo filho, e só se ele estiver esgotado”). Rodei essa sintaxe nos três motores do teste e todos entenderam.

Quando a pergunta deixa de ser “que posição ele ocupa entre os irmãos” e passa a ser “o que existe dentro dele”, nenhuma dessas três serve: aí o seletor é :has().

::before e ::after: o content que não pode faltar

A regra número um dos pseudo-elementos: sem content, ele não nasce. Não é que fique invisível — ele simplesmente não é gerado. Compare as duas regras:

css
/* não aparece: falta content */
.sem-content::before { color: crimson; font-weight: 700; }

/* aparece */
.com-content::before { content: "★ "; color: crimson; font-weight: 700; }
js
for (const classe of ['.sem-content', '.com-content']) {
  const dados = await pagina.$eval(classe, (el) => {
    const e = getComputedStyle(el, '::before');
    return { content: e.content, cor: e.color, texto: el.textContent };
  });
  console.log(classe.padEnd(14), 'content:', dados.content.padEnd(6),
              '| color:', dados.cor, '| textContent:', JSON.stringify(dados.texto));
}
.sem-content content: none | color: rgb(220, 20, 60) | textContent: "Torto Arado" .com-content content: "★ " | color: rgb(220, 20, 60) | textContent: "Torto Arado"

Os dois têm color: rgb(220, 20, 60) computado, e mesmo assim só um aparece na tela. O que separa os dois é content: none contra content: "★ ". Quando um ::before “não funciona”, esta é a primeira linha a conferir. Para desenhar uma bolinha ou uma barra, sem texto nenhum, o valor é a string vazia: content: "" mais width, height e background.

Repare também que o textContent dos dois é idêntico. O texto do content não entra no HTML — mas entra no nome acessível, o que é uma surpresa comum:

js
// a página tem <button class="esgotado">O Cortiço</button>
// e a regra .esgotado::before { content: "Esgotado: " }
console.log('textContent do botão ->', JSON.stringify(await pagina.$eval('.esgotado', (el) => el.textContent)));
console.log('árvore de acessibilidade:');
console.log(await pagina.locator('button').ariaSnapshot());
textContent do botão -> "O Cortiço" árvore de acessibilidade: - 'button "Esgotado: O Cortiço"'

O leitor de tela anuncia “Esgotado: O Cortiço” mesmo com o HTML dizendo só “O Cortiço”. Ótimo para um selo decorativo; péssimo para informação que o usuário precisa poder copiar, traduzir ou buscar na página.

::placeholder, ::selection e ::marker: estilizar o que não é seu

Além de ::before e ::after, o navegador expõe pedaços internos que ele mesmo desenha. Três aparecem em quase todo projeto:

css
.prateleira li::marker { content: "▸ "; color: rgb(23, 116, 70); }
.busca::placeholder    { color: rgb(120, 113, 108); font-style: italic; }
::selection            { background: rgb(255, 236, 179); color: rgb(28, 25, 23); }

Cada um aceita um conjunto restrito de propriedades, e essa é a parte que pega as pessoas. Aqui, depois de aplicar as regras, o teste tenta forçar background e padding no ::marker:

js
// primeiro, lê o que as três regras acima produziram
const lerPseudo = (seletor, pseudo) =>
  pagina.$eval(seletor, (el, p) => {
    const e = getComputedStyle(el, p);
    if (p === '::marker') return { content: e.content, color: e.color, fontSize: e.fontSize };
    if (p === '::placeholder') return { color: e.color, fontStyle: e.fontStyle, valorDoInput: el.value };
    return { background: e.backgroundColor, color: e.color };
  }, pseudo);

console.log('li::marker        ', JSON.stringify(await lerPseudo('.prateleira li', '::marker')));
console.log('input::placeholder', JSON.stringify(await lerPseudo('.busca', '::placeholder')));
console.log('::selection       ', JSON.stringify(await lerPseudo('.resenha', '::selection')));
js
// depois, dentro da página, tenta forçar propriedades que o ::marker não aceita
const proibido = await pagina.evaluate(() => {
  const s = document.createElement('style');
  s.textContent = '.prateleira li::marker { background: red; padding: 40px; color: rgb(120, 53, 15) }';
  document.head.append(s);

  const e = getComputedStyle(document.querySelector('.prateleira li'), '::marker');
  return { background: e.backgroundColor, padding: e.paddingLeft, color: e.color };
});
console.log('::marker com background/padding/color ->', JSON.stringify(proibido));
li::marker {"content":"\"▸ \"","color":"rgb(23, 116, 70)","fontSize":"16px"} input::placeholder {"color":"rgb(120, 113, 108)","fontStyle":"italic","valorDoInput":""} ::selection {"background":"rgb(255, 236, 179)","color":"rgb(28, 25, 23)"} ::marker com background/padding/color -> {"background":"rgba(0, 0, 0, 0)","padding":"0px","color":"rgb(120, 53, 15)"}

O color passou: rgb(120, 53, 15). O background ficou transparente e o padding ficou em zero — o navegador aceitou a regra e descartou as duas propriedades. ::marker só responde a cor, fonte e content. Para mudar o espaçamento da bolinha da lista, você mexe no li, não no marcador.

O que um pseudo-elemento não consegue fazer

Três limites, todos medidos na mesma página. O primeiro: pseudo-elemento não nasce em elemento substituído — aquele cujo conteúdo não vem do documento e sim de fora dele, como img e input. Não há caixa de texto interna onde encaixar o ::before, então o navegador simplesmente não gera nada. br não é substituído, mas cai na mesma sacada por outro motivo: ele não tem conteúdo nenhum, só emite uma quebra de linha. Cada candidato está embrulhado num .medida com display: inline-block, que encolhe até o tamanho do que tem dentro — então a largura da embalagem é a medida do conteúdo:

html
<span class="medida"><span class="rotulo alvo">O Cortiço</span></span>
<span class="medida"><img class="alvo" src="capa.gif" width="60" height="90" alt="capa"></span>
<span class="medida"><input class="alvo" size="12" value="O Cortiço"></span>
<span class="medida"><br class="alvo"></span>

O teste mede as quatro caixas antes e depois de aplicar content: "Esgotado ":

js
const larguras = () =>
  pagina.$$eval('.medida', (nos) => nos.map((n) => Math.round(n.getBoundingClientRect().width)));

const antes = await larguras();
await pagina.evaluate(() => {
  const s = document.createElement('style');
  s.textContent = '.alvo::before { content: "Esgotado " }';
  document.head.append(s);
});
const depois = await larguras();

E, na mesma página, mais três leituras: o que o querySelectorAll devolve, o que sobrou no textContent do span e quem recebe o clique dado bem em cima da palavra que o ::before desenhou.

js
console.log('querySelectorAll(".alvo::before") ->',
  await pagina.evaluate(() => `NodeList com ${document.querySelectorAll('.alvo::before').length} item(ns)`));
console.log('textContent do <span>             ->',
  JSON.stringify(await pagina.$eval('.rotulo', (el) => el.textContent)));

await pagina.evaluate(() => {
  document.addEventListener('click', (e) => { window.__alvo = `${e.target.tagName}.${e.target.className}`; });
});
const p = await pagina.$eval('.rotulo', (el) => {
  const r = el.getBoundingClientRect();
  return { x: r.left + 12, y: r.top + r.height / 2 }; // 12px: dentro da palavra "Esgotado"
});
await pagina.mouse.click(p.x, p.y);
console.log('clique em cima da palavra "Esgotado" -> event.target =',
  await pagina.evaluate(() => window.__alvo));
<span> largura sem ::before: 64px com ::before: 127px -> RENDERIZOU <img> largura sem ::before: 60px com ::before: 60px -> ignorou <input> largura sem ::before: 97px com ::before: 97px -> ignorou <br> largura sem ::before: 0px com ::before: 0px -> ignorou

querySelectorAll(“.alvo::before”) -> NodeList com 0 item(ns) textContent do <span> -> “O Cortiço” clique em cima da palavra “Esgotado” -> event.target = SPAN.rotulo alvo

O span cresceu de 64px para 127px, os outros três não mudaram um pixel. Rodei a mesma medição com textarea, select e input type="checkbox": nenhum mudou de largura também. Se você precisa de um selo em cima de uma capa de livro, envolva a img numa div e ponha o ::before na div.

O segundo limite: o JavaScript não alcança. querySelectorAll aceita o seletor sem reclamar e devolve uma lista vazia — não é erro, é ausência. Rodei o mesmo querySelectorAll nos três motores, para você não guardar isso como mania do Chrome:

js
import { chromium, firefox, webkit } from 'playwright-core';

for (const [nome, motor] of [['Chromium', chromium], ['Firefox', firefox], ['WebKit', webkit]]) {
  const navegador = await motor.launch();
  const pagina = await navegador.newPage();
  await pagina.setContent('<style>p::before{content:"X"}</style><p>oi</p>');
  const r = await pagina.evaluate(() => {
    try { return `NodeList com ${document.querySelectorAll('p::before').length} item(ns)`; }
    catch (e) { return e.name; }
  });
  console.log(`${nome} ${navegador.version()}`.padEnd(34), '->', r);
  await navegador.close();
}
Chromium 151.0.7922.34 -> NodeList com 0 item(ns) Firefox 153.0 -> NodeList com 0 item(ns) WebKit 26.5 -> NodeList com 0 item(ns)

O terceiro limite é a última linha da saída das larguras: clicando exatamente em cima da palavra “Esgotado”, o event.target foi o span, não o pseudo-elemento. Ele herda o clique de quem o gerou. Botão feito de ::after não existe — se precisa ser clicável por si, precisa ser um elemento de verdade no HTML.

Estados de formulário: :checked, :disabled, :required e :invalid

O formulário do clube do livro tem um e-mail obrigatório, um cupom opcional, uma caixa de presente e um botão desligado até o e-mail ficar válido:

html
<form class="assinatura">
  <input id="email" type="email" required placeholder="seu e-mail">
  <input id="cupom" type="text" placeholder="cupom (opcional)">
  <input id="presente" type="checkbox">
  <button id="enviar" type="submit" disabled>Assinar o clube do livro</button>
</form>

O teste digita, marca a caixa e pergunta ao navegador, a cada passo, quais pseudo-classes o campo casa naquele momento:

js
const estados = (id) =>
  pagina.$eval(`#${id}`, (el) =>
    [':required', ':optional', ':valid', ':invalid', ':checked',
     ':disabled', ':enabled', ':placeholder-shown']
      .filter((p) => el.matches(p))
      .join(' '),
  );

const linha = async (rotulo, id) => console.log(`${rotulo.padEnd(28)} ${await estados(id)}`);

await linha('email vazio', 'email');
await pagina.fill('#email', 'ana@');
await linha('email = "ana@"', 'email');
await pagina.fill('#email', 'ana@livraria.com.br');
await linha('email = "ana@livraria.com.br"', 'email');

await linha('cupom vazio', 'cupom');
await linha('checkbox desmarcado', 'presente');
await pagina.check('#presente');
await linha('checkbox marcado', 'presente');
await linha('botão disabled', 'enviar');
email vazio :required :invalid :enabled :placeholder-shown email = "ana@" :required :invalid :enabled email = "ana@livraria.com.br" :required :valid :enabled cupom vazio :optional :valid :enabled :placeholder-shown checkbox desmarcado :optional :valid :enabled checkbox marcado :optional :valid :checked :enabled botão disabled :optional :disabled

Duas coisas para guardar. Todo campo preenchível é :required ou :optional, sempre um dos dois — o cupom, sem atributo nenhum, já casa com :optional. E :placeholder-shown some assim que a primeira letra é digitada, o que faz dele o jeito mais barato de detectar “campo vazio” em CSS puro.

A última linha da saída merece uma ressalva, porque ela não é regra: :optional casando com um <button> contraria a especificação, que reserva :required e :optional para input, select e textarea. Repeti a pergunta nos três motores:

js
for (const [nome, motor] of [['Chromium', chromium], ['Firefox', firefox], ['WebKit', webkit]]) {
  const navegador = await motor.launch();
  const pagina = await navegador.newPage();
  await pagina.setContent('<form><button disabled>Assinar</button></form>');
  const casa = await pagina.$eval('button', (el) =>
    [':required', ':optional', ':disabled'].filter((p) => el.matches(p)).join(' '));
  console.log(`${nome} ${navegador.version()}`.padEnd(26), 'button ->', casa);
  await navegador.close();
}
Chromium 151.0.7922.34 button -> :optional :disabled Firefox 153.0 button -> :disabled WebKit 26.5 button -> :optional :disabled

O Firefox segue a letra da especificação; Chromium e WebKit esticam a regra. É divergência de verdade, então não escreva CSS que dependa dela: :required e :optional só em campo que a pessoa preenche.

Agora o problema prático. Repare na primeira linha: o e-mail vazio já é :invalid. Se você pintar input:invalid { border-color: red }, a página abre com o formulário todo vermelho antes de a pessoa digitar qualquer coisa. A solução é :user-invalid, que só liga depois que a pessoa interagiu e saiu do campo:

css
.assinatura input:user-invalid { border-color: rgb(190, 40, 40); }
.assinatura input:user-valid   { border-color: rgb(23, 116, 70); }

O teste abaixo repete a jornada de quem preenche o campo — abre a página, foca, digita um e-mail incompleto, sai do campo e depois corrige:

js
const linha = async (rotulo) => {
  const r = await pagina.$eval('#email', (el) => ({
    invalid: el.matches(':invalid'),
    userInvalid: el.matches(':user-invalid'),
  }));
  console.log(`${rotulo.padEnd(34)} :invalid ${String(r.invalid).padEnd(5)} | :user-invalid ${r.userInvalid}`);
};

await linha('página recém-carregada');
await pagina.click('#email');
await linha('campo focado, ainda sem digitar');
await pagina.type('#email', 'ana@');
await linha('digitando "ana@", ainda no campo');
await pagina.click('#cupom');
await linha('saiu do campo com "ana@"');
await pagina.fill('#email', 'ana@livraria.com.br');
await pagina.click('#cupom');
await linha('corrigiu e saiu de novo');
página recém-carregada :invalid true | :user-invalid false campo focado, ainda sem digitar :invalid true | :user-invalid false digitando "ana@", ainda no campo :invalid true | :user-invalid false saiu do campo com "ana@" :invalid true | :user-invalid true corrigiu e saiu de novo :invalid false | :user-invalid false

:invalid foi true desde o primeiro instante. :user-invalid só virou true na quarta linha, quando a pessoa escreveu algo errado e saiu do campo — que é exatamente o momento em que um aviso de erro é útil. É a mesma validação nativa que aparece em formulário em HTML, agora com um gancho visual.

O que vem depois

Escreva a prateleira da Página Sete no seu editor e teste três coisas: listre a lista com :nth-child(odd), ponha um ::after com content: "" como separador entre os itens e tire a borda do último com :last-child. Se alguma regra não pintar, o culpado costuma ser a disputa entre seletores — e aí o caminho é especificidade no CSS, que explica por que a sua regra perde, depois de você já ter visto como seletores e cascata montam o endereço até o elemento.

A próxima parada da trilha de CSS é a herança — o que um filho recebe do pai sem pedir — e o guia completo de CSS mostra onde cada assunto entra na ordem de estudo.

Prefere aprender em vídeo?

Tem uma aula sobre este assunto no nosso canal.

Ver todos os vídeos do canal
  • css
  • pseudo-classes
  • pseudo-elementos
  • hover
  • nth-child

Perguntas frequentes

Pseudo-classe e pseudo-elemento pesam igual na especificidade?
Não. Uma pseudo-classe pesa como uma classe e um pseudo-elemento pesa como uma tag. Medindo com a mesma calculadora da especificação, li:hover dá (0, 1, 1) e li::before dá (0, 0, 2) — o primeiro vence o segundo.
Dá para usar dois pseudo-elementos no mesmo seletor?
Não. Escrevendo li::before::after, o navegador descarta a regra inteira na hora de montar a folha de estilo: ela não chega a existir em cssRules. Pseudo-classes, essas sim, podem ser encadeadas à vontade.
Preciso escrever ::before com dois dois-pontos?
Para funcionar, não: os quatro pseudo-elementos antigos (::before, ::after, ::first-line e ::first-letter) ainda aceitam a forma com um dois-pontos, por compatibilidade. Escreva com dois mesmo assim — os pseudo-elementos criados depois de 2011, como ::marker, só existem na forma dupla.
O leitor de tela lê o texto que está no content?
No Chromium, sim: ele entra no nome acessível do elemento. Isso é bom para um selo decorativo e péssimo para informação essencial, porque outro navegador pode ignorar e porque o texto não existe no HTML, não é selecionável e não é traduzido por extensão de tradução.

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, Firefox 153.0 e WebKit 26.5 (Playwright 1.62.1), e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. MDN — Pseudo-classes — developer.mozilla.org
  2. MDN — Pseudo-elements — developer.mozilla.org
  3. W3C — Selectors Level 4 — w3.org
  4. W3C — CSS Pseudo-Elements Module Level 4 — w3.org

Continue por aqui