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.

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.
// ❌ 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.
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.
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.
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.
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.


