Implementing a Roving Tabindex for Chart Series
A keyboard user presses Tab to move past your chart and instead gets trapped stepping through five hundred individual data points, one press at a time.
The fix is a roving tabindex: exactly one element in each group holds tabindex="0" at a time while every other holds tabindex="-1", so the chart is a single tab stop and arrow keys move focus within it. This page is part of keyboard navigation patterns for charts, and it is the mechanism that makes a dense, multi-series chart operable without violating focus-order and no-trap requirements. It is the same pattern that underpins adding keyboard navigation to Canvas charts, applied here to the structure of series and points.
Diagnostic checklist
aria-live region, exactly as the roving-tabindex pattern below prescribes.Broken vs fixed
// ❌ BROKEN: every mark is tabbable → N tab stops, a de-facto keyboard trap.
function makeFocusable(marks: SVGElement[]): void {
for (const m of marks) {
m.setAttribute("tabindex", "0"); // 500 marks = 500 Tab presses to escape
}
}
// ✅ FIXED: roving tabindex — one active mark per group, arrows move the active one.
interface RovingState {
series: number; // active series index
index: number; // active point index within the series
}
class RovingTabindex {
private state: RovingState = { series: 0, index: 0 };
constructor(
private groups: SVGElement[][], // marks[series][point]
private announce: (msg: string) => void,
private describe: (series: number, index: number) => string,
) {
this.applyTabindex();
for (const row of groups)
for (const mark of row) {
mark.setAttribute("role", "img");
mark.addEventListener("keydown", (e) => this.onKeydown(e as KeyboardEvent));
}
}
// A11Y: exactly ONE mark holds tabindex=0; all others are -1 (WCAG 2.4.3 focus order).
private applyTabindex(): void {
this.groups.forEach((row, s) =>
row.forEach((mark, i) => {
const active = s === this.state.series && i === this.state.index;
mark.setAttribute("tabindex", active ? "0" : "-1");
}),
);
}
private onKeydown(e: KeyboardEvent): void {
const row = this.groups[this.state.series];
let handled = true;
switch (e.key) {
case "ArrowRight": this.state.index = Math.min(this.state.index + 1, row.length - 1); break;
case "ArrowLeft": this.state.index = Math.max(this.state.index - 1, 0); break;
case "ArrowDown": this.state.series = Math.min(this.state.series + 1, this.groups.length - 1); break;
case "ArrowUp": this.state.series = Math.max(this.state.series - 1, 0); break;
case "Home": this.state.index = 0; break;
case "End": this.state.index = row.length - 1; break;
default: handled = false;
}
if (!handled) return;
e.preventDefault(); // stop the page from scrolling on arrows
this.moveFocus();
}
private moveFocus(): void {
// Clamp index when switching to a shorter series.
const row = this.groups[this.state.series];
this.state.index = Math.min(this.state.index, row.length - 1);
this.applyTabindex();
const target = row[this.state.index];
target.focus(); // move real DOM focus so the ring paints
// A11Y: announce the newly focused value (WCAG 4.1.3 status messages).
this.announce(this.describe(this.state.series, this.state.index));
}
}
The invariant that makes this correct is “one tabbable element per group at all times”. When an arrow key fires, you first flip the old active mark to tabindex="-1" and the new one to tabindex="0", then call .focus() on the new one. Skipping the tabindex update leaves two tabbable marks and reintroduces extra tab stops; skipping the .focus() call updates the tabindex but leaves the browser’s focus on the old, now-untabbable element, stranding the ring.
Step-by-step fix
// Announce and describe helpers wired into the roving controller.
function describePoint(series: { name: string; points: { xLabel: string; yFormatted: string }[] }[], s: number, i: number): string {
const p = series[s].points[i];
// A11Y: name + position + value so the listener knows exactly where focus is.
return `${series[s].name}, ${p.xLabel}: ${p.yFormatted}, point ${i + 1} of ${series[s].points.length}`;
}
Verification
// Assert exactly one tabbable mark exists across the whole chart at any time.
function countTabbable(root: HTMLElement): number {
return root.querySelectorAll('[tabindex="0"]').length;
}
console.assert(countTabbable(chartRoot) === 1, "roving tabindex must expose exactly one tab stop");
// After an ArrowRight, focus must be on the new active mark, not the old one.
const before = document.activeElement;
chartRoot.querySelector('[tabindex="0"]')!.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }),
);
console.assert(document.activeElement !== before, "arrow key must move focus to the next point");
In DevTools, Tab into the chart and confirm a single press moves focus past it to the next control — that proves there is one tab stop, not N. Then use the arrow keys and watch the Accessibility pane: the focused node should change, its name should include the value, and the live region should update on each move.
Series versus points: two dimensions of movement
A multi-series chart is a grid — series down one axis, points along the other — and a good keyboard model reflects that. Left and Right traverse points within the current series; Up and Down switch series while preserving the point index, so a user comparing “March across all series” can hold the column and move down through it. This two-axis model is more useful than a flat left-to-right walk because comparison is the reason multi-series charts exist. The one subtlety is ragged series of different lengths: when Up/Down lands on a shorter series, clamp the index to that series’ last point rather than throwing or focusing nothing. Keeping the index sticky where possible, and clamping only when forced, matches the mental model of moving through a table and keeps the focus predictable. For very dense series, consider a coarse mode — Page Up/Page Down jumping ten points, or a modifier that steps between labelled peaks — so a user is not obliged to arrow through a thousand points to cross the chart.
Edge cases & gotchas
- Two elements at
tabindex="0". If a bug leaves the old mark tabbable after a move, the chart grows a second tab stop and focus order becomes unpredictable. Always flip the old mark to-1in the same update that sets the new one to0. - Arrow keys scrolling the page. Without
preventDefault, arrow presses scroll the viewport while also moving focus. CallpreventDefaultonly for the keys you handle, so unrelated keys still behave normally. - Focus lost after a data update. Re-rendering marks can destroy the element that held focus. After an update, re-apply the tabindex to the mark at the saved
{ series, index }and re-focus it, or focus silently falls back to<body>. - Empty or single-point series. Guard the index maths so Home/End and Left/Right do not go out of bounds when a series has zero or one point.
- Screen-reader virtual cursor overlap. Some screen readers intercept arrow keys in browse mode. The roving pattern works in focus/forms mode; ensure marks are genuinely focusable elements so the reader switches modes when focus enters the chart.
- Announcement flooding. Holding an arrow key auto-repeats and can flood the live region. Debounce announcements or announce only the settled position after rapid movement stops, as covered in the live-region guidance.
Frequently Asked Questions
Why not just give every data point tabindex="0"?
Because it turns the chart into a keyboard trap in practice: a five-hundred-point chart becomes five hundred Tab presses to move past, which violates focus-order expectations and exhausts the user. A roving tabindex keeps the chart a single tab stop and delegates movement between points to the arrow keys, matching how native composite widgets like menus, grids, and radio groups behave.
How should arrow keys map for a multi-series chart?
Use two axes. Left and Right move between points within the active series; Up and Down switch between series while keeping the point index so users can compare the same position across series. Add Home and End to jump to the first and last point. This mirrors moving through a data grid and supports the comparison tasks multi-series charts exist for.
Do I still need an ARIA live region if focus moves visibly?
Yes. A visible focus ring serves sighted keyboard users, but a screen-reader user needs the focused point’s name and value spoken. Update a polite aria-live region on each move with the series name, position, and value, and debounce it so holding an arrow key does not flood the speech buffer. The ring and the announcement are complementary, not alternatives.
Related
- Keyboard navigation patterns for charts — the guide covering focus order, traps, and focus rings.
- Adding keyboard navigation to Canvas charts — the DOM-proxy version of this pattern for opaque canvases.
- Accessible tooltips triggered by keyboard focus — showing detail on the focused point.
- Screen-reader-friendly charts — the semantics announced as focus moves.