Construir componentes de React accesibles desde cero
Construye componentes React navegables con teclado, compatibles con lectores de pantalla y conformes con WCAG, usando ARIA, foco y HTML semántico.

La accesibilidad no es una idea tardía
La accesibilidad es un atributo de calidad de tu software, no una funcionalidad aparte que se añade después. Cuando construyes componentes que manejan el foco correctamente, exponen la semántica adecuada y responden a la entrada del teclado, construyes componentes que funcionan mejor para todos: usuarios de lectores de pantalla, usuarios que solo usan teclado y usuarios de ratón por igual.
HTML semántico como base
Antes de recurrir a los atributos ARIA, usa los elementos HTML correctos. Un <button> ya se anuncia como botón, maneja las pulsaciones de Enter y Espacio, y es enfocable. Un <div onClick> no hace nada de esto sin un trabajo extra considerable.
// ❌ Div pretending to be a button — inaccessible by default
function BadButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
return (
<div className="btn" onClick={onClick}>
{children}
</div>
);
}
// ✅ Semantic button — accessible automatically
function GoodButton({ onClick, children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button className="btn" onClick={onClick} {...props}>
{children}
</button>
);
}// Semantic structure matters for screen readers
function ArticleCard({ title, excerpt, date, href }: ArticleCardProps) {
return (
<article aria-labelledby={`title-${href}`}>
<header>
<time dateTime={date}>{formatDate(date)}</time>
<h3 id={`title-${href}`}>
<a href={href}>{title}</a>
</h3>
</header>
<p>{excerpt}</p>
</article>
);
}Gestión del foco en componentes dinámicos
Cuando el contenido aparece o desaparece dinámicamente —modales, menús desplegables, paneles de pestañas— el foco debe moverse de forma predecible. Perder el foco hacia el cuerpo del documento desorienta a los usuarios de teclado y de lectores de pantalla.
function Modal({ isOpen, onClose, title, children }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// Store the element that had focus before opening
previousFocusRef.current = document.activeElement as HTMLElement;
// Move focus into the modal
modalRef.current?.focus();
return () => {
// Restore focus when modal closes
previousFocusRef.current?.focus();
};
}
}, [isOpen]);
// Trap focus inside the modal
function handleKeyDown(event: React.KeyboardEvent) {
if (event.key === "Escape") {
onClose();
return;
}
if (event.key !== "Tab") return;
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusableElements?.length) return;
const first = focusableElements[0];
const last = focusableElements[focusableElements.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose} role="presentation">
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
onKeyDown={handleKeyDown}
onClick={(e) => e.stopPropagation()}
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>
);
}Construir un componente de pestañas accesible
Las pestañas siguen el patrón de pestañas de WAI-ARIA: un tablist que contiene elementos tab que controlan elementos tabpanel. Las teclas de flecha navegan entre las pestañas, y solo la pestaña activa está en el orden de tabulación.
function Tabs({ tabs }: { tabs: Array<{ label: string; content: React.ReactNode }> }) {
const [activeIndex, setActiveIndex] = useState(0);
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
function handleKeyDown(event: React.KeyboardEvent, index: number) {
let newIndex = index;
switch (event.key) {
case "ArrowRight":
newIndex = (index + 1) % tabs.length;
break;
case "ArrowLeft":
newIndex = (index - 1 + tabs.length) % tabs.length;
break;
case "Home":
newIndex = 0;
break;
case "End":
newIndex = tabs.length - 1;
break;
default:
return;
}
event.preventDefault();
setActiveIndex(newIndex);
tabRefs.current[newIndex]?.focus();
}
return (
<div>
<div role="tablist" aria-label="Content tabs">
{tabs.map((tab, index) => (
<button
key={index}
ref={(el) => { tabRefs.current[index] = el; }}
role="tab"
id={`tab-${index}`}
aria-selected={index === activeIndex}
aria-controls={`panel-${index}`}
tabIndex={index === activeIndex ? 0 : -1}
onClick={() => setActiveIndex(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div
key={index}
role="tabpanel"
id={`panel-${index}`}
aria-labelledby={`tab-${index}`}
hidden={index !== activeIndex}
tabIndex={0}
>
{tab.content}
</div>
))}
</div>
);
}Regiones en vivo para actualizaciones dinámicas
Cuando el contenido se actualiza sin recargar la página —notificaciones toast, errores de validación de formularios, datos en vivo— hay que avisar a los lectores de pantalla. Las regiones en vivo de ARIA anuncian los cambios automáticamente.
function useAnnounce() {
const [message, setMessage] = useState("");
const announce = useCallback((text: string, priority: "polite" | "assertive" = "polite") => {
// Clear first to re-trigger announcement for identical messages
setMessage("");
requestAnimationFrame(() => setMessage(text));
}, []);
const AnnouncerRegion = useMemo(
() =>
function Announcer() {
return (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{message}
</div>
);
},
[message]
);
return { announce, AnnouncerRegion };
}
// Usage in a form
function SearchForm() {
const { announce, AnnouncerRegion } = useAnnounce();
const [results, setResults] = useState<SearchResult[]>([]);
async function handleSearch(query: string) {
const data = await fetchResults(query);
setResults(data);
announce(`${data.length} results found for "${query}"`);
}
return (
<form role="search" onSubmit={(e) => {
e.preventDefault();
const query = new FormData(e.currentTarget).get("q") as string;
handleSearch(query);
}}>
<label htmlFor="search-input">Search</label>
<input id="search-input" name="q" type="search" />
<button type="submit">Search</button>
<AnnouncerRegion />
<ul aria-label="Search results">
{results.map((r) => (
<li key={r.id}>{r.title}</li>
))}
</ul>
</form>
);
}Probar la accesibilidad
Las herramientas automatizadas detectan alrededor del 30% de los problemas de accesibilidad. El resto requiere pruebas manuales con teclado y un lector de pantalla. Integra ambos en tu flujo de trabajo.
// jest + testing-library accessibility assertions
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe, toHaveNoViolations } from "jest-axe";
expect.extend(toHaveNoViolations);
describe("Tabs component", () => {
it("has no accessibility violations", async () => {
const { container } = render(
<Tabs
tabs={[
{ label: "First", content: <p>First panel</p> },
{ label: "Second", content: <p>Second panel</p> },
]}
/>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("supports keyboard navigation", async () => {
const user = userEvent.setup();
render(
<Tabs
tabs={[
{ label: "First", content: <p>First panel</p> },
{ label: "Second", content: <p>Second panel</p> },
]}
/>
);
const firstTab = screen.getByRole("tab", { name: "First" });
await user.click(firstTab);
expect(firstTab).toHaveFocus();
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("tab", { name: "Second" })).toHaveFocus();
expect(screen.getByRole("tab", { name: "Second" })).toHaveAttribute(
"aria-selected",
"true"
);
});
});Conclusiones clave
La accesibilidad empieza con HTML semántico. Usa <button>, <nav>, <main>, <article> antes de recurrir a ARIA. Gestiona el foco de forma deliberada cuando el contenido dinámico aparece o desaparece: guarda el foco anterior, muévelo al nuevo contenido y restáuralo al cerrar. Sigue los patrones de WAI-ARIA para widgets complejos como pestañas, menús y diálogos.
Usa regiones en vivo para anunciar los cambios de contenido dinámico a los lectores de pantalla. Prueba con jest-axe para las comprobaciones automatizadas, y luego verifica con navegación por teclado y un lector de pantalla real. La accesibilidad no es una lista de verificación que completar: es una restricción de diseño que produce mejores componentes para todos los usuarios.


