Acessibilidade e performance costumam viver em times diferentes, com métricas diferentes, e isso é um erro de organização. As duas medem a mesma coisa: se a interface responde no tempo e da forma que o usuário real — com o dispositivo, a rede e as capacidades que ele tem — consegue usar. Um botão com INP alto é tão inacessível pra quem usa mouse quanto um botão sem foco visível é pra quem usa teclado.

Esse post cobre o que mudou em WCAG 2.2, os três Core Web Vitals com os limiares atuais, e como colocar os dois num pipeline de CI em vez de depender de auditoria manual antes do deploy.

WCAG 2.2: os nove critérios novos que interessam

WCAG 2.2 virou recomendação do W3C em outubro de 2023 e chegou a 87 critérios de sucesso, contra 78 da versão 2.1. Removeu um (parsing, que já era coberto por HTML moderno) e adicionou nove. A maioria dos novos critérios ataca exatamente os pontos que times de frontend mais erram na prática: foco visível, área de toque e autenticação.

CritérioNívelO que exige na prática
2.4.11 Focus Not Obscured (Minimum)AAElemento focado não pode ficar total ou parcialmente escondido atrás de header fixo, banner ou modal
2.4.13 Focus AppearanceAAAIndicador de foco precisa ter contraste e espessura mínimos, não só uma borda de 1px
2.5.7 Dragging MovementsAToda ação de arrastar precisa ter alternativa por clique/toque simples
2.5.8 Target Size (Minimum)AAAlvo de toque mínimo de 24×24px CSS, com exceções para texto inline e espaçamento suficiente
3.3.8 Accessible Authentication (Minimum)AALogin não pode depender só de teste cognitivo (resolver puzzle, lembrar senha de memória) sem alternativa

O critério que mais gera retrabalho é o 2.4.11. Header fixo é padrão em praticamente todo produto, e ele frequentemente cobre o primeiro item da lista quando o usuário navega por Tab.

/* Corrige elemento focado escondido atrás de header fixo */
:target,
:focus {
  scroll-margin-top: calc(var(--header-height) + 16px);
}

/* Foco visível de verdade — não apenas outline: none removido */
:focus-visible {
  outline: 3px solid var(--color-focus);
  outline-offset: 2px;
}

/* Target Size: garante 24x24 mesmo em ícones pequenos */
.icon-button {
  min-width: 24px;
  min-height: 24px;
  padding: 10px; /* área de toque real fica maior que o ícone visual */
}

scroll-margin-top resolve o 2.4.11 sem JavaScript: o navegador já calcula o offset ao rolar até o elemento focado. E :focus-visible em vez de :focus evita o erro clássico de mostrar o anel de foco em todo clique de mouse — ele só aparece em navegação por teclado, que é quando realmente importa.

Foco gerenciado em SPA: o exemplo que ninguém testa

Troca de rota em SPA quebra o modelo mental de navegação do leitor de tela, porque o DOM muda mas o foco continua onde estava. A correção manual é simples e quase sempre esquecida:

// useFocusOnRouteChange.js
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';

export function useFocusOnRouteChange() {
  const location = useLocation();
  const headingRef = useRef(null);

  useEffect(() => {
    // Move o foco pro heading da nova página e anuncia via leitor de tela
    headingRef.current?.focus();
  }, [location.pathname]);

  return headingRef;
}

// Página
function ProdutoPage() {
  const headingRef = useFocusOnRouteChange();
  return <h1 ref={headingRef} tabIndex={-1}>Detalhes do produto</h1>;
}

tabIndex={-1} permite focar o <h1> programaticamente sem colocá-lo na ordem de Tab. Sem isso, o usuário de leitor de tela continua ouvindo o conteúdo da página anterior até rolar manualmente — na prática, ele nem sabe que a navegação aconteceu.

Core Web Vitals: os três números e o que cada um mede

Desde março de 2024 o INP (Interaction to Next Paint) substituiu o FID como métrica oficial de responsividade — mudança relevante porque FID só media o atraso do primeiro clique, e INP mede a pior interação da visita inteira. Os limiares atuais, avaliados no percentil 75 de visitas reais:

INP é hoje o vital mais reprovado — a maioria dos sites em produção ainda passa de 200ms em pelo menos uma interação, geralmente por causa de long tasks bloqueando a main thread durante clique ou digitação.

Otimizando INP: quebrando long tasks de verdade

O erro comum é otimizar só o tempo de carregamento e ignorar o que acontece depois — cliques em filtro, abrir modal, digitar em busca com autocomplete. Se o handler roda uma tarefa síncrona pesada, o navegador não consegue pintar o próximo frame até ela terminar.

// Sem otimização: processa 5000 itens e trava a main thread
function aplicarFiltro(itens, termo) {
  return itens
    .filter(item => item.nome.includes(termo))
    .map(item => enriquecerComMetadados(item)); // caro
}

// Com yield: devolve o controle ao navegador entre lotes
async function aplicarFiltroComYield(itens, termo) {
  const resultado = [];
  for (let i = 0; i < itens.length; i++) {
    if (itens[i].nome.includes(termo)) {
      resultado.push(enriquecerComMetadados(itens[i]));
    }
    // a cada 200 itens, cede a vez pro navegador pintar/responder input
    if (i % 200 === 0) {
      await new Promise(resolve => setTimeout(resolve, 0));
    }
  }
  return resultado;
}

setTimeout(resolve, 0) força o event loop a processar a fila de renderização e input antes de continuar o loop — é a versão que funciona em qualquer navegador hoje. Onde scheduler.yield() já está disponível, ele faz o mesmo com prioridade mais correta, mas ainda não tem suporte universal.

Otimizando LCP e CLS junto com a UX

<!-- LCP: prioriza a imagem principal antes de qualquer outra coisa -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high" />
<img src="/hero.webp" alt="Painel do produto mostrando o dashboard principal" fetchpriority="high" />

<!-- CLS: reserva o espaço antes da imagem carregar -->
<img
  src="/thumb.webp"
  alt="Miniatura do artigo"
  width="400"
  height="225"
  style="aspect-ratio: 16/9;"
  loading="lazy"
/>

O alt descritivo não é só requisito de WCAG 1.1.1 — ele também é o que aparece enquanto a imagem carrega ou falha, então uma imagem sem alt bom prejudica tanto o leitor de tela quanto a experiência de carregamento lento. width/height (ou aspect-ratio) evitam que o layout pule quando a imagem chega, que é a causa mais comum de CLS alto em páginas de conteúdo.

Fluxo de trabalho: dos dois lados no mesmo CI

Auditoria manual não escala. O ganho real vem de rodar os dois checks automaticamente, antes do merge:

# .github/workflows/quality.yml
jobs:
  accessibility-and-performance:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build

      - name: Testes de acessibilidade com axe
        run: npx playwright test a11y.spec.ts

      - name: Lighthouse CI com orçamento de performance
        uses: treosh/lighthouse-ci-action@v11
        with:
          urls: |
            http://localhost:3000/
            http://localhost:3000/produto
          budgetPath: ./lighthouse-budget.json
          uploadArtifacts: true
// a11y.spec.ts — Playwright + axe-core, direto no fluxo de E2E existente
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('página de produto não tem violações WCAG 2.2 AA', async ({ page }) => {
  await page.goto('/produto');

  const resultados = await new AxeBuilder({ page })
    .withTags(['wcag22aa'])
    .analyze();

  expect(resultados.violations).toEqual([]);
});

Isso transforma acessibilidade e performance em barreira de build, igual teste unitário quebrado — não em item de checklist que alguém preenche de memória na sexta-feira antes do release. Em produção, complementa com a biblioteca web-vitals mandando dados reais (não sintéticos) pro seu analytics, porque o Lighthouse roda em ambiente controlado e não pega a rede 3G do usuário real no interior.

import { onLCP, onINP, onCLS } from 'web-vitals';

onLCP(metric => enviarParaAnalytics('LCP', metric.value));
onINP(metric => enviarParaAnalytics('INP', metric.value));
onCLS(metric => enviarParaAnalytics('CLS', metric.value));

O ponto em comum

WCAG e Core Web Vitals nasceram de comunidades diferentes com vocabulário diferente, mas resolvem o mesmo problema visto de dois ângulos: será que a interface funciona pra quem não tem as condições ideais — o dispositivo mais rápido, a conexão mais estável, a visão mais aguçada, o controle motor mais preciso? Otimizar INP quebrando long tasks ajuda o usuário com CPU fraca e ajuda quem usa switch device pra navegar. Focus visível ajuda quem usa teclado e ajuda quem está numa reunião olhando de relance pra tela.

Tratar os dois como times, métricas e sprints separados garante que cada um otimize só a própria métrica — e que a experiência real, que é a soma dos dois, continue quebrada nas bordas onde ninguém está olhando.