
Accesibilidad para Desarrolladores que No Saben por Dónde Empezar
Intenta navegar tu propia aplicación usando solo la tecla Tab, sin mouse. Si te atascas, pierdes de vista dónde está el foco, o simplemente no puedes alcanzar un botón, eso no es un caso hipotético raro — así es como una parte significativa de usuarios reales, y todo usuario de lector de pantalla, experimenta tu aplicación todos los días.
La accesibilidad suele quedar pospuesta para "después" porque suena como una especialidad. No lo es. La mayoría de los ajustes que importan son pequeños, mecánicos, y toman minutos una vez que sabes qué buscar.
Por qué esto sigue quedando de lado
El trabajo de accesibilidad tiende a perder prioridad por una razón predecible: es invisible para quienes toman las decisiones de roadmap. Nadie en el equipo usa un lector de pantalla, así que nadie nota ese <div onClick> que un usuario de teclado no puede alcanzar. El bug no aparece en una demo. Aparece cuando un usuario real no puede completar el checkout y simplemente se va.
La buena noticia: no necesitas convertirte en especialista en accesibilidad para resolver la mayoría de los problemas reales. Cuatro categorías cubren casi todo lo que realmente rompe aplicaciones para usuarios reales:
- HTML semántico — usar el elemento correcto para cada función
- Navegación por teclado — todo alcanzable y operable sin mouse
- Gestión del foco — el usuario siempre sabe dónde está
- Color y contraste — contenido legible sin depender solo del color
HTML semántico: el ajuste que no cuesta nada
Este es el cambio de mayor impacto que puedes hacer, porque muchas veces es solo cambiar una etiqueta por otra.
// ❌ Un div estilizado para parecer un botón no tiene ninguno de los comportamientos nativos
<div className="button" onClick={handleSubmit}>
Enviar
</div>
// ✅ Un botón de verdad recibe foco de teclado, se dispara con Enter/Espacio, y se anuncia correctamente
<button className="button" onClick={handleSubmit}>
Enviar
</button>
Un <div> con onClick se ve idéntico visualmente, pero no recibe foco de teclado, no responde a Enter ni Espacio, y no se anuncia ningún rol a los lectores de pantalla. Tendrías que reimplementar todo eso manualmente con tabIndex, onKeyDown y role="button" — o simplemente usar <button> y obtenerlo gratis.
La misma lógica aplica en todas partes:
| En lugar de | Usa |
|---|---|
<div onClick> | <button> |
<div> para una lista de elementos | <ul> / <li> |
<span> estilizado como título | <h1>–<h6> |
<div> envolviendo campos de formulario | <label> con htmlFor |
Navegación por teclado: la prueba que puedes correr ahora mismo
Cada elemento interactivo de tu página debería ser alcanzable y usable solo con Tab, Enter y Espacio. Pruébalo:
// ❌ Dropdown personalizado, solo funciona con mouse — Tab pasa de largo
function Dropdown({ options }: { options: string[] }) {
const [open, setOpen] = useState(false)
return (
<div onClick={() => setOpen(!open)}>
Selecciona una opción
{open &&
options.map((o) => (
<div key={o} onClick={() => select(o)}>
{o}
</div>
))}
</div>
)
}
// ✅ Disparador enfocable, opciones operables por teclado
function Dropdown({ options }: { options: string[] }) {
const [open, setOpen] = useState(false)
return (
<div>
<button aria-expanded={open} aria-haspopup="listbox" onClick={() => setOpen(!open)}>
Selecciona una opción
</button>
{open && (
<ul role="listbox">
{options.map((o) => (
<li
key={o}
role="option"
tabIndex={0}
onClick={() => select(o)}
onKeyDown={(e) => e.key === 'Enter' && select(o)}
>
{o}
</li>
))}
</ul>
)}
</div>
)
}
aria-expanded y aria-haspopup le dicen al lector de pantalla qué hace el botón incluso antes de activarlo. Ese contexto importa tanto como que la interacción simplemente funcione.
Gestión del foco: ¿dónde queda el usuario?
Las aplicaciones de una sola página rompen un comportamiento por defecto del navegador en el que los usuarios confían: cuando el contenido cambia, el foco debería moverse a algún lugar sensato. Navegar entre páginas sin gestionar el foco deja al usuario de lector de pantalla atascado, escuchando el anuncio de lo que sea que estaba enfocado antes de la navegación.
// ✅ Mueve el foco al título de la nueva página cuando cambia la ruta
function PageLayout({ title, children }: { title: string; children: React.ReactNode }) {
const headingRef = useRef<HTMLHeadingElement>(null)
useEffect(() => {
headingRef.current?.focus()
}, [title])
return (
<>
<h1 ref={headingRef} tabIndex={-1}>
{title}
</h1>
{children}
</>
)
}
tabIndex={-1} hace que el título sea enfocable programáticamente sin agregarlo al orden normal del Tab — exactamente lo que quieres en este caso.
Los modales necesitan el mismo cuidado: atrapar el foco dentro mientras están abiertos, y devolverlo al elemento que los activó cuando se cierran. Librerías como Radix UI o Headless UI resuelven esto correctamente de fábrica — vale la pena usarlas en lugar de construir modales desde cero.
Color y contraste
Nunca dependas solo del color para transmitir información.
// ❌ El texto rojo es la única señal de que este campo tiene un error
<input className="border-red-500" />
<span className="text-red-500">Email inválido</span>
// ✅ El ícono y el texto transmiten la misma información que los usuarios daltónicos no obtienen solo del rojo
<input aria-invalid="true" aria-describedby="email-error" className="border-red-500" />
<span id="email-error" className="text-red-500 flex items-center gap-1">
<AlertIcon aria-hidden="true" />
Dirección de email inválida
</span>
aria-invalid y aria-describedby también conectan el campo con su mensaje de error para los lectores de pantalla, algo que el color por sí solo nunca hizo.
Pasa tu paleta de colores por un verificador de contraste. WCAG AA exige una proporción de 4.5:1 para texto normal — texto gris claro sobre fondo blanco casi nunca pasa esto, sin importar qué tan bien se vea en tu monitor.
Errores comunes
- ❌ Agregar
aria-labelpara arreglarlo todo. Los atributos ARIA parchan semántica faltante — no reemplazan usar el elemento HTML correcto en primer lugar. Recurre a HTML semántico primero, ARIA después. - ❌ Probar solo con mouse. Si nunca desconectas el mouse e intentas navegar solo con Tab, nunca vas a detectar las trampas de teclado que tú mismo construiste.
- ❌ Ocultar los contornos de foco con
outline: none. Esta es una de las regresiones de accesibilidad más comunes — quitar el anillo de foco vuelve inutilizable la navegación por teclado porque los usuarios pierden toda la retroalimentación visual de dónde están. - ❌ Tratar la accesibilidad como una tarea de limpieza post-lanzamiento. Adaptar HTML semántico en una librería de componentes construida enteramente sobre
<div>s toma mucho más tiempo que construirla bien desde el principio.
Buenas prácticas
- Corre axe DevTools o la auditoría de accesibilidad de Lighthouse en cada PR que toque UI. No va a detectar todo, pero atrapa los problemas mecánicos al instante.
- Mantén los contornos de foco visibles, y estilízalos si el predeterminado se ve mal — no los quites.
- Prueba tus tres flujos principales solo con teclado, una vez por sprint. Checkout, registro y búsqueda suelen ser los flujos más críticos para acertar.
- Usa un lector de pantalla real de vez en cuando — VoiceOver en Mac (Cmd+F5) viene integrado y es gratis. Diez minutos de uso real enseñan más que cualquier artículo.
Qué hacer ahora
Desconecta el mouse ahora mismo e intenta completar el flujo principal de tu aplicación usando solo el teclado. Cada lugar donde te atasques es un bug real, no un "estaría bien tenerlo". Arregla esos primero — normalmente es un <button> faltante, un trap de foco faltante, o un outline: none que no debería estar ahí.
Ese único ejercicio va a revelar más problemas reales de accesibilidad en diez minutos de los que la mayoría de las auditorías detectan en una semana.