Guía completa de accesibilidad para interacción con teclado y gestión del foco: orden, visibilidad, Roving TabIndex, atajos y patrones para modales/pestañas/menús
Resumen (puntos clave primero)
- Mantén el orden de Tab = orden lógico, y añade un enlace de salto en la primera pulsación de Tab → contenido principal, para que las personas tengan un “mapa”.
- Haz los indicadores de foco gruesos y con alto contraste; usa
:focus-visible. Cumple el requisito de contraste no textual de 3:1.- Para UIs compuestas (pestañas/menús/cuadrículas), usa Roving TabIndex: Tab = moverse entre regiones / Flechas = moverse dentro de una región.
- Los modales deben cubrir los cuatro esenciales: abrir, trampa de foco, Esc para cerrar, restaurar el foco. Silencia el fondo con
inert/aria-hidden.- Para atajos, decláralos y evita conflictos. Ofrece siempre operaciones alternativas (clic/teclado estándar) y haz que el esquema sea fácil de aprender.
- Incluye una prueba rápida de 5 minutos y muchos fragmentos listos para producción (HTML/ARIA/CSS/JS).
Audiencia (concreta): Ingenieros front-end, diseñadores UI/UX, PMs, QA, redactores técnicos y responsables de design systems
Nivel de accesibilidad: WCAG 2.1 AA (con elementos recomendados de 2.2/2.5 cuando aplique)
1. Introducción: la accesibilidad empieza con el “viaje del foco”
Las personas navegan con la tecla Tab por enlaces y botones, usan las flechas para elegir elementos y Enter/Espacio para activar. Más allá de los lectores de pantalla, la web se usa con gran variedad de métodos de entrada: trackpads, teclados externos, punteros bucales y conmutadores.
Foco invisible, orden de foco caótico o modales que no se pueden cerrar son estresantes para cualquiera. Cuando se alinean orden, visibilidad y consistencia operativa, la experiencia se vuelve mucho más tranquila.
Iremos de principios → patrones comunes → código → pruebas → operación, con plantillas que puedes usar de inmediato.
2. Principios básicos: Orden, Nombre, Percepción
2.1 Orden de Tab = orden lógico
- Orden del DOM = orden de lectura = orden de Tab como base. Si reordenas visualmente, prioriza el orden del código fuente y cambia solo el layout con CSS (p. ej.,
order). - Usa
tabindex="0"para hacer elementos focalizables. Evita valores positivos (1, 2, …): rompen el orden.
2.2 Nombre, Rol, Valor (NRV)
- Nombre: La etiqueta visible = nombre accesible (WCAG 2.5.3 “Label in Name”).
- Rol: Usa roles nativos o ARIA equivalentes (
button,tab,tabpanel,menuitem, etc.). - Valor: Expón estado con
aria-checked/aria-expanded/aria-selected/aria-valuenow, etc.
2.3 Hacer el foco “visible”
- Usa ≥ 3px de grosor, 2–3px de separación y garantiza contraste con el fondo.
- Muestra un indicador fuerte solo para teclado con
:focus-visible; mantén sutil el foco por ratón.
:focus-visible {
outline: 3px solid #FF9900;
outline-offset: 3px;
}
3. Enlaces de salto y “landmarks”: acorta el primer paso
- Pon un enlace de salto en la parte superior para saltar al contenido de
main. - Para múltiples regiones
nav/aside, añadearia-label(“Global”, “Pie”, etc.) para desambiguar.
<a class="skip" href="#content">Saltar al contenido principal</a>
<main id="content" tabindex="-1">…</main>
.skip{ position:absolute; left:-9999px; }
.skip:focus{ left:1rem; top:1rem; background:#111; color:#fff; padding:.5rem .75rem; border-radius:.5rem; }
4. Roving TabIndex: el predeterminado para componentes compuestos
Objetivo: Mantener corta la secuencia de Tab usando Tab para moverse entre componentes y flechas para moverse dentro de ellos.
4.1 Pestañas (Tabs)
- Estructura:
role="tablist"conbutton[role="tab"], másdiv[role="tabpanel"]emparejados. - Teclado: Flechas Izq./Der. para pestañas adyacentes,
Home/Endpara saltar,Espacio/Enterpara seleccionar.
<div role="tablist" aria-label="Facturación" id="tabs">
<button role="tab" aria-selected="true" aria-controls="p1" id="t1" tabindex="0">Extractos</button>
<button role="tab" aria-selected="false" aria-controls="p2" id="t2" tabindex="-1">Métodos de pago</button>
<button role="tab" aria-selected="false" aria-controls="p3" id="t3" tabindex="-1">Ajustes</button>
</div>
<section id="p1" role="tabpanel" aria-labelledby="t1">…</section>
<section id="p2" role="tabpanel" aria-labelledby="t2" hidden>…</section>
<section id="p3" role="tabpanel" aria-labelledby="t3" hidden>…</section>
const tabs = document.querySelectorAll('[role="tab"]');
const panels = document.querySelectorAll('[role="tabpanel"]');
function activateTab(tab){
tabs.forEach(t=>{ t.setAttribute('tabindex', t===tab? '0':'-1'); t.setAttribute('aria-selected', String(t===tab)); });
panels.forEach(p=> p.hidden = (p.id !== tab.getAttribute('aria-controls')));
tab.focus();
}
document.getElementById('tabs').addEventListener('keydown', e=>{
const list = [...tabs]; const i = list.indexOf(document.activeElement);
if(e.key==='ArrowRight') activateTab(list[(i+1)%list.length]);
if(e.key==='ArrowLeft') activateTab(list[(i-1+list.length)%list.length]);
if(e.key==='Home') activateTab(list[0]);
if(e.key==='End') activateTab(list[list.length-1]);
});
tabs.forEach(t=> t.addEventListener('click', ()=>activateTab(t)));
4.2 Menú (navegación tipo “disclosure”)
- El disparador es un
<button aria-expanded>. Al abrir, pontabindex="0"en el primer elemento. - Usa flechas para moverte;
Esccierra y devuelve el foco al disparador.
<div class="menu">
<button id="menuBtn" aria-expanded="false" aria-controls="m1">Cuenta</button>
<ul id="m1" role="menu" hidden>
<li><a role="menuitem" href="/perfil" tabindex="0">Perfil</a></li>
<li><a role="menuitem" href="/ajustes" tabindex="-1">Ajustes</a></li>
<li><button role="menuitem" tabindex="-1">Cerrar sesión</button></li>
</ul>
</div>
const btn = document.getElementById('menuBtn'), menu = document.getElementById('m1');
const items = ()=> [...menu.querySelectorAll('[role="menuitem"]')];
btn.addEventListener('click', ()=>{
const open = btn.getAttribute('aria-expanded')==='true';
btn.setAttribute('aria-expanded', String(!open));
menu.hidden = open;
if(!open) items()[0].focus();
});
menu.addEventListener('keydown', e=>{
const list = items(); const i = list.indexOf(document.activeElement);
if(e.key==='ArrowDown') list[(i+1)%list.length].focus();
if(e.key==='ArrowUp') list[(i-1+list.length)%list.length].focus();
if(e.key==='Escape'){ btn.click(); btn.focus(); }
});
Nota: Para la navegación del sitio,
ul/li/anativos con un botón para mostrar/ocultar suelen ser suficientes. Evita sobreusarrole="menu", que implica menús de estilo aplicación.
4.3 Grid (conjuntos de tarjetas, date pickers)
- Usa
role="grid"conrole="gridcell"para celdas. Implementa navegación por flechas por fila/columna y Enter/Espacio para seleccionar.
5. Diálogos modales: abrir, atrapar, restaurar, anunciar
5.1 Los cuatro esenciales
- Abrir: Mueve el foco al primer elemento accionable.
- Atrapar: Cicla Tab dentro del diálogo; vuelve el fondo
inert(alternativa:aria-hidden="true"). - Esc: Cierra con
Escape. - Restaurar: Devuelve el foco al elemento disparador al cerrar.
<button id="open">Abrir ajustes</button>
<dialog id="dlg" aria-labelledby="dlg-title">
<h2 id="dlg-title">Notificaciones</h2>
<label><input type="checkbox"> Recibir notificaciones por correo</label>
<div class="actions">
<button id="save">Guardar</button>
<button id="close">Cerrar</button>
</div>
</dialog>
const openBtn = document.getElementById('open');
const dlg = document.getElementById('dlg');
let lastFocus;
openBtn.addEventListener('click', ()=>{
lastFocus = document.activeElement;
dlg.showModal(); // Proporciona fallback para entornos sin <dialog>
dlg.querySelector('input,button,select,textarea,[tabindex="0"]')?.focus();
});
document.getElementById('close').addEventListener('click', ()=>{ dlg.close(); lastFocus?.focus(); });
dlg.addEventListener('cancel', (e)=>{ e.preventDefault(); dlg.close(); lastFocus?.focus(); }); // Esc
Si
<dialog>no está soportado, añadearia-hidden="true"al fondo, aplicabody { overflow: hidden; }, y crea una trampa de foco dentro del diálogo.
6. Diseño de foco visible: que nadie lo pierda
- Cumple el 3:1 de contraste no textual para anillos, iconos y contornos.
- Grosor: 2–3px; separación: 2–4px para evitar “sangrado”.
- Provee colores para tema claro/oscuro; refuerza con
prefers-contrast: more.
:root{ --focus:#FF9900; }
:focus-visible{ outline:3px solid var(--focus); outline-offset:3px; }
@media (prefers-contrast: more){
:focus-visible{ outline-width:4px; }
}
7. Diseño de atajos: rápidos, pero siempre aprendibles y sustituibles
- Ofrece siempre operaciones alternativas (botones/menús/teclado estándar).
- Unifica notación (Win/Linux = Ctrl, macOS = ⌘).
- Evita conflictos con combinaciones reservadas del navegador (Ctrl+L, Ctrl+T, ⌘+R, etc.).
- Muestra los atajos en ayuda, tooltips y menús.
<button aria-keyshortcuts="Ctrl+K" aria-label="Buscar (Ctrl+K)">Buscar</button>
window.addEventListener('keydown', (e)=>{
const mac = navigator.platform.includes('Mac');
if((mac? e.metaKey : e.ctrlKey) && e.key.toLowerCase()==='k'){
e.preventDefault();
document.getElementById('search').focus();
}
});
8. Patrones de orden de foco: recetario práctico
8.1 Lista de tarjetas → detalle
- Región (
role="region") → enlace del encabezado de tarjeta → acciones primarias internas → siguiente tarjeta. - Coloca enlaces “Más” al final. Repite encabezado → cuerpo → acciones de forma consistente.
8.2 Filtros y resultados
- Pon los filtros primero (y un enlace de salto para ir a resultados).
- Tras aplicar, mueve el foco a resultados y anuncia con
role="status".
8.3 En errores
- Mueve el foco a un resumen de errores (
role="alert"), con enlaces a los campos.
9. Alternativas para UIs centradas en ratón/táctil: arrastre, hover, pulsación larga
- Alternativas al arrastre: Proporciona botones/menús (arriba/abajo/izq./der.) para lograr el mismo resultado (WCAG 2.5.7).
- El contenido solo en hover debe aparecer también en foco (tooltips, mega-menús).
- La pulsación larga debe tener alternativas clic/Enter y tolerancias de tiempo relajadas.
<div role="listbox" aria-label="Orden">
<button aria-label="Subir uno">↑</button>
<button aria-label="Bajar uno">↓</button>
</div>
10. Regiones en vivo y anuncios de estado: silenciosos pero fiables
role="status": Actualizaciones no interruptivas como “Guardado”.role="alert": Críticas e interruptivas.aria-live="polite": Info que se actualiza automáticamente (conteos de búsqueda).
<div id="status" role="status" aria-atomic="true" class="sr-only"></div>
<script>
function saved(){ document.getElementById('status').textContent='Guardado correctamente.'; }
</script>
11. Errores comunes y cómo evitarlos
| Error | Qué ocurre | Cómo evitar |
|---|---|---|
Abuso de tabindex positivo |
Se rompe el orden; pérdida de foco | Rediseña el DOM; usa solo tabindex="0" |
| Quitar los anillos de foco | Las personas pierden dónde están | Mantenlos, con estilo vía :focus-visible |
| Fondo operable en modales | Doble foco; confusión | inert/aria-hidden + trampa + Esc + restaurar |
| Pestañas sin flechas | Mayor coste de aprendizaje | Roving TabIndex + Izq./Der. + Home/End |
| Solo hover para mostrar | Inaccesible con teclado | Botón conmutador + aria-expanded |
Ocultar diálogo solo con display:none |
Desfase con lectores de pantalla | Define atributos adecuados y movimientos de foco al alternar |
| Acciones solo con atajos | Inaprendible | Duplica con menús/botones/ayuda |
12. Prueba rápida de 5 minutos: el ritual mínimo por release
- Completa flujos clave usando solo Tab (abrir → operar → cerrar → siguiente).
- El primer Tab muestra un enlace de salto que aterriza en el contenido principal.
- Pestañas/menús: flechas mueven dentro, y
Home/Endfuncionan. - Modal: abre → foco inicial dentro → Esc → foco restaurado al disparador.
- El anillo de foco es siempre visible (tema claro/oscuro).
- Atajos: listados en ayuda, alternativas disponibles, sin conflictos con atajos del navegador.
- Con lector de pantalla (NVDA/VoiceOver), Nombre/Rol/Valor se anuncian correctamente.
13. Biblioteca de snippets (copiar/pegar)
13.1 Tokens de focus-visible
:root{ --focus-color:#FF9900; --focus-width:3px; --focus-offset:3px; }
:focus-visible{ outline: var(--focus-width) solid var(--focus-color); outline-offset: var(--focus-offset); }
13.2 Fallback de inert (simple)
function setInert(el, inert){
if('inert' in HTMLElement.prototype){ el.inert = inert; }
else { el.setAttribute('aria-hidden', String(inert)); }
}
13.3 Movimiento con flechas en una grid (esqueleto)
function moveInGrid(e, cols){
const items = [...document.querySelectorAll('[role="gridcell"]')];
const i = items.indexOf(document.activeElement);
const map = { ArrowRight: i+1, ArrowLeft: i-1, ArrowDown: i+cols, ArrowUp: i-cols };
if(map[e.key] !== undefined){
items[(map[e.key]+items.length)%items.length]?.focus();
e.preventDefault();
}
}
13.4 Recordar y restaurar el “punto de retorno”
let lastFocus;
function rememberFocus(){ lastFocus = document.activeElement; }
function restoreFocus(){ lastFocus?.focus(); }
14. Caso práctico: reducir churn corrigiendo solo el modelo de foco
Antes
- Se eliminó el anillo de foco.
- Mega-menú que solo abría con hover; inaccesible por teclado.
- El modal permitía interactuar con el fondo; Esc no hacía nada.
Después
- Se introdujo un contorno grueso con
:focus-visible. - Se rehízo el mega-menú como botón de “disclosure” + navegación con flechas.
- El modal ahora atrae el foco + Esc cierra + foco restaurado, con
inerten el fondo.
Resultado: Finalización de formularios +14%, reportes de “me pierdo” con lector −76%, visitas al help center −28%.
15. Despliegue organizacional: codifica “reglas de interacción” en tu design system
- Añade una tabla de interacción con teclado (Tab/Flechas/Home/End/Esc/Enter/Espacio) a cada especificación de componente.
- Define tokens de foco (color/grosor/separación) globalmente.
- Extiende tu Definition of Done con: enlace de salto, orden de foco, cuatro esenciales del modal, Roving TabIndex y alternativas de atajos.
- Añade checks automatizados + prueba de 5 minutos a los PRs para minimizar regresiones.
16. Audiencia y beneficios concretos
- Ingeniería front-end: Estandarizando Roving TabIndex y control de modales, la implementación se estabiliza y bajan las regresiones.
- Diseño UI/UX: Un lenguaje de foco visible equilibra accesibilidad y marca.
- PMs/Directores: Los criterios de aceptación AA se vuelven explícitos: puertas de calidad claras.
- QA/CS: Se estandarizan perspectivas de prueba; bajan los tickets de “me perdí”.
- Usuarios: Con teclado/AT/conmutador, pueden terminar tareas con confianza.
17. Instantánea de conformidad (lo que entrega este artículo)
- WCAG 2.1 AA (puntos principales)
- 2.1.1 Teclado: Todas las funciones operables con teclado
- 2.4.3 Orden del foco: Orden lógico preservado
- 2.4.7 Foco visible: Anillo claro por grosor/color/separación
- 2.4.1 Omitir bloques: Enlace de salto
- 4.1.2 Nombre, Rol, Valor: Estado/relaciones vía
aria-* - 1.4.11 Contraste no textual: 3:1 para anillos de foco y límites
- 2.2.2 Pausar, Detener, Ocultar: Apertura/cierre controlados en modales/menús
- WCAG 2.2 (recomendado)
- 2.5.7 Movimientos de arrastre: Alternativas al arrastre
- 2.5.8 Tamaño objetivo (mínimo): Tamaño mínimo del objetivo (≈ 44×44 px)
18. Cierre: que el foco sea una “luz amable”
- Orden de Tab = orden lógico, el primer Tab va al contenido principal.
- Mantén el anillo de foco grueso y de alto contraste; usa
:focus-visiblecon criterio. - Usa Roving TabIndex para unificar reglas dentro de UIs compuestas.
- Implementa siempre los cuatro del modal (abrir, trampa, Esc, restaurar).
- Atajos: decláralos, ofrece alternativas, evita conflictos—velocidad amable.
- Haz crecer la calidad con una prueba rápida de 5 minutos y reglas de design system.
Cuando el viaje del foco está bien iluminado, cualquier método de entrada conduce a la misma comprensión y resultado. Que tu UI sea una guía tranquila. Estoy aquí para ayudar, de corazón.
