¿Qué hace Patrones de Accesibilidad Frontend?
Patrones prácticos de accesibilidad para React y Next.js. Cubre los problemas más comúnmente señalados en revisiones de código: etiquetas de formulario faltantes, uso incorrecto de ARIA, elementos interactivos no semánticos y navegación por teclado rota.
Cuándo Activarlo
- Al construir o revisar componentes de formulario (
<input>,<select>,<textarea>) - Al crear elementos interactivos (modales, menús desplegables, tooltips, pestañas)
- Al usar
<div>o<span>cononClick - Al agregar atributos
aria-*a cualquier elemento - Al implementar navegación por teclado o gestión de foco
- Al recibir comentarios de accesibilidad de herramientas de revisión de código (CodeRabbit, ESLint a11y)
- Al construir componentes que deben ser compatibles con lectores de pantalla
Accesibilidad en Formularios
La falta de emparejamiento htmlFor / id y los mensajes de error desconectados son los problemas más comunes señalados en las revisiones de código.
Conexión de Etiqueta
// MALO: la etiqueta no tiene conexión con el input — los lectores de pantalla no pueden asociarlos
<label>Email</label>
<input type="email" />
// BUENO: htmlFor coincide con el id del input
<label htmlFor="email">Email</label>
<input id="email" type="email" />
Campos Obligatorios
// MALO: el asterisco solo visual no transmite nada a los lectores de pantalla
<label htmlFor="email">Email *</label>
<input id="email" type="email" />
// BUENO: required activa la validación nativa del navegador; aria-required lo señala a los lectores de pantalla
<label htmlFor="email">
Email <span aria-hidden="true">*</span>
</label>
<input id="email" type="email" required aria-required="true" />
Mensajes de Error
// MALO: el texto de error existe visualmente pero no está vinculado al input
<input id="email" type="email" />
<span className="error">Dirección de correo inválida</span>
// BUENO: aria-describedby conecta el input con su mensaje de error
// aria-invalid señala el estado inválido a los lectores de pantalla
<input
id="email"
type="email"
aria-describedby="email-error"
aria-invalid={!!error}
/>
{error && (
<span id="email-error" role="alert">
{error}
</span>
)}
Formulario Accesible Completo
interface LoginFormProps {
onSubmit: (email: string, password: string) => void;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const newErrors: typeof errors = {};
if (!email) newErrors.email = 'El correo es obligatorio';
if (!password) newErrors.password = 'La contraseña es obligatoria';
if (Object.keys(newErrors).length) {
setErrors(newErrors);
return;
}
onSubmit(email, password);
};
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="email">
Correo <span aria-hidden="true">*</span>
</label>
<input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
aria-required="true"
aria-describedby={errors.email ? 'email-error' : undefined}
aria-invalid={!!errors.email}
autoComplete="email"
/>
{errors.email && (
<span id="email-error" role="alert">
{errors.email}
</span>
)}
</div>
<div>
<label htmlFor="password">
Contraseña <span aria-hidden="true">*</span>
</label>
<input
id="password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
aria-required="true"
aria-describedby={errors.password ? 'password-error' : undefined}
aria-invalid={!!errors.password}
autoComplete="current-password"
/>
{errors.password && (
<span id="password-error" role="alert">
{errors.password}
</span>
)}
</div>
<button type="submit">Iniciar sesión</button>
</form>
);
}
HTML Semántico
Usa el elemento que coincida con la intención. Los lectores de pantalla y los usuarios de teclado dependen de la semántica nativa.
// MALO: div no tiene rol, ni soporte de teclado, ni nombre accesible
<div onClick={handleClick}>Enviar</div>
// BUENO: button es enfocable, se activa con Enter/Espacio, se anuncia como "botón"
<button type="button" onClick={handleClick}>Enviar</button>
// MALO: navegación no semántica
<div onClick={() => navigate('/home')}>Inicio</div>
// BUENO: anchor soporta clic derecho, clic medio y navegación por teclado
<a href="/home">Inicio</a>
// MALO: jerarquía de encabezados saltada (h1 a h4)
<h1>Panel de Control</h1>
<h4>Actividad Reciente</h4>
// BUENO: niveles de encabezado secuenciales
<h1>Panel de Control</h1>
<h2>Actividad Reciente</h2>
Atributos ARIA
Usa ARIA solo cuando la semántica HTML nativa sea insuficiente. Un ARIA incorrecto es peor que no tener ARIA.
aria-label vs aria-labelledby
// aria-label: etiqueta de cadena en línea — úsalo cuando no haya texto de etiqueta visible
<button aria-label="Cerrar modal">
<XIcon />
</button>
// aria-labelledby: referencia al texto de otro elemento — úsalo cuando exista una etiqueta visible
<section aria-labelledby="section-title">
<h2 id="section-title">Órdenes Recientes</h2>
{/* contenido */}
</section>
aria-describedby
// Proporciona una descripción complementaria más allá de la etiqueta
<button
aria-describedby="delete-warning"
onClick={handleDelete}
> Eliminar cuenta
</button>
<p id="delete-warning">Esta acción no se puede deshacer.</p>
aria-live para Contenido Dinámico
// Usa aria-live para anunciar contenido que se actualiza sin recargar la página
// polite: espera a que el usuario termine su acción actual antes de anunciar
// assertive: interrumpe inmediatamente — úsalo solo para errores urgentes
export function StatusMessage({ message, isError }: { message: string; isError?: boolean }) {
return (
<div role="status" aria-live={isError ? 'assertive' : 'polite'} aria-atomic="true">
{message}
</div>
);
}
aria-expanded y aria-controls
export function Accordion({ title, children }: { title: string; children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const contentId = useId();
return (
<div>
<button aria-expanded={isOpen} aria-controls={contentId} onClick={() => setIsOpen(prev => !prev)}>
{title}
</button>
<div id={contentId} hidden={!isOpen}>
{children}
</div>
</div>
);
}
Navegación por Teclado
Cada elemento interactivo debe ser accesible y operable solo con el teclado.
Menú Desplegable Personalizado
export function Dropdown({ options, onSelect }: { options: string[]; onSelect: (value: string) => void }) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const listId = useId();
if (!options.length) return null;
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
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) onSelect(options[activeIndex]);
setIsOpen(prev => !prev);
break;
case 'Escape':
setIsOpen(false);
break;
}
};
return (
<div
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listId}
tabIndex={0}
onKeyDown={handleKeyDown}
onClick={() => setIsOpen(prev => !prev)}
>
<span>{options[activeIndex]}</span>
{isOpen && (
<ul id={listId} role="listbox">
{options.map((option, index) => (
<li
key={option}
role="option"
aria-selected={index === activeIndex}
onClick={() => {
onSelect(option);
setIsOpen(false);
}}
>
{option}
</li>
))}
</ul>
)}
</div>
);
}
Gestión de Foco
El foco debe moverse de manera lógica cuando cambia el estado de la interfaz de usuario, especialmente en modales y transiciones de ruta.
Restauración de Foco en Modal
Este ejemplo cubre el foco inicial y la restauración. Para un bloqueo de foco completo (ciclo Tab/Shift+Tab dentro del modal), usa una librería como
focus-trap-reactque maneja casos extremos como contenido dinámico y portales anidados.
export 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) {
// Guarda el elemento actualmente enfocado y mueve el foco al modal
previousFocusRef.current = document.activeElement as HTMLElement;
modalRef.current?.focus();
} else {
// Restaura el foco al elemento que abrió el modal
previousFocusRef.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div ref={modalRef} role="dialog" aria-modal="true" aria-labelledby="modal-title" tabIndex={-1} onKeyDown={e => e.key === 'Escape' && onClose()}>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Cerrar</button>
</div>
);
}
Imágenes e Iconos
// MALO: icono decorativo anunciado como imagen sin etiqueta
<img src="/icon.svg" />
// BUENO: imagen decorativa oculta de los lectores de pantalla
<img src="/decoration.png" alt="" aria-hidden="true" />
// BUENO: imagen significativa con texto alternativo descriptivo
<img src="/chart.png" alt="Los ingresos mensuales aumentaron un 23% de enero a marzo" />
// BUENO: botón de icono con etiqueta accesible
<button aria-label="Eliminar elemento">
<TrashIcon aria-hidden="true" />
</button>
Movimiento Reducido
Respeta a los usuarios que han solicitado movimiento reducido en la configuración de su sistema operativo.
export function useReducedMotion(): boolean {
const [prefersReduced, setPrefersReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReduced(mq.matches);
const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
return prefersReduced;
}
// Uso
export function AnimatedCard({ children }: { children: React.ReactNode }) {
const reduceMotion = useReducedMotion();
return (
<div
style={{
transition: reduceMotion ? 'none' : 'transform 300ms ease'
}}
>
{children}
</div>
);
}
Anti-Patrones
// MALO: onClick en elemento no interactivo sin soporte de teclado
<div onClick={handleClick}>Haz clic aquí</div>
// MALO: aria-label en un div que no tiene rol
<div aria-label="Navegación">...</div>
// MALO: placeholder usado como sustituto de etiqueta
<input placeholder="Ingresa tu correo" />
// MALO: tabIndex positivo crea un orden de tabulación impredecible
<button tabIndex={3}>Enviar</button>
// MALO: aria-hidden en un elemento enfocable — los usuarios de teclado quedan atrapados
<button aria-hidden="true">Abrir</button>
// MALO: role="button" en div sin manejador de teclado
<div role="button" onClick={handleClick}>Enviar</div>
// Falta: tabIndex={0}, onKeyDown para Enter/Espacio
Lista de Verificación
Antes de enviar cualquier componente interactivo para revisión:
- Cada
<input>,<select>y<textarea>tiene un<label>conectado mediantehtmlFor/id - Los mensajes de error están vinculados con
aria-describedbyy marcados conrole="alert" - No hay
onClicken<div>o<span>sinrole,tabIndexyonKeyDown - Los botones solo con icono tienen
aria-label - Las imágenes decorativas usan
alt=""yaria-hidden="true" - Los modales restauran el foco al cerrarse (para un bloqueo de foco completo con ciclo Tab/Shift+Tab, usa una librería como
focus-trap-react) - Las actualizaciones de contenido dinámico usan
aria-live - Se respeta
prefers-reduced-motionpara las animaciones
Skills Relacionados
frontend-patterns— patrones generales de componentes y estado en Reactdesign-system— consistencia de tokens de diseño y componentesmotion-foundationsymotion-patterns: patrones de animación con consideraciones de accesibilidad

