Adjacency Tables as Network Graph Alternatives
Permalink to "Adjacency Tables as Network Graph Alternatives"A network diagram’s equivalent is not a paragraph; it is data. Every graph is a set of nodes and edges, and both can be presented as tables that screen readers navigate well. An edge table (“Checkout calls Auth service”) is complete and filterable. A node table with a “Connects to” column gives a per-node view. Either one, done well, lets screen reader users answer the same questions sighted users answer from the picture — and often lets everyone answer them faster.
This guide belongs to accessible network & graph diagrams.
Spec reference
Permalink to "Spec reference"- SC 1.1.1 Non-text Content (A) — complex images need a long description or equivalent; for data graphics, a data table is the most complete equivalent.
- SC 1.3.1 Info and Relationships (A) — table headers and row headers must be marked up so each cell’s meaning is announced.
- SC 1.4.1 Use of Color (A) — edge types shown by colour in the graph need words in the table.
- SC 2.4.6 Headings and Labels (AA) — the table caption says what the table represents and that it mirrors the graph.
When to use — and when not to
Permalink to "When to use — and when not to"Provide an edge table for every network diagram that conveys relationships users must act on — dependency maps, flow networks, organisational links. Provide a node table when users mainly ask per-node questions (“how connected is this account?”), with degree and a list of neighbours.
Do not offer only an adjacency matrix (nodes × nodes grid of ticks) for more than a dozen nodes — screen reader users must cross huge empty grids. Do not offer a table without filtering for large graphs; 2,000 rows of edges without filters is complete but unusable.
The misapplication: a “Data” tab that dumps the graph’s JSON. It is complete and machine-readable, and nearly unusable by ear.
Annotated code example
Permalink to "Annotated code example"<section aria-labelledby="edges-h">
<h3 id="edges-h">Service connections (table version of the graph)</h3>
<label for="edge-filter">Filter by service</label>
<input id="edge-filter" type="search" aria-describedby="edge-count">
<p id="edge-count" role="status">96 connections</p>
<table>
<caption>Service connections: 96 directed edges between 42 services</caption> <!-- SC 2.4.6 -->
<thead>
<tr>
<th scope="col">From</th><th scope="col">Relation</th> <!-- SC 1.3.1 -->
<th scope="col">To</th><th scope="col" aria-sort="descending">Calls per minute</th>
<th scope="col">Type</th>
</tr>
</thead>
<tbody>
<tr><th scope="row">Checkout</th><td>calls</td><td>Auth service</td><td>1,240</td><td>Synchronous</td></tr> <!-- type in words (SC 1.4.1) -->
</tbody>
</table>
</section>
filter.addEventListener('input', debounce(() => {
const q = filter.value.trim().toLowerCase();
const rows = edges.filter(e => !q || e.from.toLowerCase().includes(q) || e.to.toLowerCase().includes(q));
renderRows(rows);
count.textContent = `${rows.length} connections${q ? ` involving “${filter.value.trim()}”` : ''}`;
}, 400));
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Event | Expected announcement | AT deviations |
|---|---|---|
| T (next table) | “Service connections: 96 directed edges…, table, 97 rows, 5 columns” | — |
| Move across a row | “Checkout, From… calls, Relation… Auth service, To” | Headers read on column change |
| Filter “auth” | “11 connections involving ‘auth’” | — |
| Sort by Calls per minute | Sort state announced | See sortable table guidance |
Integration context
Permalink to "Integration context"Tables are the complete alternative; keyboard traversal of node-link graphs is the exploratory one, and announcing graph structure summaries the overview. Showing and hiding the table follows show-as-table toggles for charts, and sorting follows sortable & filterable data grids.
Gotchas
Permalink to "Gotchas"Undirected graphs. If edges have no direction, list each edge once and use “connected to” — and let filters match either end.
Multi-edges. Two services may have several relations. Give each its own row rather than concatenating relations in one cell.
Graph and table drift. Generate both from the same model; a hand-maintained table goes stale.
Common mistakes
Permalink to "Common mistakes"- Offering an adjacency matrix for large graphs.
- Omitting direction or relation type.
- Colour-coded edge types with no Type column.
- No filter, so the table is complete but impractical.
Framework notes
Permalink to "Framework notes"Most graph libraries hold the edge list you need: Cytoscape.js cy.edges().map(e => e.data()), D3 force simulations’ links array, vis-network DataSet of edges. Render the table from that data with your grid component; if you use TanStack Table, column filters on From and To give “what depends on X” for free.
const rows = cy.edges().map(e => ({ from: e.source().data('name'), relation: e.data('label'), to: e.target().data('name'), rate: e.data('rate') }));
Worked example
Permalink to "Worked example"A fraud team’s link-analysis graph showed accounts connected by shared devices, addresses and payments. Analysts using screen readers could not use it. The team added an edge table — From account, Relation (“shares device with”, “sent payment to”), To account, Amount, First seen — with filters on either account and relation type. Analysts now start from a flagged account, filter the table to its edges, and follow suspicious relations by filtering again; the graph remains for sighted colleagues and for presentations.
Testing checklist
Permalink to "Testing checklist"Design system notes
Permalink to "Design system notes"Give the graph component a table view generated from its model, with standard columns (From, Relation, To, plus attributes) and built-in filters. Teams should not have to build it per diagram.
FAQ
Permalink to "FAQ"Edge table or node table?
Edge tables are complete and filterable; node tables suit per-node questions. Offer edge tables by default and add node tables when degree matters.
Is a table enough on its own?
It is a complete equivalent. Adding a summary and traversal makes the graph’s shape and local structure easier to grasp.
How do I show weights or volumes?
As columns, with units in the header, sortable.
Should the table be hidden by default?
It can be behind a toggle or tab, as long as it is easy to find and its control is named clearly.
Can the table be exported?
Yes, and it should be — it is the same data users would analyse elsewhere.
What about hierarchical graphs like org charts?
A tree or nested list is often a better equivalent than an edge table. See the org chart guidance.
How should node attributes appear in an edge table?
Put the most useful ones as columns (team, status, type) for both ends if needed — “From team” and “To team” — so users can filter cross-team connections without looking up each node.
Related
Permalink to "Related"- Keyboard traversal — exploring the graph itself
- Show-as-table toggles — revealing the table
- Org charts and hierarchy diagrams — when the graph is a tree