Using aria-activedescendant for Grid Cell Focus

Permalink to "Using aria-activedescendant for Grid Cell Focus"

aria-activedescendant is a focus model in which real DOM focus stays on a composite widget’s container while a single aria-activedescendant attribute names, by id, whichever descendant is currently active. It prevents the specific failure that breaks roving tabindex under virtualization: losing keyboard focus into document.body when the focused cell’s DOM node is recycled out of the viewport. This page shows how to wire the pointer, draw the indicator yourself, scroll the active cell into view, and respect the one hard constraint — the referenced id must always exist in the DOM.

This is the container-focus alternative introduced in composite widget roles and states. Read that first for how it compares with the roving model.


Spec reference

Permalink to "Spec reference"

aria-activedescendant is a relationship property defined in ARIA 1.2. It is set on the element that holds DOM focus (a grid, listbox, tree, or textbox/combobox container) and its value is the id of a descendant that is the active element within the widget.

Aspect Value
Where it lives On the focused container (tabindex="0")
Valid value A single id of a descendant element (an IDREF)
Default behaviour Absent → AT tracks DOM focus only; no separate active descendant
Descendant tabindex None — descendants must not be individually focusable
Referenced element Must be present in the DOM and a valid descendant of the container

WCAG 2.2 criteria in scope: 4.1.2 Name, Role, Value (Level A) (the active item is exposed programmatically), 2.4.3 Focus Order (Level A) (the pointer tracks a logical order), and 2.4.7 Focus Visible (Level AA) (you must draw the indicator yourself, since the browser will not).


When to use vs. NOT to use

Permalink to "When to use vs. NOT to use"

Prefer aria-activedescendant when

Permalink to "Prefer aria-activedescendant when"
  • Rows are virtualized — the DOM contains only a viewport’s worth of cells, and nodes are recycled during scroll. With no DOM focus on any cell, recycling cannot strand focus.
  • The list is re-rendered frequently (filtering, live data), where a roving tabindex="0" node is destroyed and re-created on each render, dropping focus.
  • You want a single stable focus target — the container — so focus restoration after a modal or route change lands somewhere predictable.

Prefer roving tabindex (do NOT use this model) when

Permalink to "Prefer roving tabindex (do NOT use this model) when"
  • The grid is fully rendered and stable. Roving tabindex is simpler, and moving real DOM focus gives you the browser’s focus ring and the most consistent AT support for free. See implementing roving tabindex for custom data grids.
  • Descendants are themselves interactive controls (<button>, <input>) that need real focus to receive typed input — aria-activedescendant does not move DOM focus, so a text cursor never lands in the cell.
  • You are targeting TalkBack-first touch experiences, where the activedescendant model is tracked less reliably than moving real focus.

The misuse to avoid: applying aria-activedescendant and leaving a roving tabindex="0" on cells. That gives the widget two competing active items — the visible ring (real focus) and the AT’s active descendant drift apart.


Annotated code example

Permalink to "Annotated code example"

Markup — container holds focus, cells hold ids

Permalink to "Markup — container holds focus, cells hold ids"
<!-- WCAG 4.1.2: container is the single focus target; cells carry ids, not tabindex -->
<!-- WCAG 2.4.7: .is-active is styled by us because the browser draws no ring here -->
<div
  role="grid"
  aria-label="Sensor readings"
  tabindex="0"
  aria-activedescendant="cell-r2-c1"
  id="reading-grid"
>
  <div role="row" aria-rowindex="2">
    <!-- No tabindex on cells: DOM focus never lands here -->
    <div role="gridcell" id="cell-r2-c1" class="is-active">Sensor A</div>
    <div role="gridcell" id="cell-r2-c2">21.4°C</div>
  </div>
  <div role="row" aria-rowindex="3">
    <div role="gridcell" id="cell-r3-c1">Sensor B</div>
    <div role="gridcell" id="cell-r3-c2">19.8°C</div>
  </div>
</div>

JavaScript — move the pointer, draw the ring, scroll into view

Permalink to "JavaScript — move the pointer, draw the ring, scroll into view"
// WCAG 2.4.3 Focus Order: arrow keys rewrite the pointer in a logical order
// WCAG 2.4.7 Focus Visible: we toggle .is-active because there is no DOM focus ring
const grid = document.getElementById('reading-grid');

function setActive(cell) {
  if (!cell) return;
  // 1. Update the pointer — AT announces the newly active descendant
  grid.setAttribute('aria-activedescendant', cell.id); // SC 4.1.2

  // 2. Draw the indicator ourselves (browser draws none on a non-focused node)
  grid.querySelectorAll('.is-active').forEach(c => c.classList.remove('is-active'));
  cell.classList.add('is-active'); // SC 2.4.7 — must meet 3:1 contrast (SC 1.4.11)

  // 3. Scroll the active cell into view WITHOUT moving DOM focus.
  //    Do not call cell.focus() — that would defeat the model.
  cell.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}

grid.addEventListener('keydown', (e) => {
  const cells = Array.from(grid.querySelectorAll('[role="gridcell"]'));
  const active = document.getElementById(grid.getAttribute('aria-activedescendant'));
  const i = cells.indexOf(active);
  const cols = 2;
  let next;
  switch (e.key) {
    case 'ArrowRight': next = cells[i + 1]; break;
    case 'ArrowLeft':  next = cells[i - 1]; break;
    case 'ArrowDown':  next = cells[i + cols]; break;
    case 'ArrowUp':    next = cells[i - cols]; break;
    case 'Home':       next = cells[i - (i % cols)]; break;
    case 'End':        next = cells[i - (i % cols) + (cols - 1)]; break;
    default: return; // Tab passes through → widget is one stop, not a trap (SC 2.1.2)
  }
  if (next) { e.preventDefault(); setActive(next); }
});

The .is-active class carries the whole visible indicator. Because DOM focus is on the container, :focus-visible on the cell never fires — style .is-active with an outline or box-shadow that meets the 3:1 contrast of WCAG 1.4.11 Non-text Contrast.


The virtualization caveat: the id must exist in the DOM

Permalink to "The virtualization caveat: the id must exist in the DOM"

This is the reason to reach for the model, and also its sharpest edge. aria-activedescendant is an IDREF: if the referenced id is not present in the DOM, the reference dangles and AT reports no active descendant — navigation goes silent.

Virtualized grids render only the visible window of rows. If the user’s active cell scrolls out of that window and the virtualizer unmounts its node, the container now points at a deleted id. Two rules keep the pointer valid:

  1. Never unmount the active cell. Teach the virtualizer to keep the row containing aria-activedescendant mounted even when it leaves the viewport, as an “overscan” exception.
  2. Move the pointer before you recycle. If a scroll event will remove the active node, first call setActive() on a cell that will remain rendered, then let the virtualizer recycle.
// Guard the virtualizer's recycle step against a dangling pointer
// SC 4.1.2: aria-activedescendant must reference a live DOM node
function beforeRecycle(rowsAboutToUnmount) {
  const activeId = grid.getAttribute('aria-activedescendant');
  const active = document.getElementById(activeId);
  const willBeRemoved = rowsAboutToUnmount.some(row => row.contains(active));
  if (willBeRemoved) {
    // Re-point to the nearest cell that stays rendered, keeping AT in sync
    const survivor = grid.querySelector('[role="gridcell"]');
    setActive(survivor);
  }
}

Contrast this with roving tabindex, where the failure mode is different: recycling the focused node drops DOM focus to document.body. Neither model is free — but with aria-activedescendant the fix is “keep one node alive,” which composes better with virtualization than chasing lost DOM focus. For the wider virtualization picture, see accessible virtualized list patterns.


Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Key / event Action Expected AT announcement Failure indicator
Tab onto grid Focus the container “Sensor readings, grid” then the active cell Focus lands on a cell (a stray tabindex remains)
ArrowDown Re-point aria-activedescendant down one row “Sensor B, row 3” Silence — pointer updated but node not in DOM
Home / End Re-point to row start / end First / last cell in the row Container scrolls but active indicator stays put
Active cell scrolls off-screen Overscan keeps it mounted Still reads active cell on next key “No active descendant”; navigation dead
Tab again Exit the widget Next control’s name and role Trapped (arrow handler swallowed Tab)

Integration context

Permalink to "Integration context"

This model is one of the two focus models catalogued in composite widget roles and states; that page also covers the ARIA states (aria-selected, aria-multiselectable, aria-expanded) you layer on top of the active-descendant pointer. When you choose the roving alternative instead, follow implementing roving tabindex for custom data grids. Pair either model with a polite live region when navigation also changes selection state that must be announced.


Gotchas

Permalink to "Gotchas"
  1. Calling .focus() on the active cell. This moves real DOM focus and defeats the model — you now have two focus notions. Scroll with scrollIntoView, never focus().
  2. Forgetting the visible indicator. Because no DOM focus ring is drawn, an un-styled active descendant is invisible to sighted keyboard users, failing SC 2.4.7. The .is-active style is mandatory, not decorative.
  3. Non-unique or regenerated ids. If your render pass regenerates cell ids on every update, the pointer set before the render references a stale value. Derive ids deterministically from row/column keys so they survive re-renders.

FAQ

Permalink to "FAQ"
When should I prefer aria-activedescendant over roving tabindex?

Prefer aria-activedescendant when the active item’s DOM node may be removed underneath you — virtualized grids, frequently re-rendered lists, or filtered views. Because DOM focus never leaves the container, there is no focus to lose when a node is recycled. For stable, fully rendered grids, roving tabindex is simpler and has broader assistive-technology support, so use that unless you have a concrete reason not to.

Do I still get a visible focus ring with aria-activedescendant?

No, not automatically. The browser only draws its focus ring on the element that holds real DOM focus, which is the container. The active descendant holds no DOM focus, so you must style it yourself with a class or attribute and ensure the indicator meets the 3:1 non-text contrast requirement of WCAG 1.4.11 and satisfies WCAG 2.4.7 Focus Visible.

What happens if aria-activedescendant points at an id that is not in the DOM?

The reference dangles and assistive technology reports no active descendant, so navigation goes silent. This is the main virtualization hazard: if you recycle the DOM node the container points at, update the pointer to a node that is still rendered, and keep the active cell mounted even when it scrolls out of the viewport.


Permalink to "Related"

Back to Composite Widget Roles & States