Saltar al contenido

Cómo construir componentes React accesibles desde cero

Guía para construir componentes React con la accesibilidad como prioridad: patrones ARIA, navegación por teclado, gestión del foco y pruebas de lectura.

5 min de lectura
Árbol de componentes React con anotaciones de accesibilidad que muestran roles ARIA y el flujo del foco de teclado

La accesibilidad no es una función: es la base

La accesibilidad no es algo que se añade a un componente terminado. Si un botón no funciona con el teclado, no es un botón completo. Si un modal atrapa el foco de forma incorrecta, es un modal roto. La accesibilidad es el mínimo exigible para que un componente sea funcional, no una mejora que se incorpora antes de una auditoría.

La buena noticia: construir componentes React accesibles no es más difícil que construir componentes inaccesibles. Requiere entender unos pocos patrones y aplicarlos de forma consistente. Los patrones se vuelven naturales después de unos cuantos componentes.

HTML semántico como fundamento

La mejora de accesibilidad con mayor impacto es usar el elemento HTML correcto. Un <button> obtiene interacción por teclado, gestión del foco y anuncios de lector de pantalla gratis. Un <div onClick> no obtiene nada de eso.

tsxtsx
// ❌ Custom div pretending to be a button
function BadButton({ onClick, children }: {
  onClick: () => void;
  children: React.ReactNode;
}) {
  return (
    <div
      className="btn"
      onClick={onClick}
    >
      {children}
    </div>
  );
  // Missing: keyboard support, focus, role, tabindex
}
 
// ✅ Actual button element — accessible by default
function GoodButton({ onClick, children, disabled = false }: {
  onClick: () => void;
  children: React.ReactNode;
  disabled?: boolean;
}) {
  return (
    <button
      className="btn"
      onClick={onClick}
      disabled={disabled}
      type="button"
    >
      {children}
    </button>
  );
  // Gets for free: focus, keyboard activation, disabled state,
  // screen reader role announcement
}

Antes de recurrir a los atributos ARIA, pregúntate si el elemento HTML correcto ya proporciona lo que necesitas. En la mayoría de los casos, lo hace.

Patrones de navegación por teclado

Todo elemento interactivo debe ser operable con el teclado. Esto significa manejar el orden del foco, la navegación con teclas de flecha dentro de widgets compuestos y las teclas de escape para los elementos que se pueden cerrar.

tsxtsx
import { useRef, useCallback, KeyboardEvent } from "react";
 
interface TabItem {
  id: string;
  label: string;
  content: React.ReactNode;
}
 
function Tabs({ items }: { items: TabItem[] }) {
  const [activeIndex, setActiveIndex] = useState(0);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
 
  const handleKeyDown = useCallback(
    (event: KeyboardEvent<HTMLDivElement>) => {
      let newIndex = activeIndex;
 
      switch (event.key) {
        case "ArrowRight":
          newIndex = (activeIndex + 1) % items.length;
          break;
        case "ArrowLeft":
          newIndex = (activeIndex - 1 + items.length) % items.length;
          break;
        case "Home":
          newIndex = 0;
          break;
        case "End":
          newIndex = items.length - 1;
          break;
        default:
          return;
      }
 
      event.preventDefault();
      setActiveIndex(newIndex);
      tabRefs.current[newIndex]?.focus();
    },
    [activeIndex, items.length]
  );
 
  return (
    <div>
      <div
        role="tablist"
        aria-label="Content tabs"
        onKeyDown={handleKeyDown}
      >
        {items.map((item, index) => (
          <button
            key={item.id}
            ref={(el) => { tabRefs.current[index] = el; }}
            role="tab"
            id={`tab-${item.id}`}
            aria-selected={index === activeIndex}
            aria-controls={`panel-${item.id}`}
            tabIndex={index === activeIndex ? 0 : -1}
            onClick={() => setActiveIndex(index)}
          >
            {item.label}
          </button>
        ))}
      </div>
      {items.map((item, index) => (
        <div
          key={item.id}
          role="tabpanel"
          id={`panel-${item.id}`}
          aria-labelledby={`tab-${item.id}`}
          hidden={index !== activeIndex}
          tabIndex={0}
        >
          {item.content}
        </div>
      ))}
    </div>
  );
}

El componente de pestañas sigue el patrón WAI-ARIA Tabs: las teclas de flecha se mueven entre pestañas, solo la pestaña activa está en el orden de tabulación (tabIndex={0}) y las pestañas inactivas se eliminan del orden de tabulación (tabIndex={-1}).

Gestión del foco para modales y diálogos

Los modales deben atrapar el foco dentro de sus límites y devolver el foco al elemento que los activó cuando se cierran. Sin esto, los usuarios de teclado se pierden detrás de la capa del modal.

tsxtsx
import { useEffect, useRef, useCallback } from "react";
 
function useFocusTrap(isOpen: boolean) {
  const containerRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);
 
  useEffect(() => {
    if (isOpen) {
      previousFocusRef.current = document.activeElement as HTMLElement;
 
      const focusableSelector =
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
 
      const container = containerRef.current;
      if (!container) return;
 
      const focusableElements = container.querySelectorAll(focusableSelector);
      const firstElement = focusableElements[0] as HTMLElement;
      firstElement?.focus();
 
      return () => {
        previousFocusRef.current?.focus();
      };
    }
  }, [isOpen]);
 
  const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
    if (event.key !== "Tab") return;
 
    const container = containerRef.current;
    if (!container) return;
 
    const focusableSelector =
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
    const focusableElements = container.querySelectorAll(focusableSelector);
    const firstElement = focusableElements[0] as HTMLElement;
    const lastElement = focusableElements[
      focusableElements.length - 1
    ] as HTMLElement;
 
    if (event.shiftKey && document.activeElement === firstElement) {
      event.preventDefault();
      lastElement.focus();
    } else if (!event.shiftKey && document.activeElement === lastElement) {
      event.preventDefault();
      firstElement.focus();
    }
  }, []);
 
  return { containerRef, handleKeyDown };
}
 
function Modal({
  isOpen,
  onClose,
  title,
  children,
}: {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}) {
  const { containerRef, handleKeyDown } = useFocusTrap(isOpen);
 
  if (!isOpen) return null;
 
  return (
    <div className="modal-overlay" onClick={onClose}>
      <div
        ref={containerRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        onKeyDown={(e) => {
          handleKeyDown(e);
          if (e.key === "Escape") onClose();
        }}
        onClick={(e) => e.stopPropagation()}
      >
        <h2 id="modal-title">{title}</h2>
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>
  );
}

Regiones live para contenido dinámico

Cuando el contenido se actualiza dinámicamente —errores de validación de formularios, notificaciones toast, estados de carga— los lectores de pantalla necesitan que se les informe de que algo cambió. Las regiones live de ARIA se encargan de esto.

tsxtsx
import { useState } from "react";
 
function SearchResults({ query }: { query: string }) {
  const [results, setResults] = useState<string[]>([]);
  const [isLoading, setIsLoading] = useState(false);
 
  return (
    <div>
      {/* Polite announcement for search results count */}
      <div
        role="status"
        aria-live="polite"
        aria-atomic="true"
        className="sr-only"
      >
        {isLoading
          ? "Searching..."
          : `${results.length} results found for "${query}"`}
      </div>
 
      {/* Results list */}
      <ul aria-label={`Search results for ${query}`}>
        {results.map((result, i) => (
          <li key={i}>{result}</li>
        ))}
      </ul>
    </div>
  );
}
 
function FormWithValidation() {
  const [errors, setErrors] = useState<Record<string, string>>({});
 
  return (
    <form>
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          aria-invalid={!!errors.email}
          aria-describedby={errors.email ? "email-error" : undefined}
        />
        {errors.email && (
          <div
            id="email-error"
            role="alert"
            className="error-message"
          >
            {errors.email}
          </div>
        )}
      </div>
    </form>
  );
}

Usa aria-live="polite" para actualizaciones no urgentes (resultados de búsqueda, cambios de estado) y role="alert" para mensajes importantes que requieren atención inmediata (errores de validación, estados de error).

Pruebas de accesibilidad

Los componentes accesibles necesitan pruebas automatizadas que verifiquen los atributos ARIA, las interacciones de teclado y la gestión del foco.

tstypescript
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
 
describe("Tabs component", () => {
  const items = [
    { id: "1", label: "Tab 1", content: <p>Content 1</p> },
    { id: "2", label: "Tab 2", content: <p>Content 2</p> },
    { id: "3", label: "Tab 3", content: <p>Content 3</p> },
  ];
 
  it("supports arrow key navigation", async () => {
    const user = userEvent.setup();
    render(<Tabs items={items} />);
 
    const firstTab = screen.getByRole("tab", { name: "Tab 1" });
    await user.click(firstTab);
 
    await user.keyboard("{ArrowRight}");
    expect(screen.getByRole("tab", { name: "Tab 2" })).toHaveFocus();
 
    await user.keyboard("{ArrowRight}");
    expect(screen.getByRole("tab", { name: "Tab 3" })).toHaveFocus();
 
    // Wraps around
    await user.keyboard("{ArrowRight}");
    expect(screen.getByRole("tab", { name: "Tab 1" })).toHaveFocus();
  });
 
  it("sets correct ARIA attributes", () => {
    render(<Tabs items={items} />);
 
    const activeTab = screen.getByRole("tab", { name: "Tab 1" });
    expect(activeTab).toHaveAttribute("aria-selected", "true");
    expect(activeTab).toHaveAttribute("tabindex", "0");
 
    const inactiveTab = screen.getByRole("tab", { name: "Tab 2" });
    expect(inactiveTab).toHaveAttribute("aria-selected", "false");
    expect(inactiveTab).toHaveAttribute("tabindex", "-1");
  });
 
  it("shows correct panel when tab is selected", async () => {
    const user = userEvent.setup();
    render(<Tabs items={items} />);
 
    expect(screen.getByText("Content 1")).toBeVisible();
    expect(screen.queryByText("Content 2")).not.toBeVisible();
 
    await user.click(screen.getByRole("tab", { name: "Tab 2" }));
    expect(screen.getByText("Content 2")).toBeVisible();
  });
});

Las pruebas automatizadas detectan regresiones en los atributos ARIA y el comportamiento del teclado. Combina esto con pruebas manuales con lector de pantalla en al menos un lector (VoiceOver en macOS, NVDA en Windows) para cada nuevo patrón de componente.

Conclusiones clave

La accesibilidad empieza con HTML semántico. Usa los elementos correctos antes de recurrir a ARIA: un <button> es más accesible que cualquier cantidad de atributos ARIA sobre un <div>. Construye la navegación por teclado siguiendo los patrones WAI-ARIA para que los usuarios obtengan un comportamiento consistente y predecible en todos los componentes.

La gestión del foco es crítica para modales, menús desplegables y cualquier componente que cree un nuevo contexto de interacción. Las regiones live mantienen informados a los usuarios de lectores de pantalla sobre los cambios de contenido dinámico. Prueba la accesibilidad con herramientas automatizadas para los atributos ARIA y las interacciones de teclado, complementado con pruebas manuales con lector de pantalla.

Construir componentes accesibles no es trabajo extra: es el trabajo de construir componentes correctamente.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX