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.
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:
<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:
/* 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:
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);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.
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:
document.querySelectorAll('input:placeholder'); // e não ::placeholderOs 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.
.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.
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->');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.
.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:
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(', ')}`);
}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:
<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:
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)');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:
/* não aparece: falta content */
.sem-content::before { color: crimson; font-weight: 700; }
/* aparece */
.com-content::before { content: "★ "; color: crimson; font-weight: 700; }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));
}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:
// 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());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:
.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:
// 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')));// 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));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:
<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 ":
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.
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));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:
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();
}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:
<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:
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');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:
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();
}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:
.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:
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');: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.
Perguntas frequentes
Pseudo-classe e pseudo-elemento pesam igual na especificidade?
Dá para usar dois pseudo-elementos no mesmo seletor?
Preciso escrever ::before com dois dois-pontos?
O leitor de tela lê o texto que está no content?
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, 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
- MDN — Pseudo-classes — developer.mozilla.org
- MDN — Pseudo-elements — developer.mozilla.org
- W3C — Selectors Level 4 — w3.org
- W3C — CSS Pseudo-Elements Module Level 4 — w3.org



