Novo Livro!
Categorias
Projetos
CSS

A linha tênue entre estados CSS e eventos JavaScript

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.

: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-classeEvento JavaScript equivalente
:bufferingwaiting
:mutedvolumechange (mas veja abaixo)
:pausedpause
:playingplaying (não play)
:seekingseeking
:stalledstalled
:volume-lockedN/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ídeo
const video = document.createElement("video");
// Muda o volume
video.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:

  • activate
  • interest
  • click
  • touch
  • dblclick
  • keypress(<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:

  • none
  • play
  • play-once
  • play-forwards
  • play-backwards
  • pause
  • reset
  • replay

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?