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

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

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 dominar os três pilares da acessibilidade em React: ARIA (Accessible Rich Internet Applications), gerenciamento de foco e compatibilidade com leitores de tela.

ARIA: Atributos para Enriquecer Semântica

O Que é ARIA e Como Funciona

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 é: sempre prefira HTML semântico em primeiro lugar. Você só usa ARIA quando HTML puro não consegue expressar a intenção.

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

// ❌ Ruim: Div sem semântica
function BadButton() {
  return <div onClick={() => console.log('clicked')}>Clique aqui</div>;
}

// ✅ Bom: Elemento button nativo
function GoodButton() {
  return <button onClick={() => console.log('clicked')}>Clique aqui</button>;
}

// ⚠️ Necessário quando customização é extrema
function CustomButton({ isDisabled, isLoading }) {
  return (
    <div
      role="button"
      tabIndex={isDisabled ? -1 : 0}
      aria-disabled={isDisabled}
      aria-busy={isLoading}
      onClick={() => !isDisabled && console.log('clicked')}
      onKeyDown={(e) => {
        if ((e.key === 'Enter' || e.key === ' ') && !isDisabled) {
          console.log('clicked');
        }
      }}
    >
      {isLoading ? 'Carregando...' : 'Clique aqui'}
    </div>
  );
}

ARIA Labels e Descrições

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: aria-label, aria-labelledby e aria-describedby.

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

// ❌ Ruim: Ícone sem contexto
function CloseButton() {
  return (
    <button>
      <span>×</span>
    </button>
  );
}

// ✅ Bom: Aria-label deixa claro
function GoodCloseButton() {
  return (
    <button aria-label="Fechar diálogo">
      <span aria-hidden="true">×</span>
    </button>
  );
}

// ✅ Usando aria-labelledby para associar
function DialogWithLabel() {
  return (
    <div role="dialog">
      <h2 id="dialog-title">Confirmar ação</h2>
      <p id="dialog-desc">Esta ação é irreversível</p>
      <div role="dialog" aria-labelledby="dialog-title" aria-describedby="dialog-desc">
        {/* conteúdo */}
      </div>
    </div>
  );
}

ARIA Live Regions

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 aria-live com valores polite (espera um tempo antes de anunciar) ou assertive (anuncia imediatamente). Também use aria-atomic para indicar se toda a região ou apenas a parte mudada deve ser lida.

import { useState } from 'react';

function NotificationBanner() {
  const [notification, setNotification] = useState('');

  const handleSubmit = () => {
    setNotification('Formulário enviado com sucesso!');
    setTimeout(() => setNotification(''), 3000);
  };

  return (
    <>
      <div
        aria-live="polite"
        aria-atomic="true"
        role="status"
        className="sr-only"
      >
        {notification}
      </div>
      <button onClick={handleSubmit}>Enviar formulário</button>
    </>
  );
}

function SearchResults({ query, results, isLoading }) {
  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={(e) => console.log(e.target.value)}
        placeholder="Buscar..."
      />
      <div
        aria-live="assertive"
        aria-atomic="true"
        role="region"
        aria-label="Resultados da busca"
      >
        {isLoading && <p>Carregando resultados...</p>}
        {!isLoading && results.length > 0 && (
          <p>{results.length} resultados encontrados</p>
        )}
        {!isLoading && results.length === 0 && <p>Nenhum resultado encontrado</p>}
      </div>
    </div>
  );
}

Focus Management: Navegação com Teclado

Por Que Focus Management É Crítico

Focus (foco) é a atual "posição" 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.

O objetivo é seguir uma regra simples: o foco sempre deve estar em um lugar lógico e previsível. 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.

useRef para Manipular Foco Diretamente

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

import { useRef } from 'react';

function Modal({ isOpen, onClose }) {
  const closeButtonRef = useRef(null);
  const modalRef = useRef(null);

  // Quando o modal abre, foca no botão de fechar
  React.useEffect(() => {
    if (isOpen && closeButtonRef.current) {
      closeButtonRef.current.focus();
    }
  }, [isOpen]);

  // Trap focus: impede navegação para fora do modal
  const handleKeyDown = (e) => {
    if (e.key === 'Escape') {
      onClose();
    }
  };

  return (
    isOpen && (
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        onKeyDown={handleKeyDown}
      >
        <h2 id="modal-title">Diálogo importante</h2>
        <p>Conteúdo do modal</p>
        <button ref={closeButtonRef} onClick={onClose}>
          Fechar
        </button>
      </div>
    )
  );
}

Focus Trap em Modais e Drawers

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.

import { useRef, useEffect } from 'react';

function ModalWithFocusTrap({ isOpen, onClose, children }) {
  const modalRef = useRef(null);
  const previousActiveElementRef = useRef(null);

  useEffect(() => {
    if (!isOpen) return;

    // Salva qual elemento tinha foco antes do modal abrir
    previousActiveElementRef.current = document.activeElement;

    const handleKeyDown = (e) => {
      if (e.key !== 'Tab') return;

      const focusableElements = modalRef.current.querySelectorAll(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );

      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 && document.activeElement === firstElement) {
        e.preventDefault();
        lastElement.focus();
      }
      // Tab no último elemento: vai pro primeiro
      else if (!e.shiftKey && document.activeElement === lastElement) {
        e.preventDefault();
        firstElement.focus();
      }
    };

    const modal = modalRef.current;
    modal.addEventListener('keydown', handleKeyDown);
    modal.querySelector('button, input, [tabindex="0"]')?.focus();

    return () => {
      modal.removeEventListener('keydown', handleKeyDown);
      // Restaura foco ao elemento anterior quando modal fecha
      previousActiveElementRef.current?.focus();
    };
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div
      ref={modalRef}
      role="dialog"
      aria-modal="true"
      style={{ position: 'fixed', zIndex: 1000 }}
    >
      {children}
    </div>
  );
}

Skip Links e Navegação Estruturada

Skip links são links "pule para o conteúdo principal" 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:

function Layout() {
  return (
    <>
      <a
        href="#main-content"
        style={{
          position: 'absolute',
          top: '-40px',
          left: 0,
          backgroundColor: '#000',
          color: '#fff',
          padding: '8px',
          textDecoration: 'none',
        }}
        onFocus={(e) => {
          e.target.style.top = '0';
        }}
        onBlur={(e) => {
          e.target.style.top = '-40px';
        }}
      >
        Pular para conteúdo principal
      </a>

      <nav>
        <a href="/">Home</a>
        <a href="/about">Sobre</a>
        <a href="/contact">Contato</a>
      </nav>

      <main id="main-content">
        {/* Conteúdo principal */}
      </main>
    </>
  );
}

Screen Readers: Compatibilidade e Testes

Como Screen Readers Funcionam

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

Screen readers usam o Accessibility Tree, 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 aria-hidden="true", o elemento desaparece completamente da árvore. Quando você usa role="presentation", o elemento fica, mas sua semântica é removida.

Testando com Screen Readers Reais

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.

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 <div>, o NVDA dirá "div", não "botão". Se uma imagem não tem alt, o NVDA a ignora completamente. Faça isso regularmente durante o desenvolvimento, não apenas no final.

// Exemplo: como um screen reader vê isto
function ProductCard({ image, title, price }) {
  return (
    <div>
      <img src={image} /> {/* ❌ Screen reader: ignora, sem alt */}
      <h3>{title}</h3>
      <p>${price}</p>
      <div onClick={() => console.log('buy')}>Buy</div> {/* ❌ não é um botão */}
    </div>
  );
}

// ✅ Acessível
function AccessibleProductCard({ image, title, price, onBuy }) {
  return (
    <article>
      <img src={image} alt={`Produto: ${title}`} />
      <h3>{title}</h3>
      <p>Preço: ${price}</p>
      <button onClick={onBuy}>Comprar</button>
    </article>
  );
}

Automated Testing com axe e jest-axe

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

import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

describe('Button Accessibility', () => {
  it('should not have accessibility violations', async () => {
    const { container } = render(
      <button aria-label="Close modal">×</button>
    );
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('should fail when button has no label', async () => {
    const { container } = render(<button>×</button>); // Inadequado
    const results = await axe(container);
    // Isso provavelmente encontrará uma violação
    expect(results.violations.length).toBeGreaterThan(0);
  });
});

Ferramenta de Inspeção: DevTools do Navegador

Chrome DevTools possui uma aba "Accessibility" (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.

Exemplo Prático Completo: Componente Dropdown Acessível

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

import { useState, useRef, useEffect } from 'react';

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) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setSelectedIndex((prev) => (prev + 1) % options.length);
        break;
      case 'ArrowUp':
        e.preventDefault();
        setSelectedIndex((prev) => (prev - 1 + options.length) % options.length);
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        setIsOpen(!isOpen);
        break;
      case 'Escape':
        e.preventDefault();
        setIsOpen(false);
        buttonRef.current?.focus();
        break;
      default:
        break;
    }
  };

  useEffect(() => {
    if (isOpen && optionsRef.current[selectedIndex]) {
      optionsRef.current[selectedIndex].focus();
    }
  }, [isOpen, selectedIndex]);

  return (
    <div className="dropdown">
      <label htmlFor="dropdown-button">{label}</label>
      <button
        ref={buttonRef}
        id="dropdown-button"
        aria-haspopup="listbox"
        aria-expanded={isOpen}
        aria-controls="dropdown-list"
        onClick={() => setIsOpen(!isOpen)}
        onKeyDown={handleKeyDown}
      >
        {options[selectedIndex]} <span aria-hidden="true">▼</span>
      </button>

      {isOpen && (
        <ul
          ref={listRef}
          id="dropdown-list"
          role="listbox"
          aria-labelledby="dropdown-button"
        >
          {options.map((option, index) => (
            <li
              key={option}
              ref={(el) => (optionsRef.current[index] = el)}
              role="option"
              aria-selected={index === selectedIndex}
              onClick={() => {
                setSelectedIndex(index);
                setIsOpen(false);
                buttonRef.current?.focus();
              }}
              onKeyDown={handleKeyDown}
              tabIndex={index === selectedIndex ? 0 : -1}
            >
              {option}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

// Uso
export default function App() {
  return (
    <AccessibleDropdown
      label="Escolha uma opção"
      options={['JavaScript', 'TypeScript', 'Python', 'Go']}
    />
  );
}

Aqui está o que fazemos:

  • ARIA: role="listbox" e role="option" comunicam a estrutura ao screen reader. aria-expanded mostra estado. aria-selected indica qual está selecionado.
  • Focus: ref mantém referências aos elementos. Quando a lista abre, focamos no item selecionado. Quando escapa, restauramos foco no botão.
  • Teclado: Setas para navegar, Enter/Espaço para abrir, Escape para fechar. Sem JavaScript, nada funciona, mas com screen reader toda navegação é clara.
  • Screen Reader: Lê "dropdown button, expanded, JavaScript" e depois "listbox with 4 options, JavaScript selected".

Conclusão

Acessibilidade em React não é uma adição complexa — é fundamentalmente sobre HTML semântico como base, ARIA apenas quando necessário, e focus management rigoroso. 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 <button> em vez de <div onclick>. Use <h2> para headers, não <div>. Use <label> para inputs. Quando você faz o básico certo, ARIA se torna um complemento elegante, não uma band-aid.

Referências


Artigos relacionados