A linha tênue entre estados CSS e eventos JavaScript
O CSS está nos ouvindo. Não, não desse jeito. Na verdade, o CSS vem acumulando cada vez mais pseudo-classes para ajudar a responder a eventos JavaScript sem precisar recorrer ao próprio JavaScript.
E, embora pseudo-classes representem estados, e não eventos, muitas vezes elas se parecem bastante com event listeners (o que, no contexto do CSS, nem faz tanta diferença).
Aliás, o que é o CSS hoje em dia? Existe, por exemplo, uma proposta para o event-trigger na especificação de Animation Triggers, que basicamente escutaria eventos e dispararia animações. Ainda assim, a sintaxe parece capaz de muito mais do que isso (pense em invoker commands, só que para CSS).
Mas, para ficar na realidade de hoje, este artigo apresenta as diversas pseudo-classes CSS que se comportam como uma espécie de event listener, para depois fazer o mesmo com o event-trigger, mostrando como (provavelmente) esse recurso ainda sem suporte deve funcionar.
Pseudo-classes que “escutam eventos”
:hover e :active
O estado :hover captura o intervalo entre o disparo do evento pointerenter e o disparo do evento pointerleave, o que ilustra perfeitamente por que pseudo-classes são estados, e não eventos.
Já :active corresponde ao alvo (por exemplo, um link ou botão) que está sendo pressionado no momento com o mouse, o dedo ou uma caneta stylus, o que o aproxima de pointerdown e pointerup/pointercancel.
:focus e :focus-visible
A pseudo-classe :focus é parecida com os eventos JavaScript focus e blur (perda de foco), mas :focus-visible é um pouco mais complexa.
Ela é acionada quando :focus também é, mas, além disso, o navegador usa uma série de heurísticas para determinar se um indicador de foco deve ou não ser exibido. O usuário está navegando pelo teclado? O elemento é um controle de formulário? É justamente aí que dá para valorizar o que o CSS oferece.
Na prática, a melhor forma de lidar com isso via JavaScript é consultar a própria pseudo-classe CSS:
element.addEventListener("focus", (event) => { if (event.target.matches(":focus-visible")) { /* Faça algo */ }});:focus-within (e :has())
O JavaScript é excelente naquele tipo de lógica “se A é Y, então faça Z em B”. É possível percorrer o DOM, aproveitar a propagação de eventos e muito mais. Nesse aspecto, o CSS pode parecer um pouco limitado. No entanto, o CSS está evoluindo rápido.
Ele já conta com vários recursos do tipo “se isto, faça aquilo”, como as scroll-driven animations, e terá ainda mais no futuro. O HTML segue o mesmo caminho, com componentes dedicados como o <details>, todos acompanhados de recursos CSS próprios.
Alguns desses recursos, aliás, aparecem mais adiante. De forma mais ampla, o que se tem é a :focus-within, que corresponde quando um elemento filho está em foco, e a :has(), que aceita qualquer seletor válido e corresponde quando existe essa relação entre os dois seletores.
Por exemplo, estes dois seletores fazem exatamente a mesma coisa:
form:focus-within { /* Estiliza o formulário quando algo dentro dele está em foco */}
form:has(:focus) { /* Estiliza o formulário quando algo dentro dele está em foco */}:checked
É bem óbvio o que a :checked faz. O evento JavaScript mais equivalente a ela é o change, disparado quando o valor de um <input>, <select> ou <textarea> muda (embora, nesse contexto, o evento input seja bem parecido).
Para escutar uma marcação, faríamos algo assim:
checkbox.addEventListener("change", (event) => { if (event.target.checked) { /* Marcado */ } else { /* Não marcado */ }});As pseudo-classes CSS frequentemente capturam o intervalo entre dois eventos JavaScript (como pointerenter e pointerleave), mas, quando não estão fazendo isso, estão lidando com lógica, como no exemplo acima.
Vale ver mais alguns exemplos desse tratamento de lógica escondido.
:valid / :invalid / :user-valid / :user-invalid / :autofill
Aqui não é preciso a função de pseudo-classe :not(), já que a validade pode ser verificada tanto com :valid quanto com :invalid.
Do lado do JavaScript, porém, não existe evento valid (apenas invalid). Dito isso, ao usar JavaScript, o mais provável é querer chamar o método checkValidity() (que, na verdade, dispara o evento invalid caso retorne false) dentro do callback do event listener de input, change, blur (para verificar a validade ao perder o foco de um elemento) ou submit (para verificar a validade do formulário inteiro ao enviá-lo, como abaixo).
form.addEventListener("submit", () => { if (form.checkValidity()) { /* Todos os controles do formulário são válidos */ } else { /* Algum controle é inválido (o evento invalid é disparado) */ }});Também dá para fazer isso com o objeto ValidityState, que não dispara o evento invalid, mas informa por que um controle de formulário é válido ou inválido, da mesma forma que a validação de formulários do HTML faz:
input.addEventListener("input", () => { if (input.validity.valid) { /* O input é válido */ } else { /* O input é inválido (o evento invalid não é disparado) */ }});O que acontece com a validação de formulários do HTML é que ela cuida de todo o front-end, mas, se você precisa de um comportamento diferente do padrão, checkValidity() ou ValidityState são o que procura.
As pseudo-classes vão funcionar de qualquer maneira. Bem demais, até. Um detalhe fácil de passar despercebido é que os controles de formulário acionam :valid ou :invalid imediatamente. Já :user-valid e :user-invalid esperam o usuário fornecer um valor e tirar o foco antes de serem acionadas.
É exatamente isso que o evento change faz (a menos que o elemento seja um checkbox, radio button, lista suspensa, seletor de cor ou range slider), e é o que o diferencia do evento input.
Não existe um evento JavaScript para o preenchimento automático (autofill) nem sequer uma forma elegante de detectá-lo via JavaScript, mas existe uma pseudo-classe :autofill.
Pseudo-classes de elementos de mídia
As pseudo-classes de elementos de mídia ainda são novidade. Elas ainda não têm suporte no Chrome e chegaram ao Firefox só recentemente, mas fazem parte do Interop 2026 e, em breve, será possível estilizar elementos <audio> e <video> com base no seu estado sem escutar eventos JavaScript.
A essa altura o funcionamento já deve estar claro, então segue um resumo rápido:
| Pseudo-classe | Evento JavaScript equivalente |
|---|---|
:buffering | waiting |
:muted | volumechange (mas veja abaixo) |
:paused | pause |
:playing | playing (não play) |
:seeking | seeking |
:stalled | stalled |
:volume-locked | N/A, veja abaixo |
Use o evento volumechange para detectar o mudo (mute):
audio.addEventListener("volumechange", () => { if (audio.muted) { // Mudo } else { // Não mudo }});Detectar o bloqueio de volume (volume lock) significa tentar mudar o volume e verificar se deu certo. A melhor abordagem é criar um elemento totalmente novo, para não disparar volumechange no elemento real:
// Cria o vídeoconst video = document.createElement("video");
// Muda o volumevideo.volume = 0.5;
if (video.volume !== 0.5) { // Volume bloqueado} else { // Volume não bloqueado}Ou usar a pseudo-classe :volume-locked, caso esteja escrevendo CSS.
:popover-open / :open / :modal
Como era de se esperar, não há um evento JavaScript para quando um popover, <dialog> ou <details> abre ou fecha, mas dá para escutar o evento toggle e então verificar o estado:
element.addEventListener("toggle", () => { if (element.open) { /* Popover/dialog/details aberto */ } else { /* Popover/dialog/details fechado */ }});No entanto, o CSS oferece estas pseudo-classes prontas de fábrica:
:popover-open(para popovers):open(para elementos<dialog>e<details>):modal(para<dialog>s modais e elementos em tela cheia)
Por falar em elementos em tela cheia…
:fullscreen
A pseudo-classe :fullscreen é equivalente ao evento JavaScript fullscreenchange, com uma condicional já embutida:
document.addEventListener("fullscreenchange", () => { if (document.fullscreenElement) { /* fullscreenElement está em tela cheia */ } else { /* Nada está em tela cheia (fullscreenElement é null) */ }});:target
Quando o hash de uma URL (por exemplo, #contact) corresponde ao ID de um elemento (por exemplo, <div id="contact">), esse elemento corresponde à pseudo-classe :target. Com JavaScript, é preciso escutar o evento hashchange e então verificar se há um elemento correspondente:
window.addEventListener("hashchange", () => { const target = document.getElementById(window.location.hash.substring(1));
if (target) { /* Elemento correspondente encontrado */ } else { /* Elemento correspondente não encontrado */ }});Este artigo não é um desabafo do tipo “JavaScript é ruim”, e sim um reconhecimento do que o CSS simplifica, sem esquecer o controle cirúrgico que o JavaScript oferece. Ter mais formas de fazer as coisas nunca é ruim.
E, nesse espírito, vale mencionar rapidamente o event-trigger.
Event listeners de verdade (event-trigger)
Os event triggers surgiram quando o Chrome implementou as animações disparadas por scroll, já que fazem parte do mesmo módulo.
Mas eles ainda não têm suporte em nenhum navegador, então, eventuais imprecisões ficam desde já justificadas.
O event-trigger-name aceita um dashed ident simples:
button { event-trigger-name: --event;}O event-trigger-source será, essencialmente, o event listener.
Ele aceitará as seguintes palavras-chave:
activateinterestclicktouchdblclickkeypress(<string>)
button { event-trigger-source: click;}Provavelmente a palavra-chave interest se refere à futura Interest Invoker API, enquanto a palavra-chave activate pode depender do elemento. No caso do <details>, por exemplo, a ativação poderia significar quando aberto, mas isso ainda não é certo. As próximas versões da especificação devem esclarecer mais e revelar novos eventos.
De todo modo, os eventos vão disparar animações. Primeiro criaríamos uma animação com @keyframes, depois a atribuiríamos ao elemento a ser animado, mas a animação só rodaria quando disparada pelo evento (enquanto, normalmente, ela rodaria de imediato).
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; }}
div { animation: fade-in 300ms both;}Em seguida, garantimos que, quando o evento é disparado, a animação seja acionada.
Fazemos isso definindo animation-trigger ao lado de animation, referenciando o dashed ident (--event). Isso tem o benefício opcional de permitir que o evento de um elemento dispare a animação de outro. Segue um exemplo rápido, desta vez usando o atalho (shorthand) event-trigger:
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; }}
button { /* Ao clicar, dispara a animação --event */ event-trigger: --event click;}
div { /* Quando --event é disparado, roda a animação para a frente */ animation-trigger: --event play-forwards;
/* Animação */ animation: fade-in 300ms both;}Isso é o que se chama de event trigger sem estado (stateless). Pense bem: não dá para “desclicar” um clique, certo? Mas dá para perder o interesse, então veja como ficaria uma animação disparada por evento com estado (stateful).
Repare na sintaxe com dois eventos separados por / e duas ações de animação, uma para cada estado:
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; }}
button { /* interest (entrada) / interest (saída) */ event-trigger: --event interest / interest;}
div { /* Roda para a frente com o interesse, e para trás ao perdê-lo */ animation-trigger: --event play-forwards play-backwards;
/* Animação */ animation: fade-in 300ms both;}As ações de animação aceitas incluem:
noneplayplay-onceplay-forwardsplay-backwardspauseresetreplay
Há muitas combinações de eventos e ações de animação que não funcionariam, mas seriam fáceis de evitar, porque nem faria sentido usá-las. Ainda assim, seria possível disparar várias animações diferentes, já que animation-trigger é uma sub-propriedade reset-only de animation.
Segue um exemplo simplificado:
animation-name: animationA, animationB;animation-trigger: --eventA play, --eventB replay;As possibilidades são infinitas, dependendo de como o W3C tocar esse recurso adiante. A especificação chega a mencionar suporte a event bubbling!
Fica, porém, o desejo de poder invocar métodos JavaScript com os event triggers, do jeito que o HTML faz com a Invoker Commands API.
Quem segue o dpw sabe nossa opinião, mas e você, o que acha? Um passo na direção certa ou o CSS deveria ficar no seu quadrado?