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.

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


