Addaw, ir a inicio Donar

Lección 3 de 10 · 15 min de lectura · Revisado el 26/10/2026

Widgets compuestos: roving tabindex y aria-activedescendant

Barras de herramientas, pestañas, menús, listas de opciones, árboles y rejillas parecen componentes muy distintos, pero comparten un mismo contrato de teclado: el componente entero es una sola parada de Tab y, una vez dentro, se navega con las flechas. La APG (se abre en una ventana nueva) llama a estos controles widgets compuestos (componentes formados por varios elementos interactivos que se comportan como uno) y describe dos técnicas para gestionar su foco: roving tabindex y aria-activedescendant. Tras la lista de reconstrucción de un control aislado que viste en «Botones, enlaces y controles personalizados», aquí aprendes la mecánica de los controles agrupados, que usarás en el resto del curso.

Por qué una sola parada de tabulación

Si cada uno de los doce botones de formato de un editor es una parada de Tab, hay que pulsar doce veces para llegar al área de texto. Los sistemas operativos de escritorio lo resolvieron hace décadas con una convención que las personas usuarias de teclado y de lector de pantalla conocen bien: Tab salta entre componentes; las flechas se mueven dentro de cada uno. El rol del contenedor («barra de herramientas», «lista de pestañas», «árbol») avisa al lector de pantalla y a la persona de que esa convención está en vigor.

El HTML nativo ya lo hace con los botones de opción (<input type="radio">) que comparten name: una sola parada y flechas para cambiar. Un widget compuesto con ARIA reproduce a mano ese comportamiento. Los criterios implicados son 2.1.1 Teclado (todo debe poder hacerse con teclado), 2.4.3 Orden del foco (el recorrido debe ser lógico) y 4.1.2 Nombre, función, valor (rol, nombre y estado expuestos).

Patrón APGRol del contenedorFlechas que usaTécnica habitual
Barra de herramientastoolbarIzquierda y derechaRoving tabindex
PestañastablistIzquierda y derecha (arriba y abajo si es vertical)Roving tabindex
Menú y barra de menúsmenu, menubarArriba y abajo; izquierda y derecha en la barraCualquiera de las dos
Lista de opcioneslistboxArriba y abajoCualquiera de las dos
Comboboxcombobox con listboxArriba y abajoaria-activedescendant
Árbol y rejillatree, grid, treegridLas cuatroCualquiera de las dos

Técnica 1: roving tabindex

En el roving tabindex («tabindex itinerante») el foco del DOM se mueve de verdad de un elemento a otro. Solo un elemento del grupo tiene tabindex="0"; todos los demás tienen tabindex="-1", de modo que siguen siendo enfocables por script pero el navegador los salta al tabular. Cuando la persona pulsa una flecha, tu código pone -1 en el elemento actual, 0 en el de destino y llama a focus() sobre este último.

<div role="toolbar" aria-label="Formato del texto">
  <button type="button" aria-pressed="false" tabindex="0">Negrita</button>
  <button type="button" aria-pressed="false" tabindex="-1">Cursiva</button>
  <button type="button" aria-pressed="false" tabindex="-1">Subrayado</button>
</div>
const barra = document.querySelector('[role="toolbar"]');
const botones = [...barra.querySelectorAll('button')];

function mover(destino) {
  botones.forEach((b) => b.tabIndex = (b === destino) ? 0 : -1);
  destino.focus();
}

barra.addEventListener('keydown', (e) => {
  const i = botones.indexOf(document.activeElement);
  if (i === -1) return;
  const n = botones.length;
  const destinos = {
    ArrowRight: botones[(i + 1) % n],
    ArrowLeft: botones[(i - 1 + n) % n],
    Home: botones[0],
    End: botones[n - 1]
  };
  if (!destinos[e.key]) return;
  e.preventDefault();
  mover(destinos[e.key]);
});

barra.addEventListener('click', (e) => {
  const b = e.target.closest('button');
  if (b) mover(b);
});

Tres detalles importan. Primero, el tabindex="0" se queda en el último elemento visitado: si la persona sale con Tab y vuelve con Mayús+Tab, aterriza donde lo dejó, que es lo que la APG recomienda en la mayoría de los patrones. Segundo, el manejador de clic sincroniza el estado cuando se usa el ratón; sin él, la parada de Tab se queda en un botón que la persona ya no recuerda. Tercero, preventDefault() evita que las flechas desplacen la página mientras se navega por la barra.

La gran ventaja: el navegador sigue desplazando el elemento enfocado, aplicando :focus-visible y enviando a los lectores de pantalla un evento de foco normal.

Técnica 2: aria-activedescendant

Con aria-activedescendant el foco del DOM no se mueve: se queda en el contenedor (o en el campo de texto, en un combobox), y un atributo indica cuál de sus elementos está activo. El lector de pantalla anuncia ese elemento como si tuviera el foco. Tu código, al pulsar una flecha, solo cambia el valor del atributo.

<p id="prov-t">Provincia de residencia</p>
<ul role="listbox" tabindex="0" aria-labelledby="prov-t"
    aria-activedescendant="op-murcia">
  <li role="option" id="op-malaga" aria-selected="false">Málaga</li>
  <li role="option" id="op-murcia" aria-selected="true" class="activa">Murcia</li>
  <li role="option" id="op-navarra" aria-selected="false">Navarra</li>
</ul>

A cambio, asumes tres responsabilidades que el navegador ya no cubre:

  1. El indicador visual. :focus-visible se aplica al contenedor, no a la opción activa. Tienes que pintar tú la opción activa (con una clase o con el selector [aria-selected="true"]) con un indicador que cumpla 2.4.7 Foco visible y tenga un contraste de al menos 3:1 (1.4.11 Contraste no textual).
  2. El desplazamiento. Si la lista tiene barra de desplazamiento, la opción activa puede quedar fuera de la vista. Llama a scrollIntoView({ block: 'nearest' }) sobre ella en cada cambio, o incumplirás 2.4.11 Foco no oculto (mínimo).
  3. La referencia válida. El id debe existir, el elemento debe ser descendiente del que tiene el foco (o pertenecerle mediante aria-owns) y no puede estar oculto. En el combobox, la especificación WAI-ARIA 1.2 (se abre en una ventana nueva) contempla expresamente que apunte a una opción de la lista emergente que el campo controla.

Error frecuente

<ul role="listbox" tabindex="0" aria-activedescendant="opcion-3">
  <li role="option" tabindex="0">Málaga</li>
  <li role="option" tabindex="0">Murcia</li>
  <li role="option" tabindex="0">Navarra</li>
</ul>

Tres fallos a la vez: las opciones no tienen id, así que aria-activedescendant apunta a un elemento que no existe y el lector no anuncia nada al pulsar las flechas; cada opción tiene tabindex="0", de modo que la lista se convierte en cuatro paradas de Tab; y se mezclan las dos técnicas. Elige una y aplícala entera.

Cuál elegir

Ninguna es «más accesible» que la otra; cada una encaja en situaciones distintas. El roving tabindex es la opción por defecto cuando los elementos son controles reales (botones, enlaces, pestañas, elementos de menú), porque el navegador gestiona foco, desplazamiento e indicador. aria-activedescendant es imprescindible cuando el foco tiene que permanecer en un campo de texto mientras se recorre una lista, como en el combobox que verás en la lección «Combobox, listbox y autocompletado»: si movieras el foco a la opción, la persona dejaría de poder escribir. También es útil en listas largas que se renderizan a medida que se desplazan. Su soporte en lectores de pantalla ha mejorado, pero pruébalo en las combinaciones de tu público, sobre todo en móvil.

Infografía · Comparativa

Roving tabindex frente a aria-activedescendant

Dos formas de gestionar el foco dentro de un widget compuesto

Roving tabindex

  • El foco del DOM se mueve a cada elemento con focus()
  • Solo el elemento activo tiene tabindex=0; el resto, -1
  • El navegador desplaza la vista y aplica :focus-visible
  • Por defecto: botones, pestañas y elementos de menú

aria-activedescendant

  • El foco del DOM se queda en el contenedor o en el campo
  • El atributo apunta al id del elemento activo
  • Tú pintas el indicador y desplazas la lista
  • Imprescindible en combobox: se puede seguir escribiendo
ADDAW · Cursos de accesibilidad
Leer la infografía como texto

Comparativa en dos columnas de las dos técnicas de la APG para gestionar el foco dentro de un widget compuesto. Columna izquierda, roving tabindex: el foco del DOM se mueve a cada elemento con focus(); solo el elemento activo tiene tabindex 0 y el resto tiene -1; el navegador desplaza la vista y aplica :focus-visible; es la opción por defecto para botones, pestañas y elementos de menú. Columna derecha, aria-activedescendant: el foco del DOM se queda en el contenedor o en el campo de texto; el atributo apunta al id del elemento activo; quien desarrolla pinta el indicador visual y desplaza la lista; es imprescindible en el combobox, porque permite seguir escribiendo mientras se recorren las sugerencias.

Detalles que marcan la diferencia

  • Ciclo o tope. En pestañas y menús es habitual pasar del último al primero; en listas, árboles y rejillas, detenerse en el extremo. Una regla por patrón, en todo el producto.
  • Inicio, Fin y escritura anticipada. Inicio y Fin llevan al primer y al último elemento. La escritura anticipada (type-ahead: teclear una letra para saltar al primer elemento que empieza por ella) es opcional en muchos patrones, pero la APG la recomienda en todas las listas de opciones, en especial en las de más de siete.
  • Elementos deshabilitados. Fuera de los widgets compuestos, lo esperable es que un control deshabilitado no reciba el foco. Dentro de un menú o una lista, la APG señala que es habitual que siga siendo alcanzable con flechas (con aria-disabled="true"), para que la persona sepa que la opción existe aunque no esté disponible.
  • Elementos que desaparecen. Si el elemento activo se elimina (borrar una etiqueta de una lista de filtros), mueve el foco al vecino más próximo. Un foco que cae en <body> obliga a empezar a tabular desde el principio de la página.
  • Orientación. Una lista de pestañas vertical se declara con aria-orientation="vertical" y responde a las flechas arriba y abajo.
  • Modo de los lectores de pantalla. En Windows, NVDA y JAWS pasan normalmente a modo foco (o modo formulario) al entrar en la mayoría de estos roles, y a partir de ahí las flechas llegan a tu código. Por eso un rol de widget compuesto sin el teclado implementado deja a la persona atrapada: el lector ha dejado de usar las flechas para leer y tu componente tampoco las usa.

Punto clave

Un widget compuesto es una promesa en dos partes: el rol anuncia «aquí se navega con flechas» y tu JavaScript tiene que cumplirlo. Si no vas a implementar el teclado completo, no pongas el rol; un grupo de botones normales, cada uno con su parada de Tab, es menos eficiente pero funciona.

Cómo comprobarlo

  • Recorre la página con Tab: cada widget compuesto debe ser una única parada, y al salir y volver debes aterrizar en el último elemento visitado (o en el seleccionado).
  • Dentro del componente, prueba todas las flechas, Inicio y Fin, y comprueba que el indicador visual acompaña siempre al elemento activo.
  • En las herramientas de desarrollo, observa cómo cambia tabindex o aria-activedescendant y verifica que el id referenciado existe y tiene nombre.
  • Con un lector de pantalla, cada flecha debe producir un anuncio con el nombre del elemento, su rol y su posición («Cursiva, botón, no pulsado»).
  • En listas con desplazamiento, baja hasta el final con flechas: la opción activa nunca debe quedar oculta.

Pruébalo tú

Abre el ejemplo de barra de herramientas de la APG. Entra con Tab, muévete con las flechas, sal con Tab y vuelve con Mayús+Tab: fíjate en dónde aterrizas. Después abre el inspector de elementos y observa cómo cambia el atributo tabindex de los botones a cada pulsación.

Para profundizar: APG: desarrollar la interfaz de teclado.

Resumen

  • Un widget compuesto es una sola parada de Tab; dentro se navega con flechas, Inicio y Fin.
  • Roving tabindex: solo el elemento activo tiene tabindex="0", el resto -1, y el foco del DOM se mueve con focus().
  • aria-activedescendant: el foco se queda en el contenedor o el campo; tú pintas el indicador, desplazas la lista y mantienes un id válido.
  • Roving tabindex por defecto; aria-activedescendant cuando hay que seguir escribiendo, como en el combobox.
  • Si no implementas el teclado completo, no uses el rol del widget compuesto.

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.