Lección 5 de 10 · 14 min de lectura · Revisado el 26/10/2026
Componentes compuestos: navegación con flechas, roving tabindex y aria-activedescendant
Hasta ahora hemos tratado cada elemento interactivo como una parada de Tab. Funciona bien en una página de contenido, pero no en una barra de herramientas de 12 botones, un calendario de 31 días o una lista de 200 municipios: si cada elemento es una parada, atravesar el componente cuesta docenas de pulsaciones, y quien usa un conmutador tarda minutos. Las aplicaciones de escritorio resolvieron esto hace décadas con una convención que la guía de prácticas de autoría de ARIA (APG (se abre en una ventana nueva)) del W3C recoge para la web: Tab entra y sale del componente, y las flechas se mueven dentro. Esta lección explica esa convención y las dos técnicas para implementarla: roving tabindex y aria-activedescendant. Los patrones concretos (menús, pestañas, carruseles) los verás en el curso de componentes interactivos; aquí aprenderás el mecanismo que todos comparten.
La convención: Tab entre componentes, flechas dentro
Un componente compuesto es un control formado por varios elementos que se comportan como una unidad: una barra de herramientas, una lista de opciones, un grupo de botones de opción, una lista de pestañas, una rejilla de datos o un árbol. Las reglas generales son:
- Todo el componente es una sola parada en la secuencia de Tab.
- Dentro, las flechas mueven el foco según la orientación del componente; Inicio y Fin van al primer y al último elemento.
- En listas y árboles largos, escribir una letra lleva al siguiente elemento que empieza por ella. Es un atajo de una sola tecla, pero cumple 2.1.4 Atajos de teclado porque solo está activo cuando el componente tiene el foco, como vimos en la primera lección.
- Al salir con Tab y volver, el foco debería caer en el último elemento activo o en el elemento seleccionado, no siempre en el primero.
Ya conoces un componente compuesto nativo: el grupo de botones de opción (input type="radio" con el mismo name). Tab entra en la opción marcada, o en la primera si no hay ninguna; las flechas cambian la selección; otra pulsación de Tab sale del grupo. El navegador lo hace solo. Los componentes personalizados tienen que reproducir ese comportamiento con código.
| Componente | Flechas | Teclas adicionales habituales |
|---|---|---|
Barra de herramientas (toolbar) | Izquierda y derecha | Inicio, Fin |
Lista de opciones (listbox) | Arriba y abajo | Inicio, Fin, escribir para saltar |
Grupo de opción (radiogroup) | Las cuatro; además marcan la opción | — |
Rejilla o calendario (grid) | Las cuatro, celda a celda | Inicio/Fin de fila; en un calendario, Re Pág/Av Pág cambian de mes |
Árbol (tree) | Arriba y abajo; derecha expande, izquierda contrae o sube al nodo padre | Inicio, Fin, escribir para saltar |
Técnica 1: roving tabindex
En el roving tabindex («tabindex itinerante») el foco del navegador se mueve de verdad entre los elementos. Solo uno de ellos tiene tabindex="0" y es el que recibe el foco al entrar con Tab; los demás tienen tabindex="-1", de modo que Tab no se detiene en ellos. Al pulsar una flecha, el código pasa el 0 al elemento de destino, pone -1 en el anterior y llama a focus().
<div role="toolbar" aria-label="Formato del texto" aria-controls="editor">
<button type="button" tabindex="0" aria-pressed="false">Negrita</button>
<button type="button" tabindex="-1" aria-pressed="false">Cursiva</button>
<button type="button" tabindex="-1" aria-pressed="false">Subrayado</button>
<button type="button" tabindex="-1">Insertar enlace</button>
</div>
const barra = document.querySelector('[role="toolbar"]');
const botones = [...barra.querySelectorAll('button')];
barra.addEventListener('keydown', (e) => {
const actual = botones.indexOf(document.activeElement);
let destino = null;
if (e.key === 'ArrowRight') destino = (actual + 1) % botones.length;
if (e.key === 'ArrowLeft') destino = (actual - 1 + botones.length) % botones.length;
if (e.key === 'Home') destino = 0;
if (e.key === 'End') destino = botones.length - 1;
if (destino === null) return; // Tab, Intro y Espacio siguen su curso
e.preventDefault(); // que las flechas no desplacen la página
botones[actual].tabIndex = -1;
botones[destino].tabIndex = 0;
botones[destino].focus();
});
Como el foco es real, todo lo demás funciona sin esfuerzo: :focus-visible dibuja el indicador, el lector de pantalla anuncia el botón enfocado, el ampliador de pantalla lo sigue y, al salir y volver con Tab, el navegador regresa al último botón activo porque es el único con tabindex="0". Por eso es la técnica recomendada por defecto.
En aplicaciones con React, Vue o Angular, guarda en el estado del componente qué elemento es el activo. Si el componente se vuelve a renderizar y todos los elementos recuperan el tabindex inicial, el foco vuelve siempre al primero o, peor, ninguno queda con tabindex="0" y el componente desaparece de la secuencia.
Técnica 2: aria-activedescendant
A veces el foco del navegador tiene que quedarse en un sitio. El caso típico es un combobox (un campo de texto con lista de sugerencias): si al bajar con la flecha el foco pasara a la opción, la persona ya no podría seguir escribiendo. La solución es aria-activedescendant: el foco permanece en el campo, y este atributo apunta al id de la opción activa. El lector de pantalla anuncia esa opción como si tuviera el foco.
<label for="municipio">Municipio</label>
<input id="municipio" type="text" role="combobox"
aria-autocomplete="list" aria-expanded="true"
aria-controls="lista-municipios" aria-activedescendant="mun-2">
<ul id="lista-municipios" role="listbox" aria-label="Municipios">
<li id="mun-1" role="option">Lorca</li>
<li id="mun-2" role="option" aria-selected="true" class="activa">Lorquí</li>
<li id="mun-3" role="option">Los Alcázares</li>
</ul>
function activar(opcion) {
const anterior = lista.querySelector('.activa');
if (anterior) {
anterior.classList.remove('activa');
anterior.removeAttribute('aria-selected');
}
opcion.classList.add('activa');
opcion.setAttribute('aria-selected', 'true');
campo.setAttribute('aria-activedescendant', opcion.id);
opcion.scrollIntoView({ block: 'nearest' }); // que la opción quede a la vista
}
Con esta técnica asumes tres responsabilidades que el navegador ya no cubre:
- El indicador visual.
:focusno se aplica a la opción, porque el foco está en el campo. Tienes que dibujar el resaltado con una clase, y ese resaltado debe cumplir 2.4.7 Foco visible y tener contraste suficiente (1.4.11 Contraste no textual). - La referencia válida. El
idtiene que existir en el DOM y pertenecer al componente: un descendiente del elemento con foco o, en un combobox, la lista que este controla conaria-controls. Si la opción se elimina (listas virtualizadas que solo pintan lo visible), el lector se queda sin nada que anunciar. - El desplazamiento. Si la lista tiene barra de desplazamiento, la opción activa debe quedar visible; de ahí el
scrollIntoView.
¿Cuál elegir?
| Aspecto | Roving tabindex | aria-activedescendant |
|---|---|---|
| Dónde está el foco del navegador | En el elemento activo | En el contenedor o en el campo de texto |
| Indicador visual | Lo da :focus-visible | Lo dibujas tú con una clase |
| Compatibilidad | La más amplia: es foco real | Buena en general, con más diferencias entre combinaciones de lector y navegador |
| Usos típicos | Barras de herramientas, pestañas, rejillas, árboles, menús | Combobox con campo de texto, listas en las que el foco no debe moverse |
| Riesgo principal | Olvidar actualizar los tabindex | Referencias a id inexistentes y falta de indicador |
Error frecuente
Tres variantes del mismo problema:
- Poner
tabindex="0"en cada elemento del componente: un calendario con 31 paradas o una barra con 12. Técnicamente operable, pero agotador. - Implementar las flechas sin
e.preventDefault(): el foco cambia de opción y, a la vez, la página se desplaza. - Convertir el menú de navegación del sitio, que es una simple lista de enlaces, en un componente con
role="menu"y flechas. Quien llega espera tabular por enlaces; el lector anuncia un menú de aplicación y cambia las reglas. Para la navegación principal basta con enlaces y botones de despliegue.
Pruébalo tú
Abre la página Developing a Keyboard Interface de la APG y, desde ella, el ejemplo del patrón Toolbar. Entra con Tab, muévete con las flechas, sal con Tab y vuelve con Mayús+Tab. Cuenta las paradas de Tab que necesitas para atravesar la barra y comprueba en qué botón caes al volver.
Cómo comprobarlo
- Cuenta las paradas: cada componente compuesto debe ser una sola parada de Tab.
- Dentro, prueba todas las flechas, Inicio y Fin, y comprueba que la página no se desplaza a la vez.
- Sal y vuelve: el foco debe caer en el elemento activo o en el seleccionado.
- Con
aria-activedescendant, verifica en el inspector que el atributo cambia en cada pulsación, que elidexiste y que el resaltado se ve. - Comprueba con un lector de pantalla que cada movimiento se anuncia (lo trabajarás a fondo en el nivel de auditoría).
Punto clave
Un componente compuesto es una parada de Tab y se recorre con flechas. Usa roving tabindex siempre que puedas, porque el foco es real; reserva aria-activedescendant para cuando el foco deba quedarse en un campo, y entonces dibuja tú el indicador.
Resumen
- Tab entra y sale del componente; las flechas, Inicio y Fin se mueven dentro; escribir una letra puede saltar a un elemento.
- Roving tabindex: un único elemento con
tabindex="0", el resto con-1, yfocus()en cada movimiento. aria-activedescendant: el foco se queda en el contenedor o en el campo y el atributo apunta alidde la opción activa; el indicador visual y el desplazamiento son cosa tuya.- Recuerda la última posición al salir y volver, y usa
preventDefault()en las flechas que gestionas. - No conviertas en componente compuesto lo que es una lista de enlaces.
Comprueba lo que has aprendido
Esta lección se puede leer libremente. Para hacer su test de 3 preguntas, los ejercicios prácticos con páginas reales, guardar tu avance y optar al examen y al certificado, matricúlate en el curso.