# A linha tênue entre estados CSS e eventos JavaScript

> Pseudo-classes como :hover, :focus e :checked funcionam quase como listeners de eventos JS. Veja os paralelos com CSS e o futuro event-trigger.

URL: https://dpw.dev/css/a-linha-tenue-entre-estados-css-e-eventos-javascript/  
Publicado em: 2026-07-20  
Categoria: css  
Tags: boas-praticas  
Autor: Tárcio Zemel

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).

> Artigo baseado em [The Shifting Line Between CSS States and JavaScript Events](https://css-tricks.com/css-states-and-javascript-events/).

Aliás, o que _é_ o CSS hoje em dia? Existe, por exemplo, uma <a href="https://drafts.csswg.org/animation-triggers-1/#event-triggers" target="_blank" rel="noopener noreferrer">proposta para o `event-trigger`</a> 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 <a href="https://css-tricks.com/invoker-commands-additional-ways-to-work-with-dialog-popover-and-more/" target="_blank" rel="noopener noreferrer">invoker commands</a>, 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 <a href="/acessibilidade/acessibilidade-css/" target="_blank">indicador de foco</a> 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:

```javascript
element.addEventListener("focus", (event) => {
  if (event.target.matches(":focus-visible")) 
});
```

### `: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 <a href="https://youtu.be/7DhzvhdbgTE" target="_blank" rel="noopener noreferrer">scroll-driven animations</a>, 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.

> **Dica:** Veja nosso vídeo sobre `:has()`:
>
> [Vídeo no YouTube](https://www.youtube.com/watch?v=Ia_4XdisCGQ)

Por exemplo, estes dois seletores fazem exatamente a mesma coisa:

```css
form:focus-within 

form:has(:focus) 
```

### `: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:

```javascript
checkbox.addEventListener("change", (event) => {
  if (event.target.checked)  else 
});
```

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).

```javascript
form.addEventListener("submit", () => {
  if (form.checkValidity())  else 
});
```

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:

```javascript
input.addEventListener("input", () => {
  if (input.validity.valid)  else 
});
```

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 <a href="https://css-tricks.com/interop-2026/" target="_blank" rel="noopener noreferrer">Interop 2026</a> 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):

```javascript
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:

```javascript
// 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:

```javascript
element.addEventListener("toggle", () => {
  if (element.open)  else 
});
```

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:

```javascript
document.addEventListener("fullscreenchange", () => {
  if (document.fullscreenElement)  else 
});
```

### `: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:

```javascript
window.addEventListener("hashchange", () => {
  const target = document.getElementById(window.location.hash.substring(1));

  if (target)  else 
});
```

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 <a href="https://drafts.csswg.org/animation-triggers-1/#event-triggers" target="_blank" rel="noopener noreferrer">event triggers</a> 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:

```css
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>)`

```css
button {
  event-trigger-source: click;
}
```

Provavelmente a palavra-chave `interest` se refere à futura <a href="https://css-tricks.com/a-first-look-at-the-interest-invoker-api-for-hover-triggered-popovers/" target="_blank" rel="noopener noreferrer">Interest Invoker API</a>, 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 <a href="/css/animacoes-css-introducao/" target="_blank">animação</a> 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).

```css
@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.

> **Dica:** Veja nosso vídeo completo sobre Animações CSS:
>
> [Vídeo no YouTube](https://www.youtube.com/watch?v=eTELLTacg-8)

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`:

```css
@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:

```css
@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:

```css
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 <a href="https://x.com/desenvolvweb" target="_blank" rel="noopener noreferrer">segue o dpw</a> sabe nossa opinião, mas e você, o que acha? Um passo na direção certa ou o CSS deveria ficar no seu quadrado?

---

Fonte: [dpw - desenvolvimento para web](https://dpw.dev/) (pt-BR).
Guia para agentes: https://dpw.dev/llms.txt
Índice completo: https://dpw.dev/sitemap-index.xml
Feed: https://dpw.dev/rss.xml
