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

Roving tabindex across a series One point per series holds tabindex zero and is the tab stop; arrow keys move the zero to a neighbour while all others hold tabindex minus one. Tab enters once; arrows move the active point Series A 0 -1 -1 -1 one tab stop Series B 0 -1 -1 -1 one tab stop Right arrow: move the 0 from point i to i+1 Up / Down: switch series and keep the index Home / End jump to first / last point
Only the active point in each series is tabbable; arrow keys rove the tabindex to a neighbour, so Tab enters the chart once and moves on.
A keyboard-navigable bar chart. Tab into it once, then use the Left and Right arrow keys to rove focus between bars; each move updates the focus ring and announces the focused bar's value through an aria-live region, exactly as the roving-tabindex pattern below prescribes.
Colour plus redundant shape encoding Four categories encoded by hue and a repeated shape, each clearing 3:1 contrast. Categorical encoding — colour + redundant cueSeries A4.9:1Series B4.8:1Series C5.2:1Series D5.8:1Shape repeats the category so colour is never the only signaland each hue clears 3:1 against the background
The encoding principle for a roving tabindex for chart series: pair every hue with a shape so the category reads without colour, and keep each swatch above 3:1.

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 -1 in the same update that sets the new one to 0.
  • Arrow keys scrolling the page. Without preventDefault, arrow presses scroll the viewport while also moving focus. Call preventDefault only 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.