Abra o console em qualquer aplicação com modal e a mensagem está lá: “Blocked aria-hidden on an element because its descendant retained focus.” Procure a frase e você vai cair em dezenas de discussões abertas em repositório de biblioteca de componente, quase sempre com a mesma resposta mais votada: coloque tabindex="-1" nos elementos de dentro e o aviso some.
Some mesmo. E o defeito continua exatamente onde estava.
A posição deste post é direta: esse aviso não é ruído de ferramenta. Ele é o navegador avisando que recusou obedecer ao seu aria-hidden, porque obedecer produziria um estado que ninguém consegue descrever para o usuário. O problema não está no atributo. Está na ordem em que o seu código esconde a região.
O que aria-hidden faz, e o que ele nunca fez
A definição do MDN é curta e vale ler devagar: adicionar aria-hidden="true" a um elemento remove aquele elemento e todos os seus filhos da árvore de acessibilidade. E, na frase seguinte, a parte que a maioria pula: o atributo esconde conteúdo da tecnologia assistiva, mas não esconde nada visualmente.
Duas consequências saem daí. A primeira é que o elemento continua desenhado na tela, clicável com o mouse e alcançável pelo Tab. A segunda é que a herança é total e irreversível para baixo: a regra do axe registra que, uma vez que aria-hidden="true" está num ancestral, colocar aria-hidden="false" num descendente não devolve aquele conteúdo. Não existe furo no cobertor.
Por isso o próprio MDN afirma que aria-hidden="true" não deve ser usado em elementos que podem receber foco e, como o atributo é herdado pelos filhos, também não deve ser colocado no pai ou em qualquer ancestral de um elemento que possa receber foco.
Foco escondido é um estado que ninguém sabe narrar
Pense no que acontece quando as duas coisas coincidem. O foco do teclado está num botão. Esse botão, ou algum ancestral dele, ganha aria-hidden="true". Agora existe um elemento que detém o foco e que, ao mesmo tempo, não existe na árvore que o leitor de tela consulta.
Não há saída boa. Se a tecnologia assistiva respeitar o atributo, ela precisa anunciar o foco atual e não tem nada para anunciar: o usuário fica com o cursor parado num vazio, sem nome, sem papel e sem valor. Se ela ignorar o atributo, ela contradiz o que o autor pediu. É exatamente esse impasse que a regra aria-hidden-focus do axe descreve, e que ela mapeia para o critério 4.1.2 da WCAG, Name, Role, Value. O elemento focado precisa ter nome, papel e valor expostos. Escondido, ele não tem nenhum dos três.
O navegador escolheu o lado menos ruim: ignora o aria-hidden na cadeia de ancestrais do elemento focado e conta para você no console. O aviso é o registro de uma decisão tomada contra a sua instrução, não uma sugestão de estilo.
Por que tabindex=”-1″ cala o aviso e mantém o problema
A remediação que circula nas threads vem da própria página da regra: colocar tabindex="-1" nos elementos que recebem foco, ou display:none, ou disabled nos campos de formulário. As três estão corretas para o caso que a regra descreve, que é conteúdo estaticamente marcado como escondido contendo algo que recebe foco.
O caso do modal é outro. Ali o foco já está dentro da região no instante em que você a esconde. E tabindex="-1" não tira o foco de ninguém: ele apenas retira o elemento da ordem de tabulação para o próximo Tab. Um elemento com tabindex="-1" continua perfeitamente capaz de receber foco por script, e continua focado se já estava. Você removeu o sintoma que a checagem estática enxerga e manteve o foco preso num subárvore invisível.
O display:none resolve de verdade, porque remove o elemento da renderização e o foco cai fora dele. O custo é que ele é incompatível com qualquer animação de saída: não dá para desaparecer com opacity em 200 ms um elemento que já não é desenhado.
inert age no foco, e é por isso que ele resolve
O atributo inert é a ferramenta que faz as duas coisas ao mesmo tempo. Segundo o MDN, o elemento e todos os descendentes na árvore plana ficam inertes, o que significa que não podem ser focados e não disparam eventos de focus, não disparam click quando clicados, não permitem seleção do texto que contêm, não são encontrados pela busca na página do navegador e ficam de fora da árvore de acessibilidade.
Repare que a exclusão da árvore de acessibilidade, que é tudo o que aria-hidden entrega, aparece por último numa lista que começa pelo foco. Essa é a diferença inteira. aria-hidden descreve uma intenção para a tecnologia assistiva. inert muda o comportamento do documento, e a descrição vira consequência de um estado que passou a ser verdade.
Disponibilidade não é mais argumento contra: o MDN classifica inert como Baseline widely available, disponível nos navegadores desde abril de 2023.
O contrato de fechamento
A correção do aviso não é trocar um atributo por outro na mesma linha. É garantir que o foco saia antes de a região sumir. Em ordem:
function fecharModal(modal, gatilho) {
// 1. devolve o foco primeiro, de forma sincrona
gatilho.focus();
// 2. so entao torna o modal inerte
modal.inert = true;
modal.setAttribute('aria-hidden', 'true');
// 3. o fundo volta a ser interativo
document.querySelector('#app').inert = false;
// 4. desmonta quando a animacao de saida terminar
modal.addEventListener('transitionend', () => modal.remove(), { once: true });
}
O passo 1 antes do passo 2 é o post inteiro. Invertido, você recria o mesmo estado com nome diferente, e o navegador vai reclamar de novo. Vale guardar a referência do elemento que abriu o modal na abertura, porque na hora de fechar ele já não está em document.activeElement.
Fica um aviso do próprio MDN que costuma virar bug de regressão: por padrão não existe nenhuma indicação visual de que um elemento ou sua subárvore estão inertes. Marcar o fundo como inerte e esquecer de desmarcar produz uma página que parece normal e não responde a nada.
A exceção do dialog modal
Se você usa <dialog> aberto com showModal(), boa parte disso já vem pronto e o MDN registra a regra especial: elementos <dialog> modais escapam da inércia, ou seja, não herdam inércia dos ancestrais, mas podem ser tornados inertes com o atributo posto neles mesmos. Nenhum outro elemento escapa.
É uma exceção pequena com efeito grande. Ela permite marcar o container da aplicação inteiro como inerte sem apagar junto o diálogo que você acabou de abrir, que é justamente o caso que qualquer implementação manual erra na primeira tentativa.
Perguntas frequentes
Posso simplesmente silenciar o aviso no console?
Pode, e o foco continua preso. O aviso é a única sinalização barata que você tem de um defeito que, no uso real, aparece como usuário de leitor de tela com o cursor num lugar sem nome.
Preciso dos dois, inert e aria-hidden?
Para navegador atual, inert sozinho já retira da árvore de acessibilidade. Manter aria-hidden junto não atrapalha e ajuda em combinações antigas de navegador com leitor de tela, desde que o foco já tenha saído.
E se a região escondida não tem nada que receba foco?
Aí aria-hidden é o uso correto do atributo. Ícone decorativo, texto duplicado que existe só para o olho, separador visual: nada disso recebe foco e nada disso precisa de inert.
Fontes