Keyboard Traversal of Node-Link Graphs

Permalink to "Keyboard Traversal of Node-Link Graphs"

Node-link graphs — dependency graphs, service maps, knowledge graphs, fraud rings, network topologies — are among the hardest visualisations to make accessible, because their meaning is in the connections, not in positions on an axis. A static description cannot capture a graph with 200 nodes; a table of edges is complete but hard to explore. What works best for many screen reader users is exploration: start at a node, hear what it connects to, move to a neighbour, and continue — the way a sighted user follows lines with their eyes.

This guide belongs to accessible network & graph diagrams.

Spec reference

Permalink to "Spec reference"
  • SC 1.1.1 Non-text Content (A) — the graph needs a text alternative; for complex graphs, that is a summary plus an equivalent structure (table or explorable model).
  • SC 1.3.1 Info and Relationships (A) — node connections must be programmatically available, not only drawn.
  • SC 2.1.1 Keyboard (A) — interactive graphs (click a node to inspect) must be keyboard operable.
  • SC 2.4.7 Focus Visible (AA) — the focused node needs a visible indicator in the drawing.
  • SC 4.1.2 Name, Role, Value (A) — focusable nodes need names. A common structure is a role="application" region or a composite with items; a simpler, robust choice is a focusable SVG group per node with role="button" or role="treeitem"-like semantics described in text.

There is no ARIA “graph” role. Practical implementations either model the graph as a focusable group of nodes with a custom key contract (documented to users) or expose it through a tree or grid that represents a traversal.

Exploring from one node to a neighbour Sequence between the user, the focused node, the neighbour list and the status region: focus on Payments API announces its type and four connections; pressing Right opens the neighbour list; choosing Ledger DB moves focus there and announces the edge; Backspace returns. Exploring from one node to a neighbourUserFocused nodeNeighboursStatusTab: "Payments API,service, 4 connections"Right: list neighbours"Ledger DB, Auth service, Queue, Gateway"choose Ledger DB"Ledger DB, database. Reached via writes to."Backspace: back to PaymentsAPI
Edges become moves; history makes moves reversible.

When to use — and when not to

Permalink to "When to use — and when not to"

Add keyboard traversal when users need to explore relationships: “what does this service depend on?”, “who is connected to this account?”. Pair it with a table of edges for completeness and with a text summary for the big picture.

Do not rely on traversal alone for small graphs; a list of nodes with their connections (“Payments API connects to: Ledger DB, Auth, Queue, Gateway”) may be all that is needed. Do not make every node a tab stop — a 200-node graph would be 200 Tab presses.

The misapplication: an SVG force-directed graph where every node is a <circle tabindex="0"> with no name. Users Tab through anonymous circles in an arbitrary order determined by the layout simulation.

Tab through every node versus explore by edges Comparison: tabbing through every node follows layout order with no relationships; exploring by edges starts from a node and follows connections, with history and search. Tab through every node versus explore by edges✗ Every node a tab stopOrder set by the layout engine"Circle" or nothing announcedNo sense of what connects to whatLost after a few moves✓ One tab stop; arrows and neighboursOrder set by connections"Payments API, service, 4 connections"Neighbours listed on demandBackspace returns along the path

Annotated code example

Permalink to "Annotated code example"
<figure>
  <figcaption id="g-cap">Service dependency graph: 42 services, 96 connections</figcaption>
  <p id="g-help" class="visually-hidden">
    Arrow Right lists neighbours; Enter moves to the chosen one; Backspace goes back; slash searches.
  </p>
  <!-- One tab stop; documented keys (SC 2.1.1, 3.3.2) -->
  <svg viewBox="0 0 20 20" role="application" aria-labelledby="g-cap" aria-describedby="g-help" tabindex="0">…</svg>
  <p id="g-status" role="status" class="visually-hidden"></p>
</figure>
let current = graph.node('payments-api');
const history = [];
const say = (t) => { status.textContent = ''; requestAnimationFrame(() => { status.textContent = t; }); };
const describe = (n) => `${n.name}, ${n.type}, ${n.degree} connections`;

svg.addEventListener('focus', () => { highlight(current); say(describe(current)); });  // SC 2.4.7, 4.1.2
svg.addEventListener('keydown', (e) => {
  if (e.key === 'ArrowRight') {                         // list neighbours
    e.preventDefault();
    openNeighbourMenu(current.neighbours.map(nb => ({
      label: `${nb.node.name}, ${nb.relation}`,          // "Ledger DB, writes to"
      run: () => go(nb.node, nb.relation),
    })));
  } else if (e.key === 'Backspace' && history.length) {
    e.preventDefault(); current = history.pop(); highlight(current); say(`Back to ${describe(current)}`);
  } else if (e.key === '/') {
    e.preventDefault(); openSearch();                    // jump by name
  }
});
function go(node, relation) {
  history.push(current); current = node; highlight(current);
  say(`${describe(node)}. Reached via ${relation}.`);
  svg.focus();
}

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Key Action Expected announcement AT deviations
Tab Focus graph at current node “Service dependency graph… Payments API, service, 4 connections” role="application" makes readers pass all keys; document that
Right Neighbour menu “Ledger DB, writes to, 1 of 4” Menu follows the menu pattern
Enter on neighbour Move “Ledger DB, database, 3 connections. Reached via writes to.” —
Backspace Back “Back to Payments API…” —
/ Search nodes Search field —
Escape Close menu or search Current node —
Representations of a graph and what each supports Matrix of representations — text summary, node list with connections, edge table, explorable traversal — against overview, completeness, local exploration and effort to use. Representations of a graph and what each supportsOverviewCompleteLocalexplorationEffortText summaryYesNoNoLowNode list with connectionsPartialYesLinearMediumEdge tableNoYesSort and filterMediumExplorable traversalVia summaryYesYesLow per step
Offer a summary plus at least one complete and one explorable form.

Integration context

Permalink to "Integration context"

Traversal complements adjacency tables as network graph alternatives and announcing graph structure summaries. The neighbour menu uses the menu pattern from row action menus with the menu button pattern. For point-based charts with a similar exploration model, see keyboard navigation for scatter plots.

Gotchas

Permalink to "Gotchas"

role=“application”. It tells screen readers to pass all keys to the page, which is what you want for a custom key model — but users lose their reading commands inside it. Keep the region small (the graph only), name it, and describe the keys.

Layout jitter. Force-directed layouts move nodes as the simulation settles. Freeze the layout before users explore, or the focus highlight jumps around for magnifier users.

Directed edges. “Connects to” hides direction. Name relations with direction: “calls”, “is called by”, “writes to”.

Common mistakes

Permalink to "Common mistakes"
  • Making every node a tab stop in layout order.
  • Announcing node names without type or degree.
  • Losing the user after a few moves because there is no back key.
  • Using colour alone for node types and edge kinds.

Framework notes

Permalink to "Framework notes"

D3, Cytoscape.js, Sigma.js and vis-network render graphs to SVG or canvas and provide no accessibility layer; keep your own graph model (nodes, neighbours, relations) and drive focus and announcements from it. With Cytoscape.js, use cy.$id(id).neighborhood() to list neighbours and cy.center() to bring the focused node into view; draw the focus ring as a style class. With canvas renderers (Sigma), the SVG-level focus highlight must be drawn in the canvas and mirrored by your announcements.

// Cytoscape: neighbours with relations for the menu
const nbs = cy.$id(current.id).connectedEdges().map(e => ({
  node: e.source().id() === current.id ? e.target() : e.source(),
  relation: e.source().id() === current.id ? e.data('label') : `is ${e.data('label')} by`,
}));

Worked example

Permalink to "Worked example"

An SRE team’s service map had 60 services drawn as a force-directed graph. A blind engineer on call could not answer “what depends on the auth service?” during incidents. The team added traversal: Tab into the map lands on the most connected service, Right lists neighbours with directed relations (“Checkout, calls Auth”), Enter moves, Backspace returns, and slash searches by name. An edge table below the map supports sorting and filtering, and the map’s caption summarises the graph. During the next incident, the engineer found Auth’s eleven dependants in under a minute.

Testing checklist

Permalink to "Testing checklist"

Design system notes

Permalink to "Design system notes"

Build graph accessibility as a layer over a graph model, independent of the renderer: GraphNavigator (focus, history, neighbours, search) plus message templates. Renderers then only draw the focus highlight, so switching from SVG to canvas does not lose accessibility.

FAQ

Permalink to "FAQ"
Is there an ARIA role for graphs?

No. Use a named application or group region with a documented key model, or expose the graph through a tree or grid representation.

Should every node be focusable?

Not as separate tab stops. One tab stop and arrow or menu navigation is far more usable.

How do I choose the starting node?

The most connected node, a node the user searched for, or the one relevant to the current task (the selected account, the failing service).

What about very large graphs?

Exploration still works locally. Add search and a summary of clusters or communities so users know where to start.

Should edges be focusable?

Usually not. Announce edges as relations when listing neighbours; that covers what users need.

How do sighted keyboard users see focus?

Draw a thick outline or halo on the focused node and highlight its edges, and scroll or pan the view to keep it visible.

Permalink to "Related"

← Back to Accessible Network & Graph Diagrams