Communitygithub.com

Padrões de Acessibilidade para Frontend

Padrões de acessibilidade para React e Next.js — HTML semântico, atributos ARIA, rotulagem de formulários, navegação por teclado, gerenciamento de foco e suporte a leitores de tela. Use ao construir ou revisar formulários, modais, dropdowns, tooltips ou abas, corrigir problemas de lint ou revisão de código de a11y, ou ao implementar navegação por teclado e gerenciamento de foco.

O que é Padrões de Acessibilidade para Frontend?

Esta skill fornece padrões práticos de acessibilidade para React e Next.js, abordando os problemas mais comuns encontrados em revisões de código: rótulos de formulário ausentes, uso incorreto de ARIA, elementos interativos não semânticos e navegação por teclado quebrada. É especialmente útil ao construir ou revisar componentes de formulário (input, select, textarea), criar elementos interativos como modais, dropdowns e tooltips, ou ao receber feedback de ferramentas de revisão de acessibilidade como CodeRabbit e ESLint a11y. A skill é compatível com qualquer ambiente React/Next.js e inclui exemplos detalhados de código para conectar rótulos a inputs, associar mensagens de erro com aria-describedby, implementar navegação completa por teclado em dropdowns personalizados, gerenciar foco em modais (com salvamento e restauração), e lidar com imagens decorativas e ícones. Também aborda o respeito à preferência de movimento reduzido (prefers-reduced-motion) e lista anti-padrões a evitar, oferecendo uma lista de verificação prática para garantir que componentes interativos estejam acessíveis antes de serem submetidos para revisão.

Funciona com~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/junksidetm/darkside-toolkit/tree/HEAD/skills/frontend-a11y

Perguntar na sua IA favorita

Abre um novo chat com esta habilidade de agente já pré-carregada.

Prévia

Do README da skill

Padrões de Acessibilidade para Frontend preview
Padrões de Acessibilidade para Frontend preview
Padrões de Acessibilidade para Frontend preview

Documentação

O que Padrões de Acessibilidade para Frontend faz?

Padrões práticos de acessibilidade para React e Next.js. Aborda os problemas mais comuns sinalizados em revisão de código: rótulos de formulário ausentes, uso incorreto de ARIA, elementos interativos não semânticos e navegação por teclado quebrada.

Quando Ativar

  • Construindo ou revisando componentes de formulário (<input>, <select>, <textarea>)
  • Criando elementos interativos (modais, dropdowns, tooltips, abas)
  • Usando <div> ou <span> com onClick
  • Adicionando atributos aria-* a qualquer elemento
  • Implementando navegação por teclado ou gerenciamento de foco
  • Recebendo feedback de acessibilidade de ferramentas de revisão de código (CodeRabbit, ESLint a11y)
  • Construindo componentes que devem suportar leitores de tela

Acessibilidade de Formulários

A falta do par htmlFor / id e mensagens de erro desconectadas são os problemas mais comuns sinalizados em revisão de código.

Conexão do Rótulo

// RUIM: o rótulo não tem conexão com o input — leitores de tela não conseguem associá-los
<label>Email</label>
<input type="email" />

// BOM: htmlFor corresponde ao id do input
<label htmlFor="email">Email</label>
<input id="email" type="email" />

Campos Obrigatórios

// RUIM: asterisco apenas visual não transmite nada para leitores de tela
<label htmlFor="email">Email *</label>
<input id="email" type="email" />

// BOM: required ativa validação nativa do navegador; aria-required sinaliza para leitores de tela
<label htmlFor="email">
  Email <span aria-hidden="true">*</span>
</label>
<input id="email" type="email" required aria-required="true" />

Mensagens de Erro

// RUIM: texto de erro existe visualmente, mas não está vinculado ao input
<input id="email" type="email" />
<span className="error">Endereço de e-mail inválido</span>

// BOM: aria-describedby conecta o input à sua mensagem de erro
// aria-invalid sinaliza o estado inválido para leitores de tela
<input
  id="email"
  type="email"
  aria-describedby="email-error"
  aria-invalid={!!error}
/>
{error && (
  <span id="email-error" role="alert">
    {error}
  </span>
)}

Formulário Acessível Completo

interface LoginFormProps {
  onSubmit: (email: string, password: string) => void;
}

export function LoginForm({ onSubmit }: LoginFormProps) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState<{ email?: string; password?: string }>({});

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    const newErrors: typeof errors = {};
    if (!email) newErrors.email = 'Email é obrigatório';
    if (!password) newErrors.password = 'Senha é obrigatória';
    if (Object.keys(newErrors).length) {
      setErrors(newErrors);
      return;
    }
    onSubmit(email, password);
  };

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor="email">
          Email <span aria-hidden="true">*</span>
        </label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={e => setEmail(e.target.value)}
          aria-required="true"
          aria-describedby={errors.email ? 'email-error' : undefined}
          aria-invalid={!!errors.email}
          autoComplete="email"
        />
        {errors.email && (
          <span id="email-error" role="alert">
            {errors.email}
          </span>
        )}
      </div>

      <div>
        <label htmlFor="password">
          Senha <span aria-hidden="true">*</span>
        </label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={e => setPassword(e.target.value)}
          aria-required="true"
          aria-describedby={errors.password ? 'password-error' : undefined}
          aria-invalid={!!errors.password}
          autoComplete="current-password"
        />
        {errors.password && (
          <span id="password-error" role="alert">
            {errors.password}
          </span>
        )}
      </div>

      <button type="submit">Entrar</button>
    </form>
  );
}

HTML Semântico

Use o elemento que corresponde à intenção. Leitores de tela e usuários de teclado dependem de semântica nativa.

// RUIM: div não tem papel, nem suporte a teclado, nem nome acessível
<div onClick={handleClick}>Enviar</div>

// BOM: button é focalizável, ativa com Enter/Espaço, anuncia como "botão"
<button type="button" onClick={handleClick}>Enviar</button>
// RUIM: navegação não semântica
<div onClick={() => navigate('/home')}>Início</div>

// BOM: anchor suporta clique direito, clique do meio e navegação por teclado
<a href="/home">Início</a>
// RUIM: hierarquia de cabeçalhos pulada (h1 para h4)
<h1>Painel</h1>
<h4>Atividade Recente</h4>

// BOM: níveis de cabeçalho sequenciais
<h1>Painel</h1>
<h2>Atividade Recente</h2>

Atributos ARIA

Use ARIA apenas quando a semântica HTML nativa for insuficiente. ARIA errado é pior que nenhum ARIA.

aria-label vs aria-labelledby

// aria-label: rótulo de string inline — use quando não houver texto de rótulo visível
<button aria-label="Fechar modal">
  <XIcon />
</button>

// aria-labelledby: referencia o texto de outro elemento — use quando houver um rótulo visível
<section aria-labelledby="section-title">
  <h2 id="section-title">Pedidos Recentes</h2>
  {/* conteúdo */}
</section>

aria-describedby

// Fornece descrição suplementar além do rótulo
<button
  aria-describedby="delete-warning"
  onClick={handleDelete}
> Excluir conta
</button>
<p id="delete-warning">Esta ação não pode ser desfeita.</p>

aria-live para Conteúdo Dinâmico

// Use aria-live para anunciar conteúdo que é atualizado sem recarregar a página
// polite: espera o usuário terminar a ação atual antes de anunciar
// assertive: interrompe imediatamente — use apenas para erros urgentes

export function StatusMessage({ message, isError }: { message: string; isError?: boolean }) {
  return (
    <div role="status" aria-live={isError ? 'assertive' : 'polite'} aria-atomic="true">
      {message}
    </div>
  );
}

aria-expanded e aria-controls

export function Accordion({ title, children }: { title: string; children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
  const contentId = useId();

  return (
    <div>
      <button aria-expanded={isOpen} aria-controls={contentId} onClick={() => setIsOpen(prev => !prev)}>
        {title}
      </button>
      <div id={contentId} hidden={!isOpen}>
        {children}
      </div>
    </div>
  );
}

Navegação por Teclado

Cada elemento interativo deve ser alcançável e operável apenas pelo teclado.

Dropdown Personalizado

export function Dropdown({ options, onSelect }: { options: string[]; onSelect: (value: string) => void }) {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(0);
  const listId = useId();

  if (!options.length) return null;

  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setActiveIndex(i => Math.min(i + 1, options.length - 1));
        break;
      case 'ArrowUp':
        e.preventDefault();
        setActiveIndex(i => Math.max(i - 1, 0));
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        if (isOpen) onSelect(options[activeIndex]);
        setIsOpen(prev => !prev);
        break;
      case 'Escape':
        setIsOpen(false);
        break;
    }
  };

  return (
    <div
      role="combobox"
      aria-expanded={isOpen}
      aria-haspopup="listbox"
      aria-controls={listId}
      tabIndex={0}
      onKeyDown={handleKeyDown}
      onClick={() => setIsOpen(prev => !prev)}
    >
      <span>{options[activeIndex]}</span>
      {isOpen && (
        <ul id={listId} role="listbox">
          {options.map((option, index) => (
            <li
              key={option}
              role="option"
              aria-selected={index === activeIndex}
              onClick={() => {
                onSelect(option);
                setIsOpen(false);
              }}
            >
              {option}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Gerenciamento de Foco

O foco deve se mover logicamente quando o estado da interface muda — especialmente para modais e transições de rota.

Restauração de Foco em Modal

Este exemplo cobre o foco inicial e a restauração. Para um foco total (Tab/Shift+Tab ciclando dentro do modal), use uma biblioteca como focus-trap-react que lida com casos de borda como conteúdo dinâmico e portais aninhados.

export function Modal({ isOpen, onClose, title, children }: { isOpen: boolean; onClose: () => void; title: string; children: React.ReactNode }) {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (isOpen) {
      // Salva o elemento atualmente focado e move o foco para o modal
      previousFocusRef.current = document.activeElement as HTMLElement;
      modalRef.current?.focus();
    } else {
      // Restaura o foco para o elemento que abriu o modal
      previousFocusRef.current?.focus();
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div ref={modalRef} role="dialog" aria-modal="true" aria-labelledby="modal-title" tabIndex={-1} onKeyDown={e => e.key === 'Escape' && onClose()}>
      <h2 id="modal-title">{title}</h2>
      {children}
      <button onClick={onClose}>Fechar</button>
    </div>
  );
}

Imagens e Ícones

// RUIM: ícone decorativo anunciado como imagem não rotulada
<img src="/icon.svg" />

// BOM: imagem decorativa oculta de leitores de tela
<img src="/decoration.png" alt="" aria-hidden="true" />

// BOM: imagem significativa com texto alternativo descritivo
<img src="/chart.png" alt="A receita mensal aumentou 23% de janeiro a março" />

// BOM: botão de ícone com rótulo acessível
<button aria-label="Excluir item">
  <TrashIcon aria-hidden="true" />
</button>

Movimento Reduzido

Respeite usuários que solicitaram movimento reduzido nas configurações do sistema operacional.

export function useReducedMotion(): boolean {
  const [prefersReduced, setPrefersReduced] = useState(false);

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

  return prefersReduced;
}

// Uso
export function AnimatedCard({ children }: { children: React.ReactNode }) {
  const reduceMotion = useReducedMotion();

  return (
    <div
      style={{
        transition: reduceMotion ? 'none' : 'transform 300ms ease'
      }}
    >
      {children}
    </div>
  );
}

Anti-Padrões

// RUIM: onClick em elemento não interativo sem suporte a teclado
<div onClick={handleClick}>Clique aqui</div>

// RUIM: aria-label em uma div que não tem papel
<div aria-label="Navegação">...</div>

// RUIM: placeholder usado como substituto de rótulo
<input placeholder="Digite seu email" />

// RUIM: tabIndex positivo cria ordem de tabulação imprevisível
<button tabIndex={3}>Enviar</button>

// RUIM: aria-hidden em elemento focalizável — usuários de teclado ficam presos
<button aria-hidden="true">Abrir</button>

// RUIM: role="button" em div sem manipulador de teclado
<div role="button" onClick={handleClick}>Enviar</div>
// Faltando: tabIndex={0}, onKeyDown para Enter/Espaço

Lista de Verificação

Antes de enviar qualquer componente interativo para revisão:

  • Cada <input>, <select> e <textarea> tem um <label> conectado via htmlFor/id
  • Mensagens de erro estão vinculadas com aria-describedby e marcadas com role="alert"
  • Nenhum onClick em <div> ou <span> sem role, tabIndex e onKeyDown
  • Botões apenas com ícone têm aria-label
  • Imagens decorativas usam alt="" e aria-hidden="true"
  • Modais restauram o foco ao fechar (para foco total com Tab/Shift+Tab, use uma biblioteca como focus-trap-react)
  • Atualizações de conteúdo dinâmico usam aria-live
  • prefers-reduced-motion é respeitado para animações

Habilidades Relacionadas

  • frontend-patterns — padrões gerais de componentes e estado React
  • design-system — consistência de tokens de design e componentes
  • motion-foundations e motion-patterns: padrões de animação com considerações de acessibilidade

Individual skills in this repo

This repo contains 2 individual skills — each has its own dedicated page.

Habilidades Relacionadas