Web Accessibility: WCAG 2.1 AA Compliance Guide

Build WCAG 2.1 AA compliant web apps with this practical guide. Covers semantic HTML, ARIA, keyboard navigation, color contrast, screen readers, and automated testing tools.

E
ECOSIRE Research and Development Team
|19 de março de 202611 min de leitura2.3k Palavras|

Parte da nossa série Compliance & Regulation

Leia o guia completo

Acessibilidade na Web: Guia de conformidade WCAG 2.1 AA

Acessibilidade não é um recurso que você adiciona após o lançamento – é um atributo fundamental de qualidade, assim como desempenho ou segurança. A conformidade com WCAG 2.1 AA é agora legalmente exigida na UE (Lei Europeia de Acessibilidade, aplicada em junho de 2025), nos EUA (jurisprudência do Título III da ADA) e em muitas outras jurisdições. Além da conformidade, as interfaces acessíveis convertem melhor, têm uma classificação mais elevada nas pesquisas e atendem cerca de 1,3 bilhão de pessoas com deficiência em todo o mundo.

Este guia é um manual prático de implementação e não uma lista de verificação. Você aprenderá os quatro princípios WCAG, as técnicas mais impactantes, como testar sistematicamente e como integrar a acessibilidade em seu fluxo de trabalho de desenvolvimento React/Next.js para que permaneça fixo.

Principais conclusões

  • WCAG 2.1 AA exige todos os quatro princípios POUR: Perceptível, Operável, Compreensível, Robusto
  • Comece com HTML semântico — ele fornece 70% de acessibilidade gratuitamente antes de qualquer ARIA ser adicionado
  • Relação mínima de contraste de cores: 4,5:1 para texto normal, 3:1 para texto grande (18pt/14pt em negrito)
  • Cada elemento interativo deve ser focalizável pelo teclado com um indicador de foco visível
  • Os leitores de tela anunciam com base na árvore de acessibilidade — teste com NVDA (Windows) e VoiceOver (Mac)
  • ARIA é um último recurso - apenas muda a forma como as tecnologias assistivas interpretam o DOM, não o comportamento
  • Automatize com axe-core em seu pipeline de CI; o teste manual detecta o que a automação perde
  • Documente sua declaração de acessibilidade e forneça um mecanismo de feedback para os usuários relatarem problemas

Os Quatro Princípios POUR

As WCAG 2.1 estão organizadas em torno de quatro princípios. Cada critério de sucesso pertence a um deles.

Perceptível: as informações devem ser apresentáveis ​​de forma que os usuários possam percebê-las. Isso abrange alternativas de texto para imagens, legendas para vídeo, contraste de cores suficiente e conteúdo que não depende apenas da cor para transmitir significado.

Operável: todas as funcionalidades devem ser operáveis ​​via teclado, com tempo suficiente para interagir, sem conteúdo que desencadeie convulsões e com estrutura navegável (pular links, títulos de páginas, ordem de foco).

Compreensível: o conteúdo deve ser legível e previsível. O idioma deve ser identificado, as mensagens de erro devem ser descritivas e os formulários devem ter rótulos claros e feedback de validação.

Robusto: o conteúdo deve ser interpretável por tecnologias assistivas atuais e futuras. Isso significa HTML válido, uso adequado de ARIA e mensagens de status anunciadas sem necessidade de foco.


HTML semântico primeiro

HTML semântico é o investimento único em acessibilidade de maior aproveitamento. Os elementos HTML nativos vêm com funções de acessibilidade, estados e comportamento de teclado integrados – sem necessidade de ARIA.

// BAD: Generic divs with no semantics
<div class="button" onclick="submit()">Submit</div>
<div class="nav">
  <div class="link" onclick="navigate('/home')">Home</div>
</div>

// GOOD: Native semantics, free keyboard and screen reader support
<button type="submit" onClick={submit}>Submit</button>
<nav aria-label="Main navigation">
  <a href="/home">Home</a>
</nav>

As regiões de referência ajudam os usuários de leitores de tela a navegar rapidamente, saltando entre as seções:

// Every page should have these landmarks
<header>        {/* banner landmark */}
  <nav aria-label="Main">...</nav>
</header>
<main>           {/* main landmark */}
  <h1>Page Title</h1>
  <article>...</article>
  <aside aria-label="Related content">...</aside>
</main>
<footer>         {/* contentinfo landmark */}
  <nav aria-label="Footer">...</nav>
</footer>

A hierarquia de títulos deve ser lógica e ininterrupta:

// BAD: Skipped heading levels
<h1>Page Title</h1>
<h3>Section</h3>  {/* Skipped h2! */}

// GOOD: Sequential hierarchy
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>

Contraste de cores

WCAG 2.1 AA requer:

  • 4,5:1 relação de contraste para texto normal (abaixo de 18pt/14pt em negrito)
  • Relação de contraste 3:1 para texto grande (18pt+ / 14pt+ negrito)
  • 3:1 para componentes de UI e objetos gráficos (botões, ícones, bordas de entrada)
// Tailwind color contrast examples
// FAIL: gray-400 on white (#9ca3af on #fff = 2.8:1)
<p className="text-gray-400">This fails AA</p>

// PASS: gray-700 on white (#374151 on #fff = 10.7:1)
<p className="text-gray-700">This passes AA</p>

// For dark mode, test both themes separately
<p className="text-gray-700 dark:text-gray-300">
  gray-700 on white (10.7:1) / gray-300 on gray-900 (9.2:1)
</p>

Use o WebAIM Contrast Checker ou a ferramenta de contraste DevTools do navegador durante o desenvolvimento. Adicione isto ao seu Storybook ou sistema de tokens de design para capturar regressões:

// contrast-checker.ts
import { getContrast } from 'polished';

function assertContrast(fg: string, bg: string, level: 'AA' | 'AAA' = 'AA') {
  const ratio = getContrast(fg, bg);
  const required = level === 'AA' ? 4.5 : 7;
  if (ratio < required) {
    throw new Error(
      `Contrast ratio ${ratio.toFixed(2)}:1 fails WCAG ${level} (requires ${required}:1)`
    );
  }
}

Cada elemento interativo – links, botões, campos de formulário, widgets personalizados – deve ser acessível e operável via teclado.

Gerenciamento de foco

// Skip link: first element on every page
// Allows keyboard users to jump past navigation
export function SkipLink() {
  return (
    <a
      href="#main-content"
      className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4
                 focus:z-50 focus:px-4 focus:py-2 focus:bg-blue-600 focus:text-white
                 focus:rounded focus:ring-2 focus:ring-white"
    >
      Skip to main content
    </a>
  );
}

// Main content target
<main id="main-content" tabIndex={-1}>
  {/* tabIndex={-1} allows programmatic focus without appearing in tab order */}

Focus Trapping em modais

Quando uma caixa de diálogo é aberta, o foco deve estar preso dentro dela. Quando fechado, o foco retorna ao gatilho:

// focus-trap.tsx using @radix-ui/react-focus-trap (used internally by shadcn Dialog)
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { useRef } from 'react';

export function AccessibleModal({ trigger, children, title }: Props) {
  const triggerRef = useRef<HTMLButtonElement>(null);

  return (
    <Dialog>
      <DialogTrigger ref={triggerRef} asChild>
        <button>Open</button>
      </DialogTrigger>
      <DialogContent
        // shadcn Dialog handles focus trap and returns focus to trigger on close
        aria-describedby="dialog-description"
      >
        <DialogTitle>{title}</DialogTitle>
        <p id="dialog-description" className="sr-only">
          {/* Screen reader description of dialog purpose */}
        </p>
        {children}
      </DialogContent>
    </Dialog>
  );
}

Indicadores de foco visíveis

WCAG 2.1 SC 2.4.11 (AA em WCAG 2.2) requer um contorno de foco mínimo de 2px. Nunca suprima o foco sem um substituto:

/* globals.css */
/* NEVER do this: */
:focus { outline: none; }

/* DO this: custom, visible focus ring */
:focus-visible {
  outline: 2px solid hsl(var(--ring));
  outline-offset: 2px;
  border-radius: 4px;
}

/* Remove for mouse users (only show for keyboard) */
:focus:not(:focus-visible) {
  outline: none;
}

ARIA: quando e como usá-lo

Os atributos ARIA (Accessible Rich Internet Applications) modificam a forma como as tecnologias assistivas interpretam o DOM. A primeira regra do ARIA: não use se existir um elemento HTML nativo para o seu caso de uso.

Etiquetas ARIA

// Icon-only button — screen reader has nothing to announce without aria-label
<button aria-label="Close dialog">
  <X className="h-4 w-4" aria-hidden="true" />
</button>

// Form field with visible label — use htmlFor, not aria-label
<label htmlFor="email">Email address</label>
<input id="email" type="email" />

// Input with visible description
<input
  id="password"
  type="password"
  aria-describedby="password-requirements"
/>
<p id="password-requirements">Must be at least 12 characters.</p>

Regiões ativas ARIA

Anuncie mudanças dinâmicas de conteúdo sem mudar o foco:

// Status messages (search results count, form submission status)
function SearchResults({ count, loading }: Props) {
  return (
    <>
      {/* aria-live="polite" waits for user to finish current action */}
      <div aria-live="polite" aria-atomic="true" className="sr-only">
        {loading ? 'Loading results...' : `${count} results found`}
      </div>
      {/* Visual result count (not for screen readers — aria-hidden) */}
      <span aria-hidden="true">{count} results</span>
    </>
  );
}

// Error messages (aria-live="assertive" interrupts immediately)
function FormError({ error }: { error: string | null }) {
  return (
    <div
      role="alert"
      aria-live="assertive"
      className={cn('text-red-500 text-sm', !error && 'hidden')}
    >
      {error}
    </div>
  );
}

ARIA para widgets personalizados

Quando você precisar construir um widget personalizado (painel de guias, visualização em árvore, caixa de combinação), siga exatamente os padrões do ARIA Authoring Practices Guide (APG):

// Accessible tabs (ARIA tab pattern)
export function Tabs({ items }: { items: Tab[] }) {
  const [active, setActive] = useState(0);

  return (
    <div>
      <div role="tablist" aria-label="Content tabs">
        {items.map((item, i) => (
          <button
            key={item.id}
            role="tab"
            aria-selected={active === i}
            aria-controls={`panel-${item.id}`}
            id={`tab-${item.id}`}
            tabIndex={active === i ? 0 : -1} // Roving tabindex
            onClick={() => setActive(i)}
            onKeyDown={(e) => {
              if (e.key === 'ArrowRight') setActive((active + 1) % items.length);
              if (e.key === 'ArrowLeft') setActive((active - 1 + items.length) % items.length);
            }}
          >
            {item.label}
          </button>
        ))}
      </div>
      {items.map((item, i) => (
        <div
          key={item.id}
          role="tabpanel"
          id={`panel-${item.id}`}
          aria-labelledby={`tab-${item.id}`}
          hidden={active !== i}
        >
          {item.content}
        </div>
      ))}
    </div>
  );
}

Formulários e tratamento de erros

Os formulários acessíveis estão entre as melhorias de maior impacto para usuários com deficiências cognitivas e motoras.

// Accessible form field with error state
function TextField({
  id,
  label,
  error,
  required,
  hint,
  ...props
}: TextFieldProps) {
  const hintId = hint ? `${id}-hint` : undefined;
  const errorId = error ? `${id}-error` : undefined;
  const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined;

  return (
    <div>
      <label htmlFor={id} className="font-medium">
        {label}
        {required && <span aria-hidden="true" className="text-red-500 ml-1">*</span>}
        {required && <span className="sr-only">(required)</span>}
      </label>

      {hint && (
        <p id={hintId} className="text-sm text-gray-500 mt-1">
          {hint}
        </p>
      )}

      <input
        id={id}
        aria-required={required}
        aria-invalid={!!error}
        aria-describedby={describedBy}
        className={cn('input', error && 'border-red-500')}
        {...props}
      />

      {error && (
        <p id={errorId} className="text-sm text-red-500 mt-1" role="alert">
          {error}
        </p>
      )}
    </div>
  );
}

Imagens e mídia

// Informative image
<img src="/chart.png" alt="Bar chart showing 40% revenue growth in Q4 2025" />

// Decorative image — empty alt hides it from screen readers
<img src="/divider.png" alt="" role="presentation" />

// Complex image — use aria-describedby for long descriptions
<figure>
  <img
    src="/architecture.png"
    alt="System architecture diagram"
    aria-describedby="arch-desc"
  />
  <figcaption id="arch-desc">
    The diagram shows three tiers: frontend Next.js on port 3000,
    NestJS API on port 3001, and PostgreSQL database on port 5433.
    Redis sits between the API and database layers.
  </figcaption>
</figure>

// SVG icons used as decoration
<svg aria-hidden="true" focusable="false">
  <use href="#icon-search" />
</svg>

Teste automatizado com axe-core

pnpm add -D @axe-core/playwright axe-core
// tests/a11y/homepage.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test.describe('Homepage accessibility', () => {
  test('should have no WCAG 2.1 AA violations', async ({ page }) => {
    await page.goto('/');

    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
      .exclude('#third-party-widget') // Exclude known external violations
      .analyze();

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

  test('should be keyboard navigable', async ({ page }) => {
    await page.goto('/');
    // Tab through interactive elements and verify focus is visible
    await page.keyboard.press('Tab');
    const focusedElement = await page.evaluate(
      () => document.activeElement?.getAttribute('href')
    );
    expect(focusedElement).toBe('#main-content'); // Skip link
  });
});

Adicionar ao pipeline de CI:

# .github/workflows/ci.yml
- name: Run accessibility tests
  run: cd apps/web && npx playwright test tests/a11y --reporter=html
- uses: actions/upload-artifact@v4
  with:
    name: a11y-report
    path: apps/web/playwright-report/

Perguntas frequentes

Qual é a diferença entre WCAG 2.1 A, AA e AAA?

O Nível A é o mínimo – falhar no Nível A significa que o conteúdo é inacessível para alguns usuários de maneiras fundamentais. O nível AA é o padrão legal na maioria das jurisdições e atende às necessidades mais amplas dos usuários. O nível AAA é aspiracional – alguns critérios não podem ser atendidos para todos os tipos de conteúdo. Almeje a conformidade com AA como sua linha de base e busque AAA sempre que possível.

Usar uma biblioteca de componentes como shadcn/ui torna meu aplicativo acessível?

shadcn/ui é construído em primitivas de UI Radix, que são acessíveis por design – elas incluem funções ARIA corretas, navegação por teclado e gerenciamento de foco. No entanto, você ainda precisa adicionar rótulos significativos, lidar com estados de erro de forma acessível, garantir contraste de cores suficiente com seu tema personalizado e testar com tecnologias assistivas reais. As bibliotecas de componentes reduzem a carga, mas não eliminam a necessidade de testes de acessibilidade.

Como faço para testar com um leitor de tela?

No Windows, use o NVDA (gratuito) com Firefox ou Chrome. No macOS, use o VoiceOver (integrado, Cmd+F5) com Safari. No celular, use TalkBack (Android) ou VoiceOver (iOS). Teste as principais jornadas do usuário: preenchimento de formulários, interações modais, navegação por pontos de referência e leitura de conteúdo dinâmico. O teste do leitor de tela detecta anúncios, ordem de leitura e comportamento de foco que as ferramentas automatizadas não percebem.

Qual é o padrão de tabindex itinerante?

Roving tabindex é o padrão de teclado para widgets compostos (listas de guias, barras de ferramentas, grupos de rádio, visualizações em árvore). Apenas um item do grupo possui tabIndex={0} por vez — o item ativo. Todos os outros recebem tabIndex={-1}. As teclas de seta movem o foco dentro do grupo e atualizam qual item tem tabIndex 0. Isso evita que o usuário navegue por todos os itens do grupo - eles entram no grupo com Tab, navegam com as teclas de seta e saem com Tab.

Como lidar com a acessibilidade de conteúdo dinâmico carregado via AJAX?

Use regiões aria-live para atualizações de status (contagem de resultados de pesquisa, salvamento de confirmações). Para substituições de seções de páginas inteiras, mova o foco para o título ou contêiner do novo conteúdo após o carregamento. Para carregar estados, use aria-busy="true" na região que está sendo atualizada e uma região aria-live="polite" para anunciar a conclusão. Sempre teste com um leitor de tela para verificar se os anúncios são claros e oportunos.


Próximas etapas

A acessibilidade na Web é uma prática contínua, não uma auditoria única. Comece corrigindo seu HTML semântico e contraste de cores, depois coloque a navegação no teclado e ARIA para widgets complexos e automatize a validação WCAG em seu pipeline de CI para capturar regressões.

ECOSIRE constrói aplicativos da web compatíveis com WCAG 2.1 AA como um padrão básico em cada projeto. Se você precisar de uma auditoria de acessibilidade ou quiser criar conformidade desde o início, explore nossos serviços de engenharia de front-end.

Compartilhar:
E

Escrito por

ECOSIRE Research and Development Team

Construindo produtos digitais de nível empresarial na ECOSIRE. Compartilhando insights sobre integrações Odoo, automação de e-commerce e soluções de negócios com IA.

Converse no WhatsApp