Accessibility · Keyboard and Focus

Roving tabindex and Active Descendant Compared

Two ways to give a composite widget one tab stop: moving tabindex among items, or keeping focus on a container and pointing at an item with aria-activedescendant.

A list of twenty tabs, a grid of a thousand cells, a menu with twelve items: if each item were its own tab stop, reaching anything past the widget would cost one Tab press per item. Composite widgets therefore collapse to a single stop and handle arrow keys internally. Two mechanisms implement that collapse, and the choice between them is decided by what has to be focused, not by preference.

The shared contract

Both mechanisms satisfy the same three requirements, and the requirements come from the keyboard model rather than from any one implementation.

The composite is one stop in the page’s tab sequence. Tab enters it, Tab leaves it, and everything between is reached with arrows. This is the behaviour users expect from native <select>, from a radio group, and from a table.

The composite remembers where the user was. Returning to the widget after tabbing away lands on the item last focused, not on the first item. A menu that resets to the top on every visit makes repeated trips expensive.

Exactly one item is the focused item at any moment. Arrow keys move that designation, and the accessible name and state of the widget reflect it — a screen reader announces the item as the user arrows, not the container.

Neither mechanism is native; both are conventions layered on tabindex and ARIA. The difference is where DOM focus physically sits while the user arrows.

Roving tabindex moves focus for real

With roving tabindex, every item in the widget is focusable but only one has tabindex="0". The rest carry tabindex="-1". When an arrow key moves the selection, the handler updates the attributes — the new item gets 0, the old gets -1 — and calls focus() on the new item.

function rovingTabs(tablist) {
  const tabs = [...tablist.querySelectorAll('[role="tab"]')];
  let current = tabs.findIndex((t) => t.tabIndex === 0);
  if (current < 0) current = 0;

  const move = (next) => {
    tabs[current].tabIndex = -1;
    tabs[next].tabIndex = 0;
    tabs[next].focus();
    current = next;
  };

  tablist.addEventListener('keydown', (event) => {
    const delta = { ArrowRight: 1, ArrowLeft: -1, Home: -Infinity, End: Infinity }[event.key];
    if (delta === undefined) return;
    event.preventDefault();
    const next = delta === -Infinity ? 0
      : delta === Infinity ? tabs.length - 1
      : (current + delta + tabs.length) % tabs.length;
    move(next);
  });
}

Because focus() is real, the browser’s own machinery applies: :focus-visible matches the item, the item is document.activeElement, focus events fire on it and bubble, and a screen reader follows the actual focus. Nothing needs to be announced explicitly. The cost is that the DOM is mutated on every arrow press — two attribute writes and a focus call per keystroke — and that a focus ring drawn by the browser lands on the item, which is what most designs want for a tablist.

The roving pattern also means focus can be lost. If the focused item is removed from the DOM while it holds tabindex="0", the widget has no tab stop at all and Tab skips the entire composite. The handler that removes an item must transfer the zero to a neighbour before the removal completes.

Active descendant keeps focus on the container

With aria-activedescendant, the container itself is the only focusable element, with tabindex="0", and it carries an attribute naming the id of the item that is conceptually focused. The items are not focusable at all. Arrow keys change the attribute value; document.activeElement never moves.

This is the mechanism a text input with a suggestion list must use. Focus has to remain in the <input> so that typing continues to work, and the browser has no way to put DOM focus on an <li> without taking it out of the input. Setting aria-activedescendant on the combobox input tells assistive technology which option is current while the caret stays where it belongs. The same constraint applies to any widget where the container is itself editable, and to grids where moving focus per cell would trigger per-cell focus handling that the widget wants to keep centralized.

The costs are concrete:

The container’s accessible name is what gets announced on entry, so the container needs one. The item is announced as a change of the active descendant, which browsers report with differing verbosity — some announce the item’s name, some its name and position, some only a change tone.

:focus-visible matches the container, never the item, so the “focused item” indicator must be drawn with a class or attribute selector rather than with :focus, a split described in Focus Rings, :focus-visible, and the Heuristic Behind Them. This is a design obligation, not a detail: a grid using active descendant with no per-item highlight shows the user a ring around the whole grid and no indication of which cell arrows will act on.

The referenced id must exist and be unique. The ARIA specification states that user agents are not expected to validate that the active descendant is a descendant of the container, so a stale reference is not corrected: the widget reports a position that no longer corresponds to anything on screen. Rebuilding the list on filter must clear or reassign the attribute in the same task as the DOM change, or the intermediate state points at a removed node.

Because DOM focus does not move, events that a per-item listener would receive arrive at the container instead. Every handler must resolve the target from the attribute rather than from event.target, and code that relies on focusin bubbling from items does not fire at all.

Choosing between them

The decision rule is short. If focus must stay in an editable control, use active descendant; there is no alternative. If the container is not editable and the widget is a list of discrete items, use roving tabindex; it delegates focus semantics to the browser and needs less custom code.

PropertyRoving tabindexActive descendant
document.activeElementthe itemthe container
Items need tabindexyes, one 0, rest -1no
Focus ring on itemnativemust be styled manually
Works inside a text inputnoyes
Screen reader position reportingfollows real focusdepends on attribute support
Cost per arrow keyattribute writes plus focus()one attribute write

Two hybrid cases are worth recognising. A menu inside a dialog can use roving tabindex; the dialog trap does not interfere because the roving item is inside the trap’s container. A combobox that opens a popup list uses active descendant on the input while the popup is open, and reverts to nothing when the popup closes — the attribute is removed, not left pointing at a hidden option.

There is also a case where neither is right: a widget whose items are links or buttons that must be individually activatable by Enter and whose activation must not depend on the container. Native elements inside a list with role="list" and no composite role keep their own tab stops, and that may be the correct answer — a list of five links does not need to become a composite. Collapsing a widget to one stop is a cost paid by users who then must arrow through it; for short lists the cost exceeds the benefit.

Interaction with the surrounding tab order

Both mechanisms depend on the composite occupying a known position, so the surrounding sequence matters. The container should carry no positive tabindex, for the reasons given in How the Browser Computes the Tab Sequence. If the widget is inside a container that is inert while a modal is open, the entire composite is correctly removed, and the roving item that held tabindex="0" retains its value for when the container becomes active again — the attribute is preserved even though the element is not focusable, so re-entering the widget lands where the user left it.

Focus that leaves the widget by Tab and returns must land on the remembered item. With roving tabindex this happens automatically, since the 0 stayed where it was. With active descendant the container keeps its tabindex="0" and the attribute is unchanged, so re-entry also works — but only if the attribute was not cleared on blur. Clearing it on focusout is a common mistake; it discards the position and makes every re-entry start at the first item.

References

  1. WAI-ARIA 1.2 — aria-activedescendant — W3C
  2. WAI-ARIA Authoring Practices Guide — Composite widget patterns — W3C WAI
  3. HTML Living Standard — Sequential focus navigation — WHATWG