Saltar al contenido

Construyendo componentes de React accesibles desde cero

Cómo construir componentes de React accesibles: atributos ARIA, navegación por teclado, gestión del foco y pruebas con lectores de pantalla.

4 min de lectura
Árbol de componentes de React anotado con roles ARIA y puntos de interacción con el teclado

La accesibilidad no es una idea de último momento ni una casilla de cumplimiento. Es un requisito fundamental de ingeniería. Los componentes inaccesibles excluyen a los usuarios que dependen de lectores de pantalla, navegación por teclado o dispositivos de entrada alternativos. Construir componentes de React accesibles desde el principio es más fácil que adaptarlos después.

La mayoría de los errores de accesibilidad caen en unas pocas categorías: etiquetas faltantes, navegación por teclado rota y uso incorrecto de ARIA. Corrige estos patrones y cubrirás la mayoría de los problemas.

HTML semántico primero

La corrección de accesibilidad más simple es usar el elemento HTML correcto. Los elementos nativos vienen con manejo de teclado integrado, gestión del foco y anuncios para lectores de pantalla.

tsxtsx
// ❌ Div pretending to be a button — no keyboard support, no role
function BadButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
  return (
    <div className="btn" onClick={onClick}>
      {children}
    </div>
  );
}
// Screen reader sees: generic container
// Keyboard user: cannot focus or activate with Enter/Space
tsxtsx
// ✅ Actual button element — keyboard, focus, and role built-in
function GoodButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
  return (
    <button type="button" className="btn" onClick={onClick}>
      {children}
    </button>
  );
}
// Screen reader sees: "Submit, button"
// Keyboard user: Tab to focus, Enter or Space to activate

Elementos semánticos a preferir: <button> en lugar de <div onClick>, <a href> en lugar de <span onClick>, <nav> en lugar de <div class="nav">, <main> en lugar de <div id="content">, <dialog> en lugar de <div class="modal">.

Etiquetado de elementos interactivos

Cada elemento interactivo necesita un nombre accesible. Sin él, los lectores de pantalla anuncian el rol del elemento pero no su propósito.

tsxtsx
// ❌ Icon button with no accessible name
function IconButton({ icon, onClick }: { icon: string; onClick: () => void }) {
  return (
    <button onClick={onClick}>
      <svg aria-hidden="true">{/* icon SVG */}</svg>
    </button>
  );
}
// Screen reader announces: "button" — which button?
tsxtsx
// ✅ Icon button with aria-label
function IconButton({
  icon,
  label,
  onClick,
}: {
  icon: string;
  label: string;
  onClick: () => void;
}) {
  return (
    <button onClick={onClick} aria-label={label}>
      <svg aria-hidden="true">{/* icon SVG */}</svg>
    </button>
  );
}
 
// Usage:
<IconButton icon="trash" label="Delete item" onClick={handleDelete} />
// Screen reader announces: "Delete item, button"

Para los campos de formulario, usa siempre elementos <label> con htmlFor apuntando al id del campo. El atributo aria-label es para casos donde no existe una etiqueta visible, como los botones que solo tienen un ícono.

Los desplegables, menús y pestañas personalizados necesitan soporte de teclado que reproduzca el de sus contrapartes nativas. Las WAI-ARIA Authoring Practices definen las interacciones de teclado esperadas.

tsxtsx
import { useState, useRef, useCallback } from 'react';
 
interface DropdownProps {
  label: string;
  options: { value: string; label: string }[];
  value: string;
  onChange: (value: string) => void;
}
 
function Dropdown({ label, options, value, onChange }: DropdownProps) {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  const listRef = useRef<HTMLUListElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const selectedOption = options.find(o => o.value === value);
 
  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      switch (e.key) {
        case 'ArrowDown':
          e.preventDefault();
          if (!isOpen) {
            setIsOpen(true);
            setActiveIndex(0);
          } else {
            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 && activeIndex >= 0) {
            onChange(options[activeIndex].value);
            setIsOpen(false);
            buttonRef.current?.focus();
          } else {
            setIsOpen(true);
            setActiveIndex(0);
          }
          break;
        case 'Escape':
          setIsOpen(false);
          buttonRef.current?.focus();
          break;
      }
    },
    [isOpen, activeIndex, options, onChange]
  );
 
  return (
    <div onKeyDown={handleKeyDown}>
      <button
        ref={buttonRef}
        aria-haspopup="listbox"
        aria-expanded={isOpen}
        aria-label={label}
        onClick={() => setIsOpen(o => !o)}
      >
        {selectedOption?.label ?? 'Select...'}
      </button>
 
      {isOpen && (
        <ul
          ref={listRef}
          role="listbox"
          aria-label={label}
          aria-activedescendant={
            activeIndex >= 0 ? `option-${activeIndex}` : undefined
          }
        >
          {options.map((option, index) => (
            <li
              key={option.value}
              id={`option-${index}`}
              role="option"
              aria-selected={option.value === value}
              data-active={index === activeIndex}
              onClick={() => {
                onChange(option.value);
                setIsOpen(false);
                buttonRef.current?.focus();
              }}
            >
              {option.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Patrones de teclado clave: las flechas navegan las opciones. Enter/Espacio selecciona. Escape cierra y devuelve el foco al disparador. aria-activedescendant le indica a los lectores de pantalla qué opción está resaltada actualmente.

Gestión del foco en modales

Los modales deben atrapar el foco: Tab debe recorrer los elementos enfocables dentro del modal, sin escapar a la página detrás de él.

tsxtsx
import { useEffect, useRef } from 'react';
 
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) {
      // Save the element that had focus before modal opened
      previousFocusRef.current = document.activeElement as HTMLElement;
 
      // Focus the modal container
      modalRef.current?.focus();
 
      return () => {
        // Restore focus when modal closes
        previousFocusRef.current?.focus();
      };
    }
  }, [isOpen]);
 
  useEffect(() => {
    if (!isOpen) return;
 
    function handleKeyDown(e: KeyboardEvent) {
      if (e.key === 'Escape') {
        onClose();
        return;
      }
 
      if (e.key !== 'Tab') return;
 
      const modal = modalRef.current;
      if (!modal) return;
 
      const focusable = modal.querySelectorAll<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
 
      const first = focusable[0];
      const last = focusable[focusable.length - 1];
 
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    }
 
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isOpen, onClose]);
 
  if (!isOpen) return null;
 
  return (
    <div className="modal-overlay" onClick={onClose}>
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-label={title}
        tabIndex={-1}
        onClick={e => e.stopPropagation()}
      >
        <h2>{title}</h2>
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>
  );
}

Tres requisitos de foco: capturar el foco cuando el modal se abre, atrapar el foco dentro del modal y restaurar el foco al disparador cuando el modal se cierra.

Regiones en vivo para contenido dinámico

Cuando el contenido se actualiza sin una navegación de página —como una notificación toast, un error de validación de formulario o un estado de carga— los lectores de pantalla necesitan que se les informe del cambio.

tsxtsx
// ❌ Dynamic error message — screen reader never announces it
function Form() {
  const [error, setError] = useState('');
 
  return (
    <form>
      <input type="email" />
      {error && <span className="error">{error}</span>}
    </form>
  );
}
tsxtsx
// ✅ Live region announces the error to screen readers
function Form() {
  const [error, setError] = useState('');
 
  return (
    <form>
      <input type="email" aria-describedby="email-error" />
      <span
        id="email-error"
        role="alert"
        aria-live="assertive"
        className="error"
      >
        {error}
      </span>
    </form>
  );
}
// When error text changes, screen reader interrupts to announce it

aria-live="assertive" interrumpe el anuncio actual: úsalo para errores. aria-live="polite" espera a que el lector de pantalla termine su lectura actual: úsalo para actualizaciones de estado como "3 resultados encontrados".

Puntos clave

  1. Usa HTML semántico antes de recurrir a ARIA: los elementos nativos manejan el teclado, el foco y los roles automáticamente
  2. Etiqueta cada elemento interactivo: los botones de ícono necesitan aria-label, los campos de formulario necesitan elementos <label>
  3. Implementa la navegación por teclado en los componentes personalizados siguiendo los patrones WAI-ARIA: flechas, Enter, Escape
  4. Atrapa el foco en los modales: guarda el foco anterior, recorre con Tab dentro del modal, restaura el foco al cerrar
  5. Usa regiones en vivo para contenido dinámico: role="alert" para errores, aria-live="polite" para actualizaciones de estado
  6. Prueba con un lector de pantalla: VoiceOver en Mac, NVDA en Windows o ChromeVox en Chrome
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX