A data table flattens a chart into rows. But a chart hasstructure — series, axes, groups — and that structure is part of the meaning. Structured navigation exposes the chart as a navigable tree (chart → axes/series → data points) that a screen-reader user descends with arrow keys, hearing where they are at each level: "Series West, collapsed" → expand → "point 2 of 3: February, 5,100." It's the model fromOlliandData Navigator: a navigation structure that lives in the accessibility tree,uncoupled from how the chart is rendered — which is exactly what a canvas chart needs, since its pixels expose nothing to assistive tech.
Why a tree, not a table
A flat list of 200 points is technically complete and practically unusable — Chartability calls this out as "navigation is tedious." A tree lets a reader get the shape first (the series-level summary, generated bydescribeChart()), then drill into only the branch they care about. Overview, then detail, on demand. The same principle that makes a good table of contents beats a wall of text.
Try it
Click into the tree and use ↑/↓ to move, → to expand/descend,← to collapse/ascend, Home/End to jump. (Shown visibly here; it's screen-reader-only by default.)
- ▾ A line chart of sales by month, split by region. sales ranges from 2,200 (Jan) to 6,800 (Mar), with a mean of 4,000 across 6 points.
- X axis: month, Jan to Mar (6 points).
- Value axis: sales, 2,200 to 6,800.
- ▸ Series West: sales ranges from 4,200 (Jan) to 6,800 (Mar), with a mean of 5,366.67 across 3 points.
- ▸ Series East: sales ranges from 2,200 (Jan) to 3,100 (Feb), with a mean of 2,633.33 across 3 points.
Each row is a real treeitem with aria-level,aria-setsize/aria-posinset ("2 of 3"), andaria-expanded. The series rows carry a generated statistical summary; the leaves carry the actual values. Collapsed branches keep the deep data out of the way until asked for.
Wiring: the ChartContainer opt-in
Like descriptions, structured navigation is an opt-in at theChartContainer layer — the place full accessible decoration belongs — not something bolted onto every bare chart. Give the container a chartConfig and setnavigable:
JSX
import { ChartContainer, LineChart } from "semiotic" <ChartContainer title="Sales by region" chartConfig={{ component: "LineChart", props }} navigable // screen-reader-only navigation tree // navigable={{ visible: true }} // also render it visibly // navigable={{ maxLeaves: 100 }} // cap leaves per branch > <LineChart {...props} /> </ChartContainer>
Enabling it flips the audit'scompromising.navigable-structurefinding to a pass — including for hierarchy charts (treemaps, trees), whose built-in keyboard navigation is otherwise flat.
Building the tree yourself
buildNavigationTree() is a pure function — use it to render your own navigation surface, feed aData Navigatorinstance, or drive a custom multimodal experience. AccessibleNavTreeis the ready-made ARIA tree renderer.
JSX
import { buildNavigationTree, AccessibleNavTree } from "semiotic" const tree = buildNavigationTree("LineChart", { data, xAccessor: "month", yAccessor: "sales", lineBy: "region", }) // tree: { id, role: "chart"|"axis"|"series"|"datum", label, level, value?, datum?, children? } <AccessibleNavTree tree={tree} label="Sales by region" onActiveChange={(node) => highlightMatchingMark(node.datum)} // optional sync />
The onActiveChange callback fires as the user moves through the tree, handing you the active node (and its datum) — the hook for syncing a visual highlight back onto the chart.
Bidirectional sync
useNavigationSync closes the loop: navigate the tree and the matching mark highlights; hover or click the chart and the tree's active node follows. It rides the existing selection and observation stores, so no provider is needed — the chart just takes achartId and selection; the tree takesactiveId and onActiveChange.
Try it: click a row in the tree to highlight its bar; hover a bar to move the tree's cursor.
- ▾ A bar chart of sales by month. sales ranges from 2,100 (May) to 9,100 (Apr), with a mean of 5,460 across 5 points. The highest month is Apr and the lowest is May.
- Category axis: month, Jan to May (5 categories).
- Value axis: sales, 2,100 to 9,100.
- Jan: 4,200
- Feb: 5,100
- Mar: 6,800
- Apr: 9,100
- May: 2,100
JSX
import { buildNavigationTree, AccessibleNavTree, useNavigationSync, BarChart } from "semiotic" function SyncedChart({ data }) { const tree = React.useMemo( () => buildNavigationTree("BarChart", { data, categoryAccessor: "month", valueAccessor: "sales" }), [data] ) const sync = useNavigationSync({ tree, chartId: "sales", matchFields: ["month"] }) return ( <> <BarChart data={data} categoryAccessor="month" valueAccessor="sales" chartId="sales" selection={sync.selection} /> <AccessibleNavTree tree={tree} activeId={sync.activeId} onActiveChange={sync.onActiveChange} /> </> ) }
matchFields are the datum keys that identify a mark (it defaults to the leaf datum's primitive keys). Tree → canvas highlights via a field-value selection; canvas → tree maps the hovered datum back to its leaf and auto-expands the path to it.
Annotations in the tree
An author-placed annotation is author intent in its purest form, so it shouldn't be reachable only when some external code callsfocusAnnotation. When a chart carries annotations,buildNavigationTree adds an Annotationsbranch — a screen-reader user encounters the notes while traversing, the same way they meet the axes and series. Each node reuses the prose vocabulary fromdescribeChart()(so the tree and the description speak the same language), surfaces the note's provenance and editorial status inline (an agent-suggested threshold, adisputed note), and skips retracted ones.
Expand Annotations to hear the author's notes — the agent-suggested threshold and the contested dip announce their provenance and editorial status.
- ▾ The author has marked 3 features on this chart: a callout labeled "Quarter-end peak", an AI-suggested threshold line labeled "Target", and a callout labeled "Dip is contested". A line chart of sales by month. sales ranges from 4,200 (Jan) to 6,800 (Mar), with a mean of 5,175 across 4 points. Overall sales climbs to a peak of 6,800 (Mar), then falls to 4,600 (Apr). This is an alerting chart; the peak of 6,800 at Mar is the point to investigate.
- X axis: month, Jan to Apr (4 points).
- Value axis: sales, 4,200 to 6,800.
- Jan: 4,200
- Feb: 5,100
- Mar: 6,800
- Apr: 4,600
- ▸ Annotations: 3 marked features.
JSX
const tree = buildNavigationTree("LineChart", { data, xAccessor: "month", yAccessor: "sales", annotations: [ { type: "callout", x: "Mar", label: "Quarter-end peak" }, { type: "y-threshold", y: 6000, label: "Target", provenance: { authorKind: "agent" } }, { type: "callout", x: "Apr", label: "Dip is contested", lifecycle: { status: "disputed" } }, ], }) // → root // ├ X axis / Value axis / data points… // └ Annotations: 3 marked features. // ├ A callout labeled "Quarter-end peak". // ├ An AI-suggested threshold line labeled "Target". // └ A callout labeled "Dip is contested" (disputed).
It works on every family — even network, hierarchy, and geo charts that otherwise return a root-only node get their annotations branch, so the author's intent is always reachable.
Reaching an annotation's anchor
The branch lets a reader find a note in the structure; the flip side is jumping from a note to the data point it's about. Ananchored annotation — an AI note pinned to a specific data point, say — is only "multiplayer" for sighted readers unless a non-visual reader can get to the anchored point. Pass the chart's annotations touseNavigationSync and each one that anchors to a datum (it carries that datum's matchFields) resolves to a nav-tree leaf:
JSX
const sync = useNavigationSync({ tree, chartId: "sales", matchFields: ["month"], annotations }) sync.annotatedIds // Set<nodeId> — leaves that carry a note; mark them in the tree sync.focusAnnotation(0) // jump the tree + canvas to the 1st annotation's anchor sync.focusAnnotation(note) // …or pass the annotation object; returns false if it doesn't anchor
focusAnnotation moves the controlled activeId to the anchored leaf and highlights the mark on the canvas, so a screen-reader user lands exactly where the note lives. Threshold/band annotations that aren't pinned to one datum simply don't resolve.
Coverage
Full trees for XY, bar, part-to-whole, and distribution families. For network, hierarchy, geo, and single-value charts,buildNavigationTree() currently returns a root node with an L1 label (plus the Annotations branch, if any) rather than a fabricated hierarchy — enabling navigable still gives those charts a labeled entry point and any author notes, and richer data structure for those families is on the roadmap.
Bidirectional sync
A nav tree is most useful wired to the canvas: focusing a leaf should highlight the matching mark, and hovering a mark should move the tree.useNavigationSync rides the module-global selection and observation stores, so no provider is needed — give the chart a chartId and selection, give the treeactiveId / onActiveChange.
JSX
import { useNavigationSync, AccessibleNavTree } from "semiotic" const sync = useNavigationSync({ tree, chartId: "sales", annotations }) <LineChart chartId="sales" selection={sync.selection} {...props} /> <AccessibleNavTree tree={tree} activeId={sync.activeId} onActiveChange={sync.onActiveChange} />
It returns { activeId, onActiveChange, selection, annotatedIds, focusAnnotation }. Tree → canvas highlights the matching mark (a field-value selection); canvas → tree maps the hovered or clicked datum back to its leaf. When you pass the chart's annotations, an anchored note resolves to its nav leaf: annotatedIds are the leaves that carry a note, and focusAnnotation(annotation | index) jumps both the tree and the canvas to the anchored point — so a non-visual reader can reach an AI's anchored annotation. Also exported fromsemiotic/ai.
Reception telemetry
Traversal is also a signal. When theconversation-arc store is enabled, AccessibleNavTree emits anav-node-focused event each time a reader moves to a node (keyboard or click) and a nav-branch-expanded event on expand/collapse, correlated to the chart by the tree'schartId prop. It's the framework's first reception-side behavioral measure — which structural nodes a non-visual (or AI) reader actually visits, rather than only what we render. Recording is zero-overhead while the arc store is disabled, and externally-driven active changes (a canvas hover syncing into the tree) are not counted as the reader's own traversal.