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 withrole="button"orrole="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.
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.
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 | — |
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.
Related
Permalink to "Related"- Adjacency tables — the complete alternative
- Graph structure summaries — the overview
- Keyboard navigation for scatter plots — exploration in point charts