Addaw, ir a inicio Donar

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.

ComponenteFlechasTeclas adicionales habituales
Barra de herramientas (toolbar)Izquierda y derechaInicio, Fin
Lista de opciones (listbox)Arriba y abajoInicio, 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 celdaInicio/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 padreInicio, 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:

  1. El indicador visual. :focus no 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).
  2. La referencia válida. El id tiene que existir en el DOM y pertenecer al componente: un descendiente del elemento con foco o, en un combobox, la lista que este controla con aria-controls. Si la opción se elimina (listas virtualizadas que solo pintan lo visible), el lector se queda sin nada que anunciar.
  3. El desplazamiento. Si la lista tiene barra de desplazamiento, la opción activa debe quedar visible; de ahí el scrollIntoView.

¿Cuál elegir?

AspectoRoving tabindexaria-activedescendant
Dónde está el foco del navegadorEn el elemento activoEn el contenedor o en el campo de texto
Indicador visualLo da :focus-visibleLo dibujas tú con una clase
CompatibilidadLa más amplia: es foco realBuena en general, con más diferencias entre combinaciones de lector y navegador
Usos típicosBarras de herramientas, pestañas, rejillas, árboles, menúsCombobox con campo de texto, listas en las que el foco no debe moverse
Riesgo principalOlvidar actualizar los tabindexReferencias 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 el id existe 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, y focus() en cada movimiento.
  • aria-activedescendant: el foco se queda en el contenedor o en el campo y el atributo apunta al id de 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.