Accesibilidad en componentes React | Nicolás Garzón
React puede renderizar HTML accesible, pero no corrige una estructura incorrecta ni convierte controles personalizados en equivalentes nativos. La accesibilidad forma parte del contrato del componente desde su diseño.
El navegador ya ofrece semántica, navegación por teclado, foco y comportamiento para muchos controles:
TypeScript
Copiar < button type= "button" onClick= { handleSave} >
Guardar cambios
< / button> No reemplaces ese comportamiento con un elemento genérico:
TypeScript
Copiar < div onClick= { handleSave} > Guardar cambios< / div> Añadir role="button" no implementa automáticamente:
Foco mediante Tab.
Activación con Enter y Space.
Estado disabled.
Semántica consistente.
Comportamiento esperado en formularios.
Usa elementos nativos siempre que representen la interacción.
JSX expresa la estructura, pero las reglas pertenecen al documento resultante:
TypeScript
Copiar < nav aria- label= "Navegación principal" >
< ul>
< li> < a href= "/projects" > Proyectos< / a> < / li>
< li> < a href= "/services" > Servicios< / a> < / li>
< / ul>
< / nav> React no hace accesible una navegación por el hecho de dividirla en componentes. Debes conservar landmarks, listas, headings y relaciones válidas.
Utiliza un botón para una acción y un enlace para navegación:
TypeScript
Copiar < button type= "button" onClick= { openDialog} > Abrir detalles< / button>
< a href= "/portfolio" > Ver portafolio< / a> No uses un enlace vacío para ejecutar una acción ni un botón para cambiar de URL sin una razón de arquitectura.
TypeScript
Copiar function EmailField ( ) {
return (
< div>
< label htmlFor= "email" > Correo electrónico< / label>
< input
id= "email"
name= "email"
type= "email"
autoComplete= "email"
required
/ >
< / div>
) ;
} En JSX se usa htmlFor; React genera el atributo for correspondiente.
Un placeholder no reemplaza el label porque desaparece al escribir y suele tener menor contraste.
Una instancia reutilizable necesita IDs únicos:
TypeScript
Copiar import { useId } from "react" ;
function PasswordField ( ) {
const id = useId ( ) ;
const inputId = ` ${ id} -input ` ;
const helpId = ` ${ id} -help ` ;
return (
< div>
< label htmlFor= { inputId} > Contraseña< / label>
< input
id= { inputId}
type= "password"
name= "password"
aria- describedby= { helpId}
/ >
< p id= { helpId} > M ínimo ocho caracteres. < / p>
< / div>
) ;
} useId genera identificadores compatibles con server rendering e hydration. No lo utilices como key de listas; las keys deben provenir de la identidad de los datos.
Relaciona el mensaje con el campo:
TypeScript
Copiar type EmailFieldProps = {
error? : string ;
} ;
function EmailField ( { error } : EmailFieldProps) {
const id = useId ( ) ;
const errorId = ` ${ id} -error ` ;
return (
< div>
< label htmlFor= { id} > Correo< / label>
< input
id= { id}
name= "email"
type= "email"
aria- invalid= { Boolean ( error) }
aria- describedby= { error ? errorId : undefined }
/ >
{ error && (
< p id= { errorId} role= "alert" >
{ error}
< / p>
) }
< / div>
) ;
} No anuncies cada validación mientras el usuario escribe si eso genera ruido. El momento adecuado depende del producto: blur, submit o validación progresiva cuidadosamente diseñada.
React no exige reemplazar validaciones HTML:
TypeScript
Copiar < input type= "email" required minLength= { 5 } / > La validación de producto puede complementar estas reglas. El servidor debe volver a validar porque el cliente puede alterarse.
Un componente interactivo debe definir:
Qué elementos reciben foco.
Qué teclas activan o navegan.
Cómo se comunica el estado.
Qué ocurre al cerrar o cancelar.
Para patrones complejos como tabs, menu, combobox o grid, sigue especificaciones accesibles probadas o utiliza primitives headless mantenidas.
No inventes un modelo de teclado distinto sin necesidad.
No elimines el outline sin una alternativa:
CSS
Copiar .button:focus-visible {
outline : 3px solid var ( --focus-ring) ;
outline-offset : 3px;
} El foco visible pertenece principalmente a CSS, pero el componente debe permitir que los elementos correctos reciban foco.
Mover el foco es apropiado cuando cambia el contexto de interacción:
Abrir un modal.
Mostrar un formulario de edición que reemplaza contenido.
Llevar al primer error tras un submit fallido.
Restaurar el foco al cerrar una capa.
No muevas el foco por cada render o actualización menor.
TypeScript
Copiar function EditDialog ( { open, onClose } : DialogProps) {
const closeButtonRef = useRef < HTMLButtonElement> ( null ) ;
useEffect ( ( ) => {
if ( open) closeButtonRef. current?. focus ( ) ;
} , [ open] ) ;
if ( ! open) return null ;
return (
< div role= "dialog" aria- modal= "true" aria- labelledby= "dialog-title" >
< h2 id= "dialog-title" > Editar perfil< / h2>
< button ref= { closeButtonRef} type= "button" onClick= { onClose} >
Cerrar
< / button>
< / div>
) ;
} Un modal real también necesita contener el foco, cerrar con Escape cuando corresponda, volver al trigger y evitar interacción con el fondo. El elemento nativo <dialog> puede resolver parte del comportamiento, pero aún requiere diseño y pruebas.
Un portal cambia la ubicación DOM, no la pertenencia al árbol React. Context y propagación de eventos React continúan según el árbol de componentes.
Para accesibilidad debes considerar la ubicación real:
Orden de foco.
aria-modal.
Fondo inert.
Relación con headings y labels.
Restauración del foco.
Portals no solucionan automáticamente modales ni stacking contexts.
Cada pantalla necesita una estructura comprensible:
TypeScript
Copiar < main>
< h1> Configuración de la cuenta< / h1>
< section aria- labelledby= "privacy-heading" >
< h2 id= "privacy-heading" > Privacidad< / h2>
< / section>
< / main> No elijas un heading por su tamaño visual. El estilo pertenece a CSS; el nivel comunica jerarquía.
Cuando el contenido es una colección, conserva la semántica:
TypeScript
Copiar < ul>
{ notifications. map ( ( notification) => (
< li key= { notification. id} > { notification. message} < / li>
) ) }
< / ul> Fragments y componentes no deben producir hijos inválidos de ul, table, dl u otras estructuras con reglas específicas.
Una tabla de datos necesita encabezados reales:
TypeScript
Copiar < table>
< caption> Pedidos recientes< / caption>
< thead>
< tr>
< th scope= "col" > N úmero< / th>
< th scope= "col" > Estado< / th>
< / tr>
< / thead>
< tbody>
{ orders. map ( ( order) => (
< tr key= { order. id} >
< th scope= "row" > { order. number } < / th>
< td> { order. status} < / td>
< / tr>
) ) }
< / tbody>
< / table> No uses tablas para layout visual.
Para mensajes que deben anunciarse sin mover foco:
TypeScript
Copiar < p role= "status" aria- live= "polite" >
{ statusMessage}
< / p> role="status" ya implica una live region polite en muchos contextos.
Usa role="alert" para información urgente. Anunciar cada cambio pequeño puede interrumpir y saturar al usuario.
Una región pendiente puede comunicar su estado:
TypeScript
Copiar < section aria- busy= { isPending} aria- labelledby= "results-heading" >
< h2 id= "results-heading" > Resultados< / h2>
< Results / >
< / section> Cuando Suspense reemplaza contenido con fallback:
Mantén contexto suficiente.
Evita mover foco.
Evita cambios bruscos de tamaño.
No anuncies repetidamente el mismo mensaje.
Considera conservar contenido anterior con transitions.
Un icono visible no garantiza un nombre accesible:
TypeScript
Copiar < button type= "button" aria- label= "Eliminar producto" onClick= { handleDelete} >
< TrashIcon aria- hidden= "true" / >
< / button> Cuando existe texto visible, normalmente ese texto ya aporta el nombre:
TypeScript
Copiar < button type= "button" >
< TrashIcon aria- hidden= "true" / >
Eliminar
< / button> El alt depende del propósito:
TypeScript
Copiar < img src= { product. imageUrl} alt= { product. name} / > Para una imagen decorativa:
TypeScript
Copiar < img src= "/decorative-wave.svg" alt= "" / > No repitas en el alt información idéntica que ya está junto a la imagen si no aporta contexto.
Si un elemento HTML nativo expresa el comportamiento, úsalo antes de recrearlo con ARIA.
ARIA puede comunicar estados y relaciones:
TypeScript
Copiar < button
type= "button"
aria- expanded= { open}
aria- controls= { panelId}
onClick= { ( ) => setOpen ( ( value) => ! value) }
>
Filtros
< / button>
< div id= { panelId} hidden= { ! open} > ... < / div> ARIA no añade comportamiento. aria-expanded no abre ni cierra el panel; el componente debe implementarlo.
React controla cuándo aparece una animación; CSS puede respetar preferencias:
CSS
Copiar @media ( prefers-reduced-motion : reduce) {
.animated-panel {
transition : none;
}
} No bases información esencial únicamente en movimiento.
Contraste, tamaño, spacing y estados hover/focus pertenecen principalmente a CSS y diseño. El componente debe exponer estados claros mediante clases, atributos y estructura semántica.
Una primitive headless accesible puede resolver comportamiento complejo sin imponer estilos.
Qué semántica genera.
Cómo compone IDs y labels.
Qué modelo de teclado implementa.
Cómo maneja focus y portals.
Si las personalizaciones rompen el contrato.
React Testing Library recomienda consultas cercanas a cómo una persona encuentra el elemento:
TypeScript
Copiar const saveButton = screen. getByRole ( "button" , { name: / guardar cambios / i } ) ;
await user. click ( saveButton) ; Si no puedes encontrar un control por rol y nombre, puede existir un problema en su semántica.
Complementa tests automáticos con:
Navegación solo por teclado.
Zoom.
High contrast.
Lectores de pantalla representativos.
Auditorías automáticas.
Las herramientas automáticas no detectan toda la experiencia.
¿Utiliza el elemento nativo adecuado?
¿Tiene un nombre accesible?
¿Funciona mediante teclado?
¿El foco es visible?
¿Comunica disabled, expanded, selected, invalid o pending?
¿Mantiene una estructura HTML válida?
¿Los errores están asociados con sus campos?
¿Gestiona foco al abrir y cerrar capas?
¿Respeta reduced motion?
¿Puede probarse por rol y nombre?
Añadir onClick a un div y considerarlo botón.
Eliminar outlines sin alternativa.
Usar placeholder como label.
Agregar ARIA redundante o incorrecta.
Usar useId como key.
Crear IDs repetidos manualmente.
Mover foco por cada render.
Abrir un modal sin restaurar foco.
Usar un portal y asumir que ya resolvió accesibilidad.
Anunciar cada actualización con role="alert".
Elegir headings por tamaño visual.
Dejar accesibilidad para el final.
React no corrige HTML incorrecto.
HTML nativo aporta semántica y comportamiento.
JSX utiliza htmlFor, ARIA y atributos del DOM.
useId crea IDs estables, no keys de listas.
La gestión de foco debe ser intencional.
Portals y Suspense requieren considerar foco y anuncios.
ARIA comunica; no implementa interacción.
La accesibilidad forma parte de la API pública del componente.
¿Por qué role="button" no convierte completamente un div en botón?
¿Para qué sirve useId y para qué no debe utilizarse?
¿Cuándo conviene una live region en lugar de mover foco?
¿Qué debe ocurrir con el foco al cerrar un modal?
¿Por qué getByRole puede detectar problemas de diseño accesible?
Ver respuestas
Porque no añade foco, teclado, disabled ni comportamiento nativo.
Sirve para relaciones accesibles e IDs hydration-safe; no para keys de listas.
Para anunciar un cambio dinámico sin cambiar el contexto de interacción.
Debe volver normalmente al elemento que abrió el modal.
Porque exige que el elemento tenga un rol y nombre cercanos a cómo lo percibe una persona.
Debugging, StrictMode y React DevTools explica cómo detectar warnings, renders inesperados, problemas de keys, hydration y ciclos de effects.