Voltar para Artigos
Front-end21 min de leitura

Acessibilidade na Prática (A11y): Desenvolvendo para Todos

Muito além do atributo 'alt'. Aprenda ARIA, focus trap, regiões live, testes com axe-core e as diretrizes WCAG para construir interfaces React verdadeiramente inclusivas.

12 de agosto de 2026

Acessibilidade na Web (A11y — A, 11 letras, Y) não é um checklist de última hora. É uma disciplina de engenharia que, quando ignorada, exclui cerca de 1,3 bilhão de pessoas no mundo que vivem com alguma deficiência. No Brasil, segundo o IBGE, isso representa mais de 18 milhões de pessoas com deficiência visual e 10 milhões com deficiência auditiva.

O problema é que a maior parte dos tutoriais de acessibilidade para React apresenta apenas o básico — alt em imagens e usar <button> ao invés de <div>. Isso é necessário, mas absolutamente insuficiente para uma aplicação de produção. Neste artigo vamos a fundo.

A Base: HTML Semântico não é opcional

Antes de qualquer atributo ARIA, a primeira regra de ouro é: use o elemento HTML correto. O HTML nativo já carrega semântica, comportamento de teclado e anúncio para leitores de tela de graça. Elementos como <button>, <a>, <input>, <nav>, <main>, <article> e <section> comunicam estrutura e intenção sem uma linha de JavaScript.

Semantica.tsx
// ❌ ERRADO: div com onClick não tem semântica nenhuma.
// Não ganha foco com Tab, não ativa com Enter/Space,
// leitores de tela ignoram ou leem como "clicável" sem contexto.
<div onClick={handleSubmit} className="btn-primary">
  Enviar Formulário
</div>

// ✅ CORRETO: button carrega foco nativo, ativa com Enter e Space,
// leitores de tela anunciam como "Enviar Formulário, botão".
<button type="submit" onClick={handleSubmit} className="btn-primary">
  Enviar Formulário
</button>

// ✅ CORRETO: Link de navegação. Ativa com Enter, anunciado como "link".
// Use <a> para navegar, <button> para ação.
<a href="/sobre">Sobre nós</a>

A primeira regra do ARIA (especificação W3C) é: não use ARIA se um elemento HTML nativo resolve. ARIA não adiciona comportamento — só muda o que os leitores de tela anunciam. Um <div role="button"> sem tabIndex e sem handler de teclado ainda não funciona com Tab ou Enter.

ARIA na Prática: Quando e Como Usar

ARIA (Accessible Rich Internet Applications) é necessário quando construímos componentes sem equivalente HTML — tabs, accordions, carrosséis, comboboxes customizados. Existem três categorias de atributos ARIA que você precisa dominar:

  • `role` — define o que é o elemento: role="dialog", role="tablist", role="combobox"
  • `aria- de estado** — descrevem o estado atual: aria-expanded, aria-checked, aria-disabled, aria-selected`
  • `aria- de propriedade** — descrevem relações: aria-labelledby, aria-describedby, aria-controls`

Veja um componente de Accordion acessível completo, com todos os atributos necessários:

Accordion.tsx
import { useState } from 'react';

interface AccordionItem {
  id: string;
  title: string;
  content: string;
}

interface AccordionProps {
  items: Array<AccordionItem>;
}

export function Accordion({ items }: AccordionProps) {
  const [openId, setOpenId] = useState<string | null>(null);

  return (
    <div>
      {items.map((item) => {
        const isOpen = openId === item.id;
        const headingId = `accordion-heading-${item.id}`;
        const panelId = `accordion-panel-${item.id}`;

        return (
          <div key={item.id}>
            {/* h3 garante hierarquia de headings na página */}
            <h3>
              <button
                id={headingId}
                aria-expanded={isOpen}
                aria-controls={panelId}
                onClick={() => setOpenId(isOpen ? null : item.id)}
                className="accordion-trigger"
              >
                {item.title}
                {/* Ícone puramente decorativo — aria-hidden para o leitor de tela ignorar */}
                <span aria-hidden="true">{isOpen ? '▲' : '▼'}</span>
              </button>
            </h3>

            <div
              id={panelId}
              role="region"
              aria-labelledby={headingId}
              hidden={!isOpen}
            >
              <p>{item.content}</p>
            </div>
          </div>
        );
      })}
    </div>
  );
}

// O que o leitor de tela anuncia ao focar no botão fechado:
// "Pergunta frequente 1, botão, recolhido"
// Ao abrir:
// "Pergunta frequente 1, botão, expandido"

Focus Trap: Aprisionando o Foco em Modais

Este é um dos erros mais comuns em SPAs. Quando um Modal abre, o foco deve ficar preso dentro dele. Se o usuário de teclado pressionar Tab repetidamente, o foco não pode vazar para o conteúdo por baixo do overlay. Isso não é só uma boa prática — é um requisito do WCAG 2.1 (critério 2.1.2 — Sem Armadilha de Teclado, paradoxalmente).

Aqui está a implementação de um hook useFocusTrap do zero, sem dependências externas:

useFocusTrap.ts
import { useEffect, useRef } from 'react';

// Seletores de todos os elementos focáveis por padrão
const FOCUSABLE_SELECTORS = [
  'a[href]',
  'area[href]',
  'input:not([disabled]):not([type="hidden"])',
  'select:not([disabled])',
  'textarea:not([disabled])',
  'button:not([disabled])',
  'iframe',
  'object',
  'embed',
  '[contenteditable]',
  '[tabindex]:not([tabindex="-1"])',
].join(', ');

export function useFocusTrap(isActive: boolean) {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!isActive || !containerRef.current) return;

    const container = containerRef.current;

    // Guarda o elemento que estava focado antes do modal abrir
    const previouslyFocused = document.activeElement as HTMLElement;

    // Foca o primeiro elemento focável do modal
    const focusableElements = Array.from(
      container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)
    );
    focusableElements[0]?.focus();

    function handleKeyDown(event: KeyboardEvent) {
      if (event.key !== 'Tab') return;

      const currentFocusable = Array.from(
        container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)
      );

      const first = currentFocusable[0];
      const last = currentFocusable[currentFocusable.length - 1];

      if (event.shiftKey) {
        // Shift+Tab: voltando — se está no primeiro, vai para o último
        if (document.activeElement === first) {
          event.preventDefault();
          last?.focus();
        }
      } else {
        // Tab: avançando — se está no último, vai para o primeiro
        if (document.activeElement === last) {
          event.preventDefault();
          first?.focus();
        }
      }
    }

    document.addEventListener('keydown', handleKeyDown);

    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      // Ao fechar o modal, devolve o foco para onde estava antes
      previouslyFocused?.focus();
    };
  }, [isActive]);

  return containerRef;
}
Modal.tsx
import { useEffect } from 'react';
import { useFocusTrap } from './useFocusTrap';

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}

export function Modal({ isOpen, onClose, title, children }: ModalProps) {
  const modalRef = useFocusTrap(isOpen);

  // Fecha o modal com Escape
  useEffect(() => {
    function handleKeyDown(event: KeyboardEvent) {
      if (event.key === 'Escape') onClose();
    }
    if (isOpen) {
      document.addEventListener('keydown', handleKeyDown);
      // Impede o scroll do body enquanto o modal está aberto
      document.body.style.overflow = 'hidden';
    }
    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      document.body.style.overflow = '';
    };
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return (
    // Overlay — aria-hidden=false já que é o conteúdo ativo
    <div
      role="presentation"
      className="modal-overlay"
      onClick={onClose} // Clique fora fecha o modal
    >
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        className="modal-content"
        onClick={(e) => e.stopPropagation()} // Evita fechar ao clicar dentro
      >
        <h2 id="modal-title">{title}</h2>

        {children}

        <button
          type="button"
          onClick={onClose}
          aria-label="Fechar modal"
          className="modal-close"
        >
          ✕
        </button>
      </div>
    </div>
  );
}

Se preferir não implementar o focus trap manualmente, a biblioteca `focus-trap-react` resolve isso de forma robusta e battle-tested. Para componentes como dropdowns e dialogs, o Radix UI já vem com tudo isso configurado: focus trap, Escape para fechar, foco de retorno e atributos ARIA corretos.

Regiões Live: Anúncios Dinâmicos para Leitores de Tela

Um problema clássico em SPAs: o usuário clica em "Salvar" e uma mensagem de sucesso aparece visualmente, mas o leitor de tela não anuncia nada porque nenhuma navegação ocorreu. Para resolver isso, usamos aria-live.

Uma live region é um elemento que, quando seu conteúdo muda, é lido automaticamente pelo leitor de tela independente de onde o foco está. Existem dois valores principais:

  • `aria-live="polite"` — anuncia quando o usuário para de interagir. Ideal para mensagens de sucesso, resultados de busca, contadores.
  • `aria-live="assertive"` — interrompe o que o leitor de tela está fazendo e anuncia imediatamente. Use apenas para erros críticos.
Toast.tsx
import { useEffect, useState } from 'react';

type ToastType = 'success' | 'error' | 'info';

interface ToastProps {
  message: string | null;
  type?: ToastType;
}

export function Toast({ message, type = 'info' }: ToastProps) {
  // A live region DEVE existir no DOM antes da mensagem ser inserida.
  // Por isso renderizamos sempre, mas com conteúdo vazio.
  return (
    <div
      role={type === 'error' ? 'alert' : 'status'}
      aria-live={type === 'error' ? 'assertive' : 'polite'}
      aria-atomic="true" // Lê o conteúdo completo, não apenas a diferença
      className={`toast toast--${type} ${message ? 'toast--visible' : ''}`}
    >
      {/* O leitor de tela anuncia quando message muda de null para uma string */}
      {message}
    </div>
  );
}

// Uso:
// <Toast message={null} /> — no DOM mas vazio, leitor de tela não anuncia nada
// <Toast message="Perfil salvo com sucesso!" type="success" />
// → Leitor de tela anuncia: "Perfil salvo com sucesso!"
// <Toast message="Senha incorreta. Tente novamente." type="error" />
// → Leitor de tela interrompe e anuncia imediatamente.

Contraste de Cores e WCAG 2.1

O Web Content Accessibility Guidelines (WCAG) define critérios mensuráveis de acessibilidade. O nível AA é o mínimo exigido na maioria das legislações (incluindo a LBI — Lei Brasileira de Inclusão). Dois critérios de contraste que todo desenvolvedor frontend precisa saber:

  • Critério 1.4.3 (Contraste — Nível AA): Texto normal deve ter razão de contraste mínima de 4.5:1 contra o fundo.
  • Critério 1.4.11 (Contraste de não-texto — Nível AA): Bordas de inputs, ícones e componentes de UI devem ter razão de 3:1.

Uma forma fácil de verificar isso durante o desenvolvimento é usar a propriedade CSS color-contrast() (CSS Level 5) ou ferramentas como o WebAIM Contrast Checker. Mas a abordagem mais eficaz é integrar a verificação direto no seu sistema de design:

tokens.css
:root {
  /* Paleta base */
  --color-brand-500: #2563eb; /* Azul principal */

  /* ✅ Texto branco sobre brand-500: ratio 4.64:1 (passa AA) */
  --color-text-on-brand: #ffffff;

  /* ❌ Texto cinza claro sobre branco: ratio 2.3:1 (REPROVA) */
  /* --color-text-muted: #aaaaaa; */

  /* ✅ Substituto acessível: ratio 4.6:1 (passa AA) */
  --color-text-muted: #767676;

  /* Nunca confie apenas na cor para transmitir informação. */
  /* Combine com ícone, texto, padrão ou outra diferença visual. */
  /* (Critério WCAG 1.4.1 — Uso da Cor) */
}

Testando Acessibilidade com axe-core

Testes manuais com leitores de tela (NVDA no Windows, VoiceOver no macOS/iOS, TalkBack no Android) são indispensáveis, mas lentos. O axe-core é um motor de análise estática de acessibilidade que detecta automaticamente ~57% dos problemas conhecidos diretamente nos seus testes de unidade e integração.

bash
npm install --save-dev @axe-core/react jest-axe
Modal.test.tsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { Modal } from './Modal';

// Adiciona o matcher do jest-axe
expect.extend(toHaveNoViolations);

describe('Modal', () => {
  it('não deve ter violações de acessibilidade', async () => {
    const { container } = render(
      <Modal isOpen={true} onClose={() => {}} title="Confirmar exclusão">
        <p>Tem certeza que deseja excluir este item?</p>
        <button type="button">Cancelar</button>
        <button type="button">Excluir</button>
      </Modal>
    );

    // axe analisa o HTML renderizado e retorna violações encontradas
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('deve ter aria-modal e aria-labelledby corretamente', () => {
    const { getByRole } = render(
      <Modal isOpen={true} onClose={() => {}} title="Confirmar exclusão">
        <p>Conteúdo do modal</p>
      </Modal>
    );

    const dialog = getByRole('dialog');
    expect(dialog).toHaveAttribute('aria-modal', 'true');
    expect(dialog).toHaveAttribute('aria-labelledby', 'modal-title');
  });
});

Além do jest-axe, adicione a extensão axe DevTools no Chrome ou Firefox. Ela audita a página atual com um clique e lista violações com explicações e links para correção. É o Lighthouse mas especializado em acessibilidade.

Para componentes de lista tipo <select> customizado, menu de navegação ou tabela de dados interativa, o padrão recomendado pelo WAI-ARIA é o Roving tabindex (ou "roving focus"). A ideia é simples: apenas um elemento dentro do grupo tem tabIndex={0} (focável pelo Tab). Os outros têm tabIndex={-1} (focáveis por JavaScript, mas não pelo Tab). As setas do teclado movem o foco entre os itens.

NavigationMenu.tsx
import { useRef, useState } from 'react';

interface MenuItem {
  id: string;
  label: string;
  href: string;
}

export function NavigationMenu({ items }: { items: Array<MenuItem> }) {
  const [focusedIndex, setFocusedIndex] = useState(0);
  const itemRefs = useRef<Array<(HTMLAnchorElement | null)>>([]);

  function handleKeyDown(event: React.KeyboardEvent, index: number) {
    let nextIndex = index;

    switch (event.key) {
      case 'ArrowRight':
      case 'ArrowDown':
        event.preventDefault();
        nextIndex = (index + 1) % items.length;
        break;
      case 'ArrowLeft':
      case 'ArrowUp':
        event.preventDefault();
        nextIndex = (index - 1 + items.length) % items.length;
        break;
      case 'Home':
        event.preventDefault();
        nextIndex = 0;
        break;
      case 'End':
        event.preventDefault();
        nextIndex = items.length - 1;
        break;
      default:
        return;
    }

    setFocusedIndex(nextIndex);
    itemRefs.current[nextIndex]?.focus();
  }

  return (
    <nav aria-label="Menu principal">
      <ul role="menubar">
        {items.map((item, index) => (
          <li key={item.id} role="none">
            <a
              ref={(el) => { itemRefs.current[index] = el; }}
              href={item.href}
              role="menuitem"
              // Roving tabindex: só o item focado é focável via Tab
              tabIndex={index === focusedIndex ? 0 : -1}
              onKeyDown={(e) => handleKeyDown(e, index)}
              onFocus={() => setFocusedIndex(index)}
            >
              {item.label}
            </a>
          </li>
        ))}
      </ul>
    </nav>
  );
}

Reduzindo Movimento para Quem Precisa

Animações e transições podem causar desconforto ou crises para pessoas com distúrbios vestibulares (epilepsia, vertigem). O WCAG 2.1 Critério 2.3.3 (Nível AAA) recomenda respeitar a preferência do sistema operacional. No CSS e no JavaScript, isso é trivial:

animations.css
/* Animação padrão */
.card {
  transition: transform 0.3s ease, box-shadow 0.3s ease;
}

.card:hover {
  transform: translateY(-4px);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}

/* Desativa animações se o usuário preferir movimento reduzido */
@media (prefers-reduced-motion: reduce) {
  .card {
    transition: none;
  }

  .card:hover {
    transform: none;
  }

  /* Para animações longas (como loading spinners), reduza ao mínimo */
  .spinner {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
  }
}
useReducedMotion.ts
import { useState, useEffect } from 'react';

// Hook que detecta a preferência do usuário em tempo real
export function useReducedMotion(): boolean {
  const [prefersReduced, setPrefersReduced] = useState(() =>
    window.matchMedia('(prefers-reduced-motion: reduce)').matches
  );

  useEffect(() => {
    const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
    const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
    mql.addEventListener('change', handler);
    return () => mql.removeEventListener('change', handler);
  }, []);

  return prefersReduced;
}

// Uso com Framer Motion:
// const prefersReduced = useReducedMotion();
// <motion.div animate={{ y: prefersReduced ? 0 : -10 }} />

Conclusão

Acessibilidade real vai muito além de alt e <button>. Envolve entender o contrato entre HTML semântico, ARIA, foco de teclado e leitores de tela. A boa notícia é que cada uma dessas práticas não apenas inclui mais usuários — ela melhora a experiência de todos. Um componente com foco bem gerenciado é mais robusto, um código com contraste adequado é mais legível, e uma live region bem posicionada deixa a UX mais clara para qualquer pessoa.

Implemente progressivamente: comece auditando sua aplicação com o axe DevTools no Chrome. Corrija as violações críticas primeiro, depois refine componente por componente. Com prática, escrever código acessível vira instinto, não esforço.