Communitygithub.com

Patrones de Accesibilidad Frontend

Patrones de accesibilidad para React y Next.js: HTML semántico, atributos ARIA, etiquetado de formularios, navegación por teclado, gestión de foco y soporte para lectores de pantalla. Úsalo al construir o revisar formularios, modales, menús desplegables, tooltips o pestañas, al corregir hallazgos de linting a11y o revisiones de código, o al implementar navegación por teclado y gestión de foco.

¿Qué es Patrones de Accesibilidad Frontend?

Este skill proporciona patrones prácticos de accesibilidad frontend para aplicaciones React y Next.js, abordando los problemas más comunes detectados en revisiones de código: etiquetas de formulario faltantes, uso incorrecto de ARIA, elementos interactivos no semánticos y navegación por teclado rota. Incluye ejemplos detallados de código para conectar etiquetas con campos mediante htmlFor/id, manejar campos obligatorios y mensajes de error con aria-describedby y aria-invalid, y construir formularios accesibles completos. También cubre el uso de HTML semántico en lugar de divs con onClick, atributos ARIA como aria-label, aria-labelledby, aria-describedby y aria-live para contenido dinámico, y patrones de navegación por teclado para menús desplegables personalizados. Además, explica la gestión de foco en modales con restauración del foco anterior, manejo de imágenes e iconos decorativos y significativos, y respeto por la preferencia de movimiento reducido del usuario. Incluye una lista de verificación para componentes interactivos y ejemplos de anti-patrones a evitar.

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

Preguntar en tu IA favorita

Abre un nuevo chat con esta habilidad de agente ya precargada.

Vista previa

Del README del skill

Patrones de Accesibilidad Frontend preview
Patrones de Accesibilidad Frontend preview
Patrones de Accesibilidad Frontend preview

Documentación

¿Qué hace Patrones de Accesibilidad Frontend?

Patrones prácticos de accesibilidad para React y Next.js. Cubre los problemas más comúnmente señalados en revisiones de código: etiquetas de formulario faltantes, uso incorrecto de ARIA, elementos interactivos no semánticos y navegación por teclado rota.

Cuándo Activarlo

  • Al construir o revisar componentes de formulario (<input>, <select>, <textarea>)
  • Al crear elementos interactivos (modales, menús desplegables, tooltips, pestañas)
  • Al usar <div> o <span> con onClick
  • Al agregar atributos aria-* a cualquier elemento
  • Al implementar navegación por teclado o gestión de foco
  • Al recibir comentarios de accesibilidad de herramientas de revisión de código (CodeRabbit, ESLint a11y)
  • Al construir componentes que deben ser compatibles con lectores de pantalla

Accesibilidad en Formularios

La falta de emparejamiento htmlFor / id y los mensajes de error desconectados son los problemas más comunes señalados en las revisiones de código.

Conexión de Etiqueta

// MALO: la etiqueta no tiene conexión con el input — los lectores de pantalla no pueden asociarlos
<label>Email</label>
<input type="email" />

// BUENO: htmlFor coincide con el id del input
<label htmlFor="email">Email</label>
<input id="email" type="email" />

Campos Obligatorios

// MALO: el asterisco solo visual no transmite nada a los lectores de pantalla
<label htmlFor="email">Email *</label>
<input id="email" type="email" />

// BUENO: required activa la validación nativa del navegador; aria-required lo señala a los lectores de pantalla
<label htmlFor="email">
  Email <span aria-hidden="true">*</span>
</label>
<input id="email" type="email" required aria-required="true" />

Mensajes de Error

// MALO: el texto de error existe visualmente pero no está vinculado al input
<input id="email" type="email" />
<span className="error">Dirección de correo inválida</span>

// BUENO: aria-describedby conecta el input con su mensaje de error
// aria-invalid señala el estado inválido a los lectores de pantalla
<input
  id="email"
  type="email"
  aria-describedby="email-error"
  aria-invalid={!!error}
/>
{error && (
  <span id="email-error" role="alert">
    {error}
  </span>
)}

Formulario Accesible 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 = 'El correo es obligatorio';
    if (!password) newErrors.password = 'La contraseña es obligatoria';
    if (Object.keys(newErrors).length) {
      setErrors(newErrors);
      return;
    }
    onSubmit(email, password);
  };

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor="email">
          Correo <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">
          Contraseña <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">Iniciar sesión</button>
    </form>
  );
}

HTML Semántico

Usa el elemento que coincida con la intención. Los lectores de pantalla y los usuarios de teclado dependen de la semántica nativa.

// MALO: div no tiene rol, ni soporte de teclado, ni nombre accesible
<div onClick={handleClick}>Enviar</div>

// BUENO: button es enfocable, se activa con Enter/Espacio, se anuncia como "botón"
<button type="button" onClick={handleClick}>Enviar</button>
// MALO: navegación no semántica
<div onClick={() => navigate('/home')}>Inicio</div>

// BUENO: anchor soporta clic derecho, clic medio y navegación por teclado
<a href="/home">Inicio</a>
// MALO: jerarquía de encabezados saltada (h1 a h4)
<h1>Panel de Control</h1>
<h4>Actividad Reciente</h4>

// BUENO: niveles de encabezado secuenciales
<h1>Panel de Control</h1>
<h2>Actividad Reciente</h2>

Atributos ARIA

Usa ARIA solo cuando la semántica HTML nativa sea insuficiente. Un ARIA incorrecto es peor que no tener ARIA.

aria-label vs aria-labelledby

// aria-label: etiqueta de cadena en línea — úsalo cuando no haya texto de etiqueta visible
<button aria-label="Cerrar modal">
  <XIcon />
</button>

// aria-labelledby: referencia al texto de otro elemento — úsalo cuando exista una etiqueta visible
<section aria-labelledby="section-title">
  <h2 id="section-title">Órdenes Recientes</h2>
  {/* contenido */}
</section>

aria-describedby

// Proporciona una descripción complementaria más allá de la etiqueta
<button
  aria-describedby="delete-warning"
  onClick={handleDelete}
> Eliminar cuenta
</button>
<p id="delete-warning">Esta acción no se puede deshacer.</p>

aria-live para Contenido Dinámico

// Usa aria-live para anunciar contenido que se actualiza sin recargar la página
// polite: espera a que el usuario termine su acción actual antes de anunciar
// assertive: interrumpe inmediatamente — úsalo solo para errores 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 y 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>
  );
}

Navegación por Teclado

Cada elemento interactivo debe ser accesible y operable solo con el teclado.

Menú Desplegable 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>
  );
}

Gestión de Foco

El foco debe moverse de manera lógica cuando cambia el estado de la interfaz de usuario, especialmente en modales y transiciones de ruta.

Restauración de Foco en Modal

Este ejemplo cubre el foco inicial y la restauración. Para un bloqueo de foco completo (ciclo Tab/Shift+Tab dentro del modal), usa una librería como focus-trap-react que maneja casos extremos como contenido dinámico y portales anidados.

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) {
      // Guarda el elemento actualmente enfocado y mueve el foco al modal
      previousFocusRef.current = document.activeElement as HTMLElement;
      modalRef.current?.focus();
    } else {
      // Restaura el foco al elemento que abrió el 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}>Cerrar</button>
    </div>
  );
}

Imágenes e Iconos

// MALO: icono decorativo anunciado como imagen sin etiqueta
<img src="/icon.svg" />

// BUENO: imagen decorativa oculta de los lectores de pantalla
<img src="/decoration.png" alt="" aria-hidden="true" />

// BUENO: imagen significativa con texto alternativo descriptivo
<img src="/chart.png" alt="Los ingresos mensuales aumentaron un 23% de enero a marzo" />

// BUENO: botón de icono con etiqueta accesible
<button aria-label="Eliminar elemento">
  <TrashIcon aria-hidden="true" />
</button>

Movimiento Reducido

Respeta a los usuarios que han solicitado movimiento reducido en la configuración de su sistema operativo.

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-Patrones

// MALO: onClick en elemento no interactivo sin soporte de teclado
<div onClick={handleClick}>Haz clic aquí</div>

// MALO: aria-label en un div que no tiene rol
<div aria-label="Navegación">...</div>

// MALO: placeholder usado como sustituto de etiqueta
<input placeholder="Ingresa tu correo" />

// MALO: tabIndex positivo crea un orden de tabulación impredecible
<button tabIndex={3}>Enviar</button>

// MALO: aria-hidden en un elemento enfocable — los usuarios de teclado quedan atrapados
<button aria-hidden="true">Abrir</button>

// MALO: role="button" en div sin manejador de teclado
<div role="button" onClick={handleClick}>Enviar</div>
// Falta: tabIndex={0}, onKeyDown para Enter/Espacio

Lista de Verificación

Antes de enviar cualquier componente interactivo para revisión:

  • Cada <input>, <select> y <textarea> tiene un <label> conectado mediante htmlFor/id
  • Los mensajes de error están vinculados con aria-describedby y marcados con role="alert"
  • No hay onClick en <div> o <span> sin role, tabIndex y onKeyDown
  • Los botones solo con icono tienen aria-label
  • Las imágenes decorativas usan alt="" y aria-hidden="true"
  • Los modales restauran el foco al cerrarse (para un bloqueo de foco completo con ciclo Tab/Shift+Tab, usa una librería como focus-trap-react)
  • Las actualizaciones de contenido dinámico usan aria-live
  • Se respeta prefers-reduced-motion para las animaciones

Skills Relacionados

  • frontend-patterns — patrones generales de componentes y estado en React
  • design-system — consistencia de tokens de diseño y componentes
  • motion-foundations y motion-patterns: patrones de animación con consideraciones de accesibilidad

Individual skills in this repo

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

Skills relacionados