Accessibility · ARIA Practice

Roving tabindex and aria-activedescendant as Competing Models

Two ways to keep one tab stop inside a composite widget: moving real DOM focus between items, or holding focus on a container and pointing at an item. Their costs differ in event routing, virtualisation, and IME handling.

A listbox with forty options should be one stop in the tab sequence, not forty. Two mechanisms achieve that, and they disagree about where the focus ring lives. With roving tabindex, real DOM focus moves from option to option, and exactly one option has tabindex="0" at a time. With aria-activedescendant, focus stays on the container and an attribute names the active option. Both are correct per the ARIA specification, both are used by shipping widgets, and the choice determines which events fire, whether virtualised content works, and how much code is needed to keep the tab sequence stable.

The tab-stop problem

Sequential focus navigation walks the document and stops at each focusable element. A focusable element is, in the HTML model, one with a tabindex value that is not negative, plus a set of natively focusable elements such as links and form controls. tabindex="0" puts an element in the sequence at its document position; tabindex="-1" makes it focusable by script but skips it in the sequence; a positive value reorders it ahead of the zero-valued ones, which is almost never what a page wants.

A composite widget made of forty focusable options therefore contributes forty stops. That is wrong for the same reason forty tab stops inside one table cell would be wrong: the user must press Tab forty times to leave the widget, and there is no way to tell from the tab sequence that the forty stops are one thing. The WAI-ARIA design pattern for every composite role, including listbox, grid, tree, menu, tablist, radiogroup, and toolbar, specifies a single tab stop and arrow keys for internal movement. The composite is entered once, traversed with arrows, and left with a single Tab.

The two mechanisms are the two ways to express that.

Roving tabindex: focus follows the active item

The roving technique keeps a single item at tabindex="0" and every other item at tabindex="-1". Tab enters the widget at whichever item currently holds the zero, arrow keys move focus with element.focus() and swap the two tabindex values.

function moveTo(items, next) {
  const current = items.findIndex((el) => el.tabIndex === 0);
  if (current !== -1) items[current].tabIndex = -1;
  items[next].tabIndex = 0;
  items[next].focus(); // real focus: events, caret, and CSS all follow
}

The properties that follow from real focus are the reason the technique is the default choice.

Focus events fire on the item. focusin, focusout, and blur behave exactly as they would in a native list, so anything that reacts to focus, whether a CSS rule, a delegated listener, or a framework’s own focus tracking, works without modification. The browser scrolls the focused element into view automatically, which handles long lists without code. The accessibility tree’s focused node is the item, so a screen reader in a mode that follows focus reports the item’s name, role, and state without being told about a relationship.

The costs are equally concrete. Each focus() call is a DOM operation that can trigger style recalculation and layout if the item’s focus style changes geometry, and moving through a list of hundreds of items one arrow press at a time performs one of those per press. Moving focus also fires a blur on the previous item, and if the widget is inside a container with a focusout handler that closes it, the handler sees a focusout before the focusin lands, which is the classic cause of a dropdown that closes when the user arrows into it. The remedy is to check relatedTarget against the container rather than to react to focusout alone.

The technique also has an invariant to maintain: exactly one item at tabindex="0". A render that writes tabindex="0" to a newly active item without clearing the old one produces two tab stops, and the widget silently doubles its footprint in the tab sequence. This is the same total-render discipline that ARIA state attributes need, as described in mirroring widget state into ARIA attributes.

aria-activedescendant: focus stays, the pointer moves

The alternative keeps DOM focus on the container, which carries tabindex="0", and writes the ID of the active item into aria-activedescendant.

<ul role="listbox" tabindex="0" aria-activedescendant="opt-17">
  <li role="option" id="opt-17" aria-selected="true">Bucharest</li>
</ul>

Nothing inside the list is focusable. Arrow keys are handled on the container, which updates the attribute and, usually, the selection. The container is the focused element in the accessibility tree; the active descendant is a relationship from it.

The specification constrains this relationship. aria-activedescendant must reference an element that is either a descendant of the element holding focus or is owned by it through aria-owns, and the referenced element must be in the accessibility tree. A reference to a node that has been removed, or to one hidden with display: none, produces a dangling ID and the reader falls back to the container, usually announcing nothing useful.

The benefit is that focus never moves, so no blur fires and no scroll is triggered by focus. That second point matters in a listbox that must not scroll the page when the user arrows: with real focus, the browser scrolls the option into view and may scroll the whole document if the list is positioned inside a scroll container; with aria-activedescendant, the widget scrolls the active option into view itself, with whatever margins and alignment it wants. Widgets that implement their own scrolling and their own active-item styling tend to prefer this.

The cost is that every focus-dependent mechanism stops working. CSS cannot style the active item from :focus, because focus is on the container. document.activeElement is the container, so code that reads the focused item has to consult the attribute instead. A framework that renders based on focus events sees one focus event for the entire widget. And the item itself cannot receive keyboard input, which rules the technique out for any composite whose items contain editable controls.

Which one survives virtualisation

Virtualised lists render only the visible window of items. With roving tabindex, focus is on a DOM node that exists only while its item is inside the window; scrolling the item out of the window removes the focused element from the document, and the browser responds by moving focus to body, which is a hard loss of position that no amount of state tracking recovers, because the platform has already decided where focus is.

With aria-activedescendant, focus is on the container, which is never removed. The active item can be unmounted and remounted as the window moves, and the attribute is rewritten to the new node’s ID on each render. The container keeps focus throughout. This is the reason virtualised listboxes and comboboxes overwhelmingly use the activedescendant model, and it is covered in more depth in custom widgets over virtualized content.

The trade is that the active option must be in the tree. A virtualised list that has scrolled the active item outside the rendered window has nothing to point at, so the implementation must keep the active item rendered, typically by rendering a small overscan band around the window, or accept that the relationship dangles while the user scrolls away from their selection.

Events, IME, and text entry

Roving tabindex puts real focus inside items, so a text input inside a grid cell receives keystrokes, composition events, and clipboard operations natively. The grid pattern with editable cells is built on this: Tab moves into the grid, arrows move the active cell, and Enter or F2 enters edit mode by focusing the cell’s input.

aria-activedescendant on a container that is itself a text input is the pattern behind every autocomplete: the input keeps focus and the caret, the popup list is a separate element, and the active option is referenced across the DOM boundary. The specification permits this because the input holds focus while referencing the popup with aria-controls, and the input’s aria-activedescendant points into that popup while the user types, which is why typing a character, arrowing to an option, and continuing to type all work without losing the caret. The alternative, reparenting the popup with aria-owns, is what the combobox pattern moved away from.

The IME constraint cuts the other way. During composition, a container that intercepts arrow keys will break candidate selection, because the IME uses the arrow keys to move within the candidate window. Any key handler on an activedescendant container must check event.isComposing and return early; the same handler with roving tabindex is attached to items that never see composition events, so the bug cannot occur. The combobox contract that governs these interactions is set out in the combobox popup contract.

Choosing between the two models

The decision reduces to four properties of the widget. If any item must receive text input, roving. If the list virtualises, activedescendant. If the widget controls its own scrolling and active styling, activedescendant. If the widget is a simple list of non-editable options that all exist in the DOM, roving is simpler, because it reuses the platform’s focus behaviour instead of reimplementing it.

Mixing the two in one widget is possible and usually a mistake: a grid that uses roving tabindex for cells and activedescendant for rows has two notions of “current”, and keeping them consistent is work with no payoff. Pick one per composite, state which element holds the tab stop in a comment, and enforce the single-writer rule on whichever attribute carries the position.

References

  1. WAI-ARIA 1.2: aria-activedescendant — W3C
  2. ARIA Authoring Practices Guide: Developing a Keyboard Interface — W3C WAI
  3. HTML Living Standard: Sequential focus navigation and the tabindex attribute — WHATWG