React & Frontend

Acessibilidade em React: ARIA, Focus Management e Screen Readers: Do Básico ao Avançado

18 min de leitura

Acessibilidade em React: ARIA, Focus Management e Screen Readers: Do Básico ao Avançado

O Que é Acessibilidade Web e Por Que Importa em React Acessibilidade web é um conjunto de práticas e técnicas que garantem que aplicações funcionem perfeitamente para todos os usuários, incluindo aqueles com deficiências visuais, auditivas, motoras ou cognitivas. Em React, essa responsabilidade começa no próprio desenvolvedor. Não é uma feature opcional ou um "nice to have" — é uma obrigação legal em muitos países (como WCAG 2.1) e, acima de tudo, é sobre inclusão genuína. Quando você cria uma aplicação React inacessível, está excluindo ativamente um percentual significativo da população. Screen readers (leitores de tela) são a ferramenta principal usada por pessoas cegas ou com baixa visão, e eles dependem de HTML semântico e atributos ARIA para funcionar. React, por ser uma biblioteca JavaScript baseada em Virtual DOM, introduz desafios únicos: elementos são criados dinamicamente, o foco pode ser perdido após re-renders, e a estrutura DOM pode estar completamente desacoplada da semântica esperada. Neste artigo, vou te ensinar como

<h2>O Que é Acessibilidade Web e Por Que Importa em React</h2>

<p>Acessibilidade web é um conjunto de práticas e técnicas que garantem que aplicações funcionem perfeitamente para todos os usuários, incluindo aqueles com deficiências visuais, auditivas, motoras ou cognitivas. Em React, essa responsabilidade começa no próprio desenvolvedor. Não é uma feature opcional ou um &quot;nice to have&quot; — é uma obrigação legal em muitos países (como WCAG 2.1) e, acima de tudo, é sobre inclusão genuína.</p>

<p>Quando você cria uma aplicação React inacessível, está excluindo ativamente um percentual significativo da população. Screen readers (leitores de tela) são a ferramenta principal usada por pessoas cegas ou com baixa visão, e eles dependem de HTML semântico e atributos ARIA para funcionar. React, por ser uma biblioteca JavaScript baseada em Virtual DOM, introduz desafios únicos: elementos são criados dinamicamente, o foco pode ser perdido após re-renders, e a estrutura DOM pode estar completamente desacoplada da semântica esperada. Neste artigo, vou te ensinar como dominar os três pilares da acessibilidade em React: ARIA (Accessible Rich Internet Applications), gerenciamento de foco e compatibilidade com leitores de tela.</p>

<h2>ARIA: Atributos para Enriquecer Semântica</h2>

<h3>O Que é ARIA e Como Funciona</h3>

<p>ARIA é um conjunto de atributos HTML que você adiciona a elementos para comunicar seu significado, estado e comportamento aos leitores de tela. Importante: ARIA não muda a aparência visual ou comportamento funcional. Ela apenas fornece informações adicionais ao leitor de tela. O princípio fundamental é: <strong>sempre prefira HTML semântico em primeiro lugar</strong>. Você só usa ARIA quando HTML puro não consegue expressar a intenção.</p>

<p>ARIA funciona através de três categorias principais: <strong>roles</strong> (o que um elemento é), <strong>properties</strong> (características permanentes) e <strong>states</strong> (condições mutáveis). Um botão customizado feito com <code>&lt;div&gt;</code>, por exemplo, não é semanticamente um botão. Você precisa dizer ao leitor de tela que é um botão usando <code>role=&quot;button&quot;</code>. Quando esse botão está desativado, você usa <code>aria-disabled=&quot;true&quot;</code>. Quando ele abre um menu, você usa <code>aria-expanded=&quot;true&quot;</code> ou <code>aria-expanded=&quot;false&quot;</code>.</p>

<pre><code class="language-jsx"></code></pre>

<h3>ARIA Labels e Descrições</h3>

<p>Elementos visuais que parecem óbvios para você podem ser completamente invisíveis para um leitor de tela. Um ícone de fechar (X) em um modal, por exemplo, não comunica nada. Você precisa de um label. Existem várias formas de fazer isso: <code>aria-label</code>, <code>aria-labelledby</code> e <code>aria-describedby</code>.</p>

<p>Use <code>aria-label</code> para elementos que não têm texto visível. Use <code>aria-labelledby</code> quando o label já existe na página em outro elemento. Use <code>aria-describedby</code> para descrições adicionais que complementam o label principal. Veja a prática:</p>

<pre><code class="language-jsx"></code></pre>

<h3>ARIA Live Regions</h3>

<p>Live regions comunicam mudanças dinâmicas ao leitor de tela sem exigir que o usuário navegue até elas. São essenciais em React porque o conteúdo muda constantemente via JavaScript. Use <code>aria-live</code> com valores <code>polite</code> (espera um tempo antes de anunciar) ou <code>assertive</code> (anuncia imediatamente). Também use <code>aria-atomic</code> para indicar se toda a região ou apenas a parte mudada deve ser lida.</p>

<pre><code class="language-jsx">import { useState } from &#039;react&#039;;

function NotificationBanner() {

const [notification, setNotification] = useState(&#039;&#039;);

const handleSubmit = () =&gt; {

setNotification(&#039;Formulário enviado com sucesso!&#039;);

setTimeout(() =&gt; setNotification(&#039;&#039;), 3000);

};

return (

&lt;&gt;

&lt;div

aria-live=&quot;polite&quot;

aria-atomic=&quot;true&quot;

role=&quot;status&quot;

className=&quot;sr-only&quot;

&gt;

{notification}

&lt;/div&gt;

&lt;button onClick={handleSubmit}&gt;Enviar formulário&lt;/button&gt;

&lt;/&gt;

);

}

function SearchResults({ query, results, isLoading }) {

return (

&lt;div&gt;

&lt;input

type=&quot;text&quot;

value={query}

onChange={(e) =&gt; console.log(e.target.value)}

placeholder=&quot;Buscar...&quot;

/&gt;

&lt;div

aria-live=&quot;assertive&quot;

aria-atomic=&quot;true&quot;

role=&quot;region&quot;

aria-label=&quot;Resultados da busca&quot;

&gt;

{isLoading &amp;&amp; &lt;p&gt;Carregando resultados...&lt;/p&gt;}

{!isLoading &amp;&amp; results.length &gt; 0 &amp;&amp; (

&lt;p&gt;{results.length} resultados encontrados&lt;/p&gt;

)}

{!isLoading &amp;&amp; results.length === 0 &amp;&amp; &lt;p&gt;Nenhum resultado encontrado&lt;/p&gt;}

&lt;/div&gt;

&lt;/div&gt;

);

}</code></pre>

<h2>Focus Management: Navegação com Teclado</h2>

<h3>Por Que Focus Management É Crítico</h3>

<p>Focus (foco) é a atual &quot;posição&quot; do usuário na página. Para usuários que utilizam teclado — seja porque têm deficiência motora ou simplesmente preferem — o foco é tudo. Se você abrir um modal e o foco permanecer no botão atrás dele, o usuário de teclado continuará navegando por trás do modal. Se você deletar um elemento focado, o foco desaparece e o usuário fica perdido. React torna isso particularmente complicado porque o DOM é frequentemente re-renderizado, e você perde a referência do elemento focado.</p>

<p>O objetivo é seguir uma regra simples: <strong>o foco sempre deve estar em um lugar lógico e previsível</strong>. Quando um modal abre, o foco entra nele. Quando um item é deletado, o foco vai para o próximo item ou para o contenedor pai. Quando uma página carrega, o foco vai para o heading principal.</p>

<h3>useRef para Manipular Foco Diretamente</h3>

<p>React desencoraja manipulação direta do DOM, mas focus management é um caso válido. Use <code>useRef</code> para manter uma referência ao elemento e <code>focus()</code> para movê-lo. Nunca force foco sem um motivo específico — isso frustrava usuários:</p>

<pre><code class="language-jsx">import { useRef } from &#039;react&#039;;

function Modal({ isOpen, onClose }) {

const closeButtonRef = useRef(null);

const modalRef = useRef(null);

// Quando o modal abre, foca no botão de fechar

React.useEffect(() =&gt; {

if (isOpen &amp;&amp; closeButtonRef.current) {

closeButtonRef.current.focus();

}

}, [isOpen]);

// Trap focus: impede navegação para fora do modal

const handleKeyDown = (e) =&gt; {

if (e.key === &#039;Escape&#039;) {

onClose();

}

};

return (

isOpen &amp;&amp; (

&lt;div

ref={modalRef}

role=&quot;dialog&quot;

aria-modal=&quot;true&quot;

aria-labelledby=&quot;modal-title&quot;

onKeyDown={handleKeyDown}

&gt;

&lt;h2 id=&quot;modal-title&quot;&gt;Diálogo importante&lt;/h2&gt;

&lt;p&gt;Conteúdo do modal&lt;/p&gt;

&lt;button ref={closeButtonRef} onClick={onClose}&gt;

Fechar

&lt;/button&gt;

&lt;/div&gt;

)

);

}</code></pre>

<h3>Focus Trap em Modais e Drawers</h3>

<p>Um focus trap impede que o usuário saia do modal com a tecla Tab. Isso é essencial porque senão ele estaria navegando por elementos por trás do modal. A lógica é simples: quando o foco sai do último elemento focável dentro do modal, você o redireciona para o primeiro. Vice-versa para Shift+Tab.</p>

<pre><code class="language-jsx">import { useRef, useEffect } from &#039;react&#039;;

function ModalWithFocusTrap({ isOpen, onClose, children }) {

const modalRef = useRef(null);

const previousActiveElementRef = useRef(null);

useEffect(() =&gt; {

if (!isOpen) return;

// Salva qual elemento tinha foco antes do modal abrir

previousActiveElementRef.current = document.activeElement;

const handleKeyDown = (e) =&gt; {

if (e.key !== &#039;Tab&#039;) return;

const focusableElements = modalRef.current.querySelectorAll(

&#039;button, [href], input, select, textarea, [tabindex]:not([tabindex=&quot;-1&quot;])&#039;

);

if (focusableElements.length === 0) return;

const firstElement = focusableElements[0];

const lastElement = focusableElements[focusableElements.length - 1];

// Shift+Tab no primeiro elemento: vai pro último

if (e.shiftKey &amp;&amp; document.activeElement === firstElement) {

e.preventDefault();

lastElement.focus();

}

// Tab no último elemento: vai pro primeiro

else if (!e.shiftKey &amp;&amp; document.activeElement === lastElement) {

e.preventDefault();

firstElement.focus();

}

};

const modal = modalRef.current;

modal.addEventListener(&#039;keydown&#039;, handleKeyDown);

modal.querySelector(&#039;button, input, [tabindex=&quot;0&quot;]&#039;)?.focus();

return () =&gt; {

modal.removeEventListener(&#039;keydown&#039;, handleKeyDown);

// Restaura foco ao elemento anterior quando modal fecha

previousActiveElementRef.current?.focus();

};

}, [isOpen]);

if (!isOpen) return null;

return (

&lt;div

ref={modalRef}

role=&quot;dialog&quot;

aria-modal=&quot;true&quot;

style={{ position: &#039;fixed&#039;, zIndex: 1000 }}

&gt;

{children}

&lt;/div&gt;

);

}</code></pre>

<h3>Skip Links e Navegação Estruturada</h3>

<p>Skip links são links &quot;pule para o conteúdo principal&quot; invisíveis que aparecem quando você pressiona Tab. São cruciais porque permitem que usuários de teclado pulem toda a navegação e vão direto ao conteúdo. Em React, você pode escondê-los com CSS e mostrar no focus:</p>

<pre><code class="language-jsx">function Layout() {

return (

&lt;&gt;

&lt;a

href=&quot;#main-content&quot;

style={{

position: &#039;absolute&#039;,

top: &#039;-40px&#039;,

left: 0,

backgroundColor: &#039;#000&#039;,

color: &#039;#fff&#039;,

padding: &#039;8px&#039;,

textDecoration: &#039;none&#039;,

}}

onFocus={(e) =&gt; {

e.target.style.top = &#039;0&#039;;

}}

onBlur={(e) =&gt; {

e.target.style.top = &#039;-40px&#039;;

}}

&gt;

Pular para conteúdo principal

&lt;/a&gt;

&lt;nav&gt;

&lt;a href=&quot;/&quot;&gt;Home&lt;/a&gt;

&lt;a href=&quot;/about&quot;&gt;Sobre&lt;/a&gt;

&lt;a href=&quot;/contact&quot;&gt;Contato&lt;/a&gt;

&lt;/nav&gt;

&lt;main id=&quot;main-content&quot;&gt;

{/ Conteúdo principal /}

&lt;/main&gt;

&lt;/&gt;

);

}</code></pre>

<h2>Screen Readers: Compatibilidade e Testes</h2>

<h3>Como Screen Readers Funcionam</h3>

<p>Um screen reader é um software que converte texto na tela em fala sintetizada (e/ou braille). A ferramenta lê o DOM processado do navegador, não o código-fonte. Ela navega através de headers, landmarks (regiões), listas, tabelas e links. Cada navegador + screen reader combina e funciona ligeiramente diferente. Os pares mais comuns são: NVDA (Windows), JAWS (Windows), VoiceOver (macOS/iOS) e TalkBack (Android).</p>

<p>Screen readers usam o <strong>Accessibility Tree</strong>, uma representação simplificada do DOM que inclui apenas elementos relevantes (não espaços em branco ou divs vazias). ARIA afeta diretamente como elementos aparecem nessa árvore. Quando você usa <code>aria-hidden=&quot;true&quot;</code>, o elemento desaparece completamente da árvore. Quando você usa <code>role=&quot;presentation&quot;</code>, o elemento fica, mas sua semântica é removida.</p>

<h3>Testando com Screen Readers Reais</h3>

<p>Teste com ferramentas reais, não apenas plugins genéricos de acessibilidade. Se está no Windows, use NVDA (gratuito). No macOS, VoiceOver está integrado (Cmd+F5). A abordagem ideal é testar manualmente porque você entende como reais usuários navegam: às vezes saltando entre headers, às vezes linha por linha, às vezes por tipo de elemento.</p>

<p>Instale NVDA, abra seu app React em desenvolvimento, ative o screen reader (Ctrl+Alt+N no NVDA), e simplesmente navegue. Ouça como ele descreve cada elemento. Se um botão é apenas um <code>&lt;div&gt;</code>, o NVDA dirá &quot;div&quot;, não &quot;botão&quot;. Se uma imagem não tem <code>alt</code>, o NVDA a ignora completamente. Faça isso regularmente durante o desenvolvimento, não apenas no final.</p>

<pre><code class="language-jsx"></code></pre>

<h3>Automated Testing com axe e jest-axe</h3>

<p>Testes automatizados não substituem testes manuais, mas detectam problemas óbvios rapidamente. Use <code>jest-axe</code> para integrar verificações de acessibilidade em seu pipeline CI/CD:</p>

<pre><code class="language-jsx">import { render } from &#039;@testing-library/react&#039;;

import { axe, toHaveNoViolations } from &#039;jest-axe&#039;;

expect.extend(toHaveNoViolations);

describe(&#039;Button Accessibility&#039;, () =&gt; {

it(&#039;should not have accessibility violations&#039;, async () =&gt; {

const { container } = render(

&lt;button aria-label=&quot;Close modal&quot;&gt;×&lt;/button&gt;

);

const results = await axe(container);

expect(results).toHaveNoViolations();

});

it(&#039;should fail when button has no label&#039;, async () =&gt; {

const { container } = render(&lt;button&gt;×&lt;/button&gt;); // Inadequado

const results = await axe(container);

// Isso provavelmente encontrará uma violação

expect(results.violations.length).toBeGreaterThan(0);

});

});</code></pre>

<h3>Ferramenta de Inspeção: DevTools do Navegador</h3>

<p>Chrome DevTools possui uma aba &quot;Accessibility&quot; (dentro do painel de Elementos) que mostra o Accessibility Tree em tempo real. Inspecione um elemento e veja como o screen reader o vê: seu nome computado (label), sua role, seus estados e propriedades ARIA. Firefox tem ferramentas similares. Use isso constantemente enquanto desenvolve.</p>

<h2>Exemplo Prático Completo: Componente Dropdown Acessível</h2>

<p>Para consolidar tudo, vou criar um dropdown (combobox) verdadeiramente acessível. Este exemplo mostra ARIA, focus management, e compatibilidade com screen readers:</p>

<pre><code class="language-jsx">import { useState, useRef, useEffect } from &#039;react&#039;;

function AccessibleDropdown({ options, label }) {

const [isOpen, setIsOpen] = useState(false);

const [selectedIndex, setSelectedIndex] = useState(0);

const buttonRef = useRef(null);

const listRef = useRef(null);

const optionsRef = useRef([]);

const handleKeyDown = (e) =&gt; {

switch (e.key) {

case &#039;ArrowDown&#039;:

e.preventDefault();

setSelectedIndex((prev) =&gt; (prev + 1) % options.length);

break;

case &#039;ArrowUp&#039;:

e.preventDefault();

setSelectedIndex((prev) =&gt; (prev - 1 + options.length) % options.length);

break;

case &#039;Enter&#039;:

case &#039; &#039;:

e.preventDefault();

setIsOpen(!isOpen);

break;

case &#039;Escape&#039;:

e.preventDefault();

setIsOpen(false);

buttonRef.current?.focus();

break;

default:

break;

}

};

useEffect(() =&gt; {

if (isOpen &amp;&amp; optionsRef.current[selectedIndex]) {

optionsRef.current[selectedIndex].focus();

}

}, [isOpen, selectedIndex]);

return (

&lt;div className=&quot;dropdown&quot;&gt;

&lt;label htmlFor=&quot;dropdown-button&quot;&gt;{label}&lt;/label&gt;

&lt;button

ref={buttonRef}

id=&quot;dropdown-button&quot;

aria-haspopup=&quot;listbox&quot;

aria-expanded={isOpen}

aria-controls=&quot;dropdown-list&quot;

onClick={() =&gt; setIsOpen(!isOpen)}

onKeyDown={handleKeyDown}

&gt;

{options[selectedIndex]} &lt;span aria-hidden=&quot;true&quot;&gt;▼&lt;/span&gt;

&lt;/button&gt;

{isOpen &amp;&amp; (

&lt;ul

ref={listRef}

id=&quot;dropdown-list&quot;

role=&quot;listbox&quot;

aria-labelledby=&quot;dropdown-button&quot;

&gt;

{options.map((option, index) =&gt; (

&lt;li

key={option}

ref={(el) =&gt; (optionsRef.current[index] = el)}

role=&quot;option&quot;

aria-selected={index === selectedIndex}

onClick={() =&gt; {

setSelectedIndex(index);

setIsOpen(false);

buttonRef.current?.focus();

}}

onKeyDown={handleKeyDown}

tabIndex={index === selectedIndex ? 0 : -1}

&gt;

{option}

&lt;/li&gt;

))}

&lt;/ul&gt;

)}

&lt;/div&gt;

);

}

// Uso

export default function App() {

return (

&lt;AccessibleDropdown

label=&quot;Escolha uma opção&quot;

options={[&#039;JavaScript&#039;, &#039;TypeScript&#039;, &#039;Python&#039;, &#039;Go&#039;]}

/&gt;

);

}</code></pre>

<p>Aqui está o que fazemos:</p>

<ul>

<li><strong>ARIA</strong>: <code>role=&quot;listbox&quot;</code> e <code>role=&quot;option&quot;</code> comunicam a estrutura ao screen reader. <code>aria-expanded</code> mostra estado. <code>aria-selected</code> indica qual está selecionado.</li>

<li><strong>Focus</strong>: <code>ref</code> mantém referências aos elementos. Quando a lista abre, focamos no item selecionado. Quando escapa, restauramos foco no botão.</li>

<li><strong>Teclado</strong>: Setas para navegar, Enter/Espaço para abrir, Escape para fechar. Sem JavaScript, nada funciona, mas com screen reader toda navegação é clara.</li>

<li><strong>Screen Reader</strong>: Lê &quot;dropdown button, expanded, JavaScript&quot; e depois &quot;listbox with 4 options, JavaScript selected&quot;.</li>

</ul>

<h2>Conclusão</h2>

<p>Acessibilidade em React não é uma adição complexa — é fundamentalmente sobre <strong>HTML semântico como base, ARIA apenas quando necessário, e focus management rigoroso</strong>. Você aprendeu que ARIA enriquece semântica (não a substitui), que focus é essencial para teclado e screen reader, e que testes manuais com ferramentas reais são inegociáveis. A lição mais importante: a maioria dos problemas de acessibilidade vem de HTML inválido ou estrutura DOM incoerente. Use <code>&lt;button&gt;</code> em vez de <code>&lt;div onclick&gt;</code>. Use <code>&lt;h2&gt;</code> para headers, não <code>&lt;div&gt;</code>. Use <code>&lt;label&gt;</code> para inputs. Quando você faz o básico certo, ARIA se torna um complemento elegante, não uma band-aid.</p>

<h2>Referências</h2>

<ul>

<li><a href="https://www.w3.org/WAI/ARIA/apg/" target="_blank" rel="noopener noreferrer">WAI-ARIA Authoring Practices - W3C</a></li>

<li><a href="https://react.dev/learn/accessibility" target="_blank" rel="noopener noreferrer">React Accessibility Documentation</a></li>

<li><a href="https://www.w3.org/WAI/WCAG21/quickref/" target="_blank" rel="noopener noreferrer">Web Content Accessibility Guidelines (WCAG) 2.1</a></li>

<li><a href="https://www.a11yproject.com/checklist/" target="_blank" rel="noopener noreferrer">The A11Y Project Checklist</a></li>

<li><a href="https://inclusive-components.design/" target="_blank" rel="noopener noreferrer">Inclusive Components - Heydon Pickering</a></li>

</ul>

<p>&lt;!-- FIM --&gt;</p>

Comentários

Mais em React & Frontend

useMemo e useCallback: Memoização Real com Análise de Custo na Prática
useMemo e useCallback: Memoização Real com Análise de Custo na Prática

Entendendo Memoização em React Memoização é uma técnica de otimização que con...

Como Usar Testes de Hooks Customizados com renderHook e act em Produção
Como Usar Testes de Hooks Customizados com renderHook e act em Produção

Por que Testar Hooks Customizados? Hooks customizados são um dos pilares da a...

Como Usar Feature Flags em React: LaunchDarkly, Unleash e Rollouts Graduais em Produção
Como Usar Feature Flags em React: LaunchDarkly, Unleash e Rollouts Graduais em Produção

O que são Feature Flags e por que eles importam Feature flags (ou feature tog...