El atributo focusgroup

El atributo focusgroup
8 min read

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:

BehaviorPatrón APGRol mínimo (contenedor / ítems)Defaults
toolbarToolbartoolbar / —inline
tablistTabstablist / tabinline wrap
radiogroupRadio Groupradiogroup / radiowrap
listboxListboxlistbox / optionblock
menuMenumenu / menuitemblock wrap
menubarMenubarmenubar / menuiteminline 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:

  • nomemory apaga la memoria del último foco (útil en un tablist donde siempre querés entrar por la pestaña activa).
  • none es 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 expone navigation, cada marcador expone link, y todos los marcadores son tab stops. Activar uno mueve el foco al target.
  • tabs: el grupo expone tablist, cada marcador tab, y los elementos originantes pasan a ser tabpanel. 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 poniendo interactivity: inert a 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.

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.

Comments

Share your thoughts and join the discussion

Loading comments…


Related Posts

Responsive iframes

Responsive iframes

11 min read

Que un iframe se ajuste al alto de su contenido es uno de los pedidos más viejos de la plataforma, y siempre lo resolvimos con postMessage. Chrome 154 lo trae como un doble opt-in: una propiedad CSS del lado del padre y un meta del lado del hijo.

Permisos declarativos con Capability Elements

Permisos declarativos con Capability Elements

11 min read

Un prompt de permisos que aparece fuera de contexto se bloquea por reflejo, y volver atrás implica bucear en la configuración del navegador. La apuesta de Chrome son cuatro elementos HTML que el navegador controla y que, además del permiso, te entregan el dato.