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-activedescendantdoes 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:
- Never unmount the active cell. Teach the virtualizer to keep the row containing
aria-activedescendantmounted even when it leaves the viewport, as an “overscan” exception. - 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"- Calling
.focus()on the active cell. This moves real DOM focus and defeats the model — you now have two focus notions. Scroll withscrollIntoView, neverfocus(). - 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-activestyle is mandatory, not decorative. - 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.
Related
Permalink to "Related"- Composite widget roles and states — the parent reference covering both focus models and the required ARIA states
- Implementing roving tabindex for custom data grids — the alternative model, for stable grids
- Accessible virtualized list patterns — keeping the referenced node alive through DOM recycling