El atributo focusgroup

Si alguna vez armaste un toolbar, un tablist o un menú accesible, ya escribiste roving tabindex. Es ese patrón donde el grupo entero tiene un solo tab stop: el ítem "activo" tiene tabindex="0", el resto tabindex="-1", y vos manejás las flechas a mano para mover el foco entre ellos. La APG lo documenta bien, pero documentarlo no es implementarlo.
El problema no es escribirlo una vez: es que cada design system lo reimplementa. FocusZone en Fluent, Tabster, los useRovingTabIndex de turno, el composable de Vue, la directiva de Angular. Todos hacen lo mismo con bugs distintos. El clásico es olvidarse de Home y End. Después está RTL, que casi nadie maneja bien. Y los ítems que se deshabilitan en el medio, que dejan el foco colgado en un botón que ya no existe.
Desde Chrome 150 (estable el 30 de junio de 2026) eso es un atributo HTML.
Lo que veníamos escribiendo a mano
Un toolbar mínimo, sin librerías, se ve más o menos así:
<div role="toolbar" aria-label="Formato" id="fmt">
<button type="button" tabindex="0">Bold</button>
<button type="button" tabindex="-1">Italic</button>
<button type="button" tabindex="-1">Underline</button>
</div>
const toolbar = document.getElementById('fmt');
toolbar.addEventListener('keydown', event => {
const items = [...toolbar.querySelectorAll('button:not([disabled])')];
const current = items.indexOf(document.activeElement);
if (current === -1) return;
let next = current;
switch (event.key) {
case 'ArrowRight': next = current + 1; break;
case 'ArrowLeft': next = current - 1; break;
case 'Home': next = 0; break;
case 'End': next = items.length - 1; break;
default: return;
}
next = Math.max(0, Math.min(next, items.length - 1));
items[current].tabIndex = -1;
items[next].tabIndex = 0;
items[next].focus();
event.preventDefault();
});
Y esto todavía es la versión ingenua: no contempla escritura RTL (donde ArrowRight tiene que ir al ítem anterior), no recuerda dónde estabas si te vas y volvés, no maneja wrap, y se rompe apenas metés un ítem nuevo por JavaScript o alguien aplica reading-flow en el contenedor flex.
El atributo
La versión declarativa es una línea:
<div focusgroup="toolbar" aria-label="Formato">
<button type="button">Bold</button>
<button type="button">Italic</button>
<button type="button">Underline</button>
</div>
Lo que te da:
- Un tab stop garantizado. Mientras haya al menos un ítem focusable, Tab entra al grupo y Tab de nuevo lo abandona.
- Navegación direccional con flechas, Home y End, respetando el eje lógico y el writing mode. Nada de calcular la dirección según
dir. - Memoria: si te fuiste con Tab y volvés con Shift+Tab, el foco cae donde estabas.
- Un rol mínimo, cuando corresponde.
Lo que desaparece de tu código es la rotación del tabindex. El atributo sigue respetando el que vos pongas —un tabindex="-1" explícito saca al ítem de la navegación inicial con flechas—, pero no tenés que ir moviendo el 0 de un botón a otro.
Los tokens
El atributo toma tokens separados por espacio. El primero es el behavior token, que declara qué patrón de la APG estás armando, y el resto son modificadores:
<div focusgroup="<behavior> [inline|block] [wrap|nowrap] [nomemory]">
Los behaviors soportados son seis, y cada uno trae defaults distintos:
| Behavior | Patrón APG | Rol mínimo (contenedor / ítems) | Defaults |
|---|---|---|---|
toolbar | Toolbar | toolbar / — | inline |
tablist | Tabs | tablist / tab | inline wrap |
radiogroup | Radio Group | radiogroup / radio | wrap |
listbox | Listbox | listbox / option | block |
menu | Menu | menu / menuitem | block wrap |
menubar | Menubar | menubar / menuitem | inline wrap |
Los defaults siguen la convención de cada patrón: un tablist navega horizontal y da la vuelta (focusgroup="tablist" equivale a focusgroup="tablist inline wrap"), un menu navega vertical y también da la vuelta, un toolbar navega horizontal pero frena en los extremos.
Los modificadores explícitos pisan al default. focusgroup="tablist nowrap" es un tablist con bordes duros; focusgroup="tablist block" uno vertical.
Los otros dos tokens que vale conocer:
nomemoryapaga la memoria del último foco (útil en un tablist donde siempre querés entrar por la pestaña activa).nonees el opt-out: saca al elemento y a todo su subárbol del focusgroup ancestro. Los elementos siguen siendo alcanzables con Tab, simplemente dejan de participar de la navegación con flechas.
Y hay un atributo compañero, focusgroupstart, que marca qué ítem recibe el foco la primera vez que entrás al grupo:
<div focusgroup="tablist nomemory" aria-label="Sistemas operativos">
<button>Linux</button>
<button focusgroupstart>macOS</button>
<button>Windows</button>
</div>
Ojo con la interacción: si la memoria está activa (que es el default) y ya hay un último foco registrado, gana la memoria. focusgroupstart aplica en la primera entrada o cuando usás nomemory.
Los roles: no pisa tu semántica
Esta es la parte que más se malinterpreta. El behavior token puede aportar un rol, pero solo cuando el contenedor es genérico y no declaraste nada:
<!-- El div se expone como menubar, los button como menuitem -->
<div focusgroup="menubar" aria-label="Formato">
<button>Fuente</button>
<button>Tamaño</button>
</div>
Si el elemento ya tiene semántica nativa no genérica (<ul>, <nav>, <table>) o vos pusiste un role explícito, el mapeo no se aplica: focusgroup aporta solo el comportamiento de teclado. Un <ul focusgroup="menubar"> sigue siendo una lista, con sus listitem adentro.
Es deliberado: evita que el atributo convierta silenciosamente una lista de links en un menubar falso. Pero implica que si querés semántica de toolbar sobre markup semántico, la escribís vos:
<!-- Links reales que funcionan sin JS; focusgroup agrega solo las flechas -->
<ul role="toolbar" focusgroup="toolbar" aria-label="Acciones del artículo">
<li role="none"><a href="/edit">Editar</a></li>
<li role="none"><a href="/history">Historial</a></li>
<li role="none"><a href="/share">Compartir</a></li>
</ul>
Lo que sigue siendo tuyo
focusgroup mueve el foco, no la selección. Son cosas distintas y la propuesta las mantiene desacopladas a propósito: en un tablist con activación automática querés que el panel cambie al mover el foco, en un listbox multi-selección no.
O sea que esto sigue siendo código tuyo:
<div focusgroup="tablist" aria-label="Documentación" id="tabs">
<button aria-selected="true" aria-controls="p1">Guía</button>
<button aria-selected="false" aria-controls="p2">API</button>
</div>
// El foco lo maneja el navegador; el estado seleccionado, vos.
document.getElementById('tabs').addEventListener('focusin', event => {
const tab = event.target.closest('button');
if (!tab) return;
for (const other of tab.parentElement.children) {
other.setAttribute('aria-selected', String(other === tab));
}
showPanel(tab.getAttribute('aria-controls'));
});
Tampoco te da indicadores visuales propios ni navegación en contenedores arbitrarios: el atributo está limitado a esos seis patrones justamente para que no termine siendo "flechas en cualquier <div>", que es una forma rápida de hacer una UI impredecible para quien navega con teclado.
Para grillas, feeds y controles anidados adentro de cada ítem hay una propuesta separada (Focusgroup V2) que todavía no está implementada.
Detección de soporte
El atributo se refleja como un DOMTokenList en focusGroup, así que podés preguntar por el soporte de un token puntual:
const probe = document.createElement('div');
const hasFocusgroup = 'focusGroup' in HTMLElement.prototype;
const supportsToolbar = hasFocusgroup && probe.focusGroup.supports('toolbar');
if (!supportsToolbar) {
// Cargar el polyfill / mantener el roving tabindex a mano
}
En navegadores sin soporte el atributo se ignora, pero cuidado: la degradación no es automática. Si sacaste el tabindex de tus ítems, en esos navegadores cada botón vuelve a ser un tab stop propio, que es el comportamiento nativo de un botón. No queda roto ni inaccesible, queda más verboso: Tab recorre uno por uno en vez de flechas. Para muchos casos eso es un fallback aceptable; si no lo es, mantené el script hasta que haya soporte cruzado.
La otra mitad: el modo tabs de los carruseles CSS
Los carruseles CSS —::scroll-marker, ::scroll-marker-group, ::scroll-button(), :target-current— resolvieron el paginado de un scroller sin JavaScript. Lo que no resolvían era la semántica: los marcadores siempre se comportaban como una lista de links.
Chrome 154 (beta desde el 2 de septiembre, estable el 22) agrega modos a scroll-marker-group:
.carousel {
scroll-marker-group: after tabs;
}
La sintaxis es none | [ [ before | after ] || [ links | tabs ] ], y el modo cambia bastante más que los roles:
links(el default, lo que teníamos hasta ahora): el grupo exponenavigation, cada marcador exponelink, y todos los marcadores son tab stops. Activar uno mueve el foco al target.tabs: el grupo exponetablist, cada marcadortab, y los elementos originantes pasan a sertabpanel. Acá está lo interesante: el grupo establece un focusgroup implícito. Solo el marcador activo es tab stop y entre marcadores te movés con flechas. Y el contenido de los paneles inactivos queda oculto del accessibility tree, así que no tenés que andar poniendointeractivity: inerta mano.
Es el mismo modelo mental que focusgroup, aplicado a pseudo-elementos generados: un tab stop para entrar, flechas para moverte adentro, roles correctos sin ARIA escrita a mano. Un carrusel-tab accesible termina siendo CSS y markup, sin una línea de JavaScript de gestión de foco.
Estado y soporte
Por ahora, solo Chromium.
focusgroupestá en Chrome 150 estable y en camino a la spec de HTML vía el PR 11723 de WHATWG. Mozilla ya dio posición positiva; WebKit todavía tiene el issue abierto, con planteos sobre independencia de dispositivo.- Los modos de
scroll-marker-groupestán en CSS Overflow 5 y llegan con Chrome 154, estable el 22 de septiembre.
Las treinta líneas de keydown probablemente ya las tenés resueltas en tu design system, así que ese no es el punto. El punto es que hoy la tecnología asistiva ve un montón de botones con tabindex raro y deduce qué widget es; con el atributo se lo decís.
Si querés probarlo, el camino barato es envolver un toolbar existente: agregás focusgroup="toolbar", dejás tu script como está, y comparás en Chrome contra el resto. El día que Safari y Firefox lo implementen, borrás el script.

