@inclunet/mermaid-a11y
Accessible keyboard navigation for Mermaid diagrams, packaged as a React component.
The component always renders the diagram itself from the chart source. Navigation is driven by Mermaid’s parse result (nodes, edges, direction), not by scraping the SVG. The SVG is only the visual projection: focus, highlight, and ARIA are applied there.
Try it
Live demo (GitHub Pages): https://inclunet.github.io/mermaid-a11y/ — includes an interactive playground, keyboard reference, copy-paste examples, and full props table.
Locally:
npm install
npm run demo
That starts a Vite playground at http://localhost:5173 with sample diagrams, an editable source box, a visible copy of the screen-reader announcement, and inline documentation. Tab to the diagram and use the arrow keys.
Documentation
- Live demo + docs: https://inclunet.github.io/mermaid-a11y/ (playground, keyboard map, React/core examples, props, accessibility notes)
- This README: install, API summary, architecture, publishing
Unsupported diagram types
Keyboard navigation is built for flowchart graphs today. Other Mermaid types (sequence, class, state, pie, gantt, …) still render normally: the component draws the same SVG Mermaid would without this plugin, skips the navigator, and announces that navigation is unavailable (visually hidden status for assistive tech).
Headless callers can check navigable on the result of renderAccessibleDiagram:
const result = await renderAccessibleDiagram({ chart, mermaid, host });
if (result.navigable) {
createNavigator({ graph: result.graph, svg: result.svg /* … */ }).attach();
}
Install
mermaid is a mandatory peer dependency. Install it together with this package so missing Mermaid fails at install/build time, not silently at runtime.
npm install @inclunet/mermaid-a11y mermaid
React 18+ is required for the React entry. It is an optional peer if you only import the framework-agnostic core or Mermaid adapter:
import { createNavigator } from '@inclunet/mermaid-a11y/core';
import { renderAccessibleDiagram, resolveMermaid } from '@inclunet/mermaid-a11y/mermaid';
Headless usage (no React)
Render from Mermaid source, then attach the navigator:
import { createNavigator } from '@inclunet/mermaid-a11y/core';
import { renderAccessibleDiagram, resolveMermaid } from '@inclunet/mermaid-a11y/mermaid';
const mermaid = await resolveMermaid();
const host = document.getElementById('svg-host')!;
const { graph, svg } = await renderAccessibleDiagram({
chart: `flowchart LR\n A[Start] --> B[Done]`,
mermaid,
host,
});
const navigator = createNavigator({
graph,
svg,
container: document.getElementById('diagram')!,
liveRegion: document.getElementById('live')!,
instructionsId: 'diagram-help',
locale: 'en',
});
navigator.attach();
resolveMermaid() loads the peer mermaid package. Pass your own instance when the host already has one: resolveMermaid(mermaid).
Per-diagram config is supported via the optional config argument on renderAccessibleDiagram (same as the React config prop — never calls global mermaid.initialize()).
Host announcer and live region
By default each diagram mounts its own polite live region and plays the Assistente-style boundary bump. Hosts that already own a global announcer (or a shared live region) can take over:
Precedence: onAnnounce → host liveRegion → internal live region.
// Callback: host owns re-announce, priority, and surface arbitration.
<MermaidA11y chart={chart} onAnnounce={(message) => assistente.announce(message)} />
// Or point at an existing live region element (library still does clear-then-set on that node).
<MermaidA11y chart={chart} liveRegion={document.getElementById('app-live')!} />
When either external channel is set, the React shell does not mount an internal aria-live, role="status", or role="alert". Status and render failures are delivered through the same channel.
Boundary feedback uses the existing onBoundary prop for the bump sound only: omit it for the default marimba; provide it (including a no-op) to own the sound. Boundary text still follows the announcement channel above (onAnnounce / liveRegion / internal).
Headless createNavigator accepts the same announce options (onAnnounce and/or liveRegion; at least one is required via the NavigatorOptions type). For interface … extends, use NavigatorOptionsBase and intersect your own announce channel, since NavigatorOptions is a union.
Basic usage
import { MermaidA11y } from '@inclunet/mermaid-a11y';
export function CheckoutFlow() {
return (
<MermaidA11y
chart={`
flowchart LR
A[Start] -->|go| B[Process]
B --> C[Done]
A -->|fail| D[Error]
`}
/>
);
}
Tab to the diagram, then use the arrow keys. A live region announces the current node or connection.
Custom per-diagram config
Pass config for this diagram only. The component never calls mermaid.initialize(), so it will not override the host app’s theme or securityLevel.
<MermaidA11y
chart="flowchart TD\n A --> B"
config={{
theme: 'neutral',
flowchart: { htmlLabels: false },
}}
/>
Multiple Mermaid instances
By default the component uses the peer mermaid package. If the host must keep more than one Mermaid copy, pass the instance as an escape hatch:
import mermaid from 'mermaid';
<MermaidA11y chart={chart} mermaid={mermaid} />
There is no bundled fallback copy of Mermaid.
Custom highlight renderer
The core only sets logical state (data-a11y-focus, aria-activedescendant). How that state is painted is injected:
import { MermaidA11y, type HighlightRenderer } from '@inclunet/mermaid-a11y';
const highlightRenderer: HighlightRenderer = {
attach({ svg }) {
const layer = document.createElementNS('http://www.w3.org/2000/svg', 'g');
layer.setAttribute('data-highlight', 'true');
layer.setAttribute('aria-hidden', 'true');
svg.appendChild(layer);
},
update(target) {
// Paint from target.element (node <g> or edge <path>).
// Do not mutate shapes Mermaid generated; add/remove overlay nodes instead.
void target;
},
detach() {
document.querySelector('[data-highlight="true"]')?.remove();
},
};
<MermaidA11y chart={chart} highlightRenderer={highlightRenderer} />
The default renderer draws a white halo plus a thicker blue stroke so the indicator does not rely on color alone (WCAG 1.4.1) and meets a visible focus appearance (WCAG 2.4.11). CSS outline is not used on SVG shapes.
Language
Announcements, keyboard instructions, and ARIA names ship in English, Spanish, and Portuguese.
Pass locale to set the language from the host app. If you omit it (or pass an unsupported tag), the component detects the user language from navigator.languages, then navigator.language, then document.documentElement.lang. Tags like pt-BR and es-MX map to Portuguese and Spanish. Anything else falls back to English.
<MermaidA11y chart={chart} locale="pt-BR" />
<MermaidA11y chart={chart} locale="es" />
<MermaidA11y chart={chart} />
Keyboard map
This model is not the ARIA APG tree or grid pattern. Instructions are exposed with aria-describedby so the keys are discoverable.
| Key | Action |
|---|---|
| Down Arrow | Next outgoing connection of the current node |
| Up Arrow | Previous outgoing connection (bumps on the first) |
| Right Arrow | Follow the selected connection outward (source → target) |
| Left Arrow | Go back to the previous node on the current path |
| D | Hear this node’s description |
| Space | Repeat this node’s description and the current connection |
| T | Toggle navigation tips on or off |
| Home | Jump to the first node |
| End | Jump to the last node |
After moving outward with Left/Right, the live region speaks the full node description (same as D) followed by the selected connection. Up/Down announce only the connection itself — an edge label such as go., or the target name when there is no label. With tips on (default), each move also appends short keyboard hints; press T to toggle tips off for a quieter flow. Press D to hear the node description again. Press Space to repeat that detail plus the current connection. At either end a bump sound plays (the same marimba used on Inclunet Assistente toolbars), and the live region announces the boundary. Hosts can replace the sound with onBoundary.
Up/Down never reconstruct the graph from SVG geometry; they walk logical outgoing edges. Left/Right use the navigation path and the selected edge’s source/target from the parse result.
Public API
React
<MermaidA11y
chart={string} // required: Mermaid source
config?={MermaidConfig} // per-diagram only
mermaid?={MermaidInstance} // optional escape hatch
locale?={string} // en | es | pt, or a BCP 47 tag
onAnnounce?={(message) => void} // host announcer; skips internal live region
liveRegion?={HTMLElement} // host live region when onAnnounce is omitted
onBoundary?={() => void} // bump sound only; text uses announce channel
focusStrategy?={'activedescendant' | 'roving'} // default: 'activedescendant'
highlightRenderer?={HighlightRenderer}
className?={string}
aria-label?={string}
/>
Mermaid adapter (@inclunet/mermaid-a11y/mermaid)
| Export | Description |
|---|---|
renderAccessibleDiagram({ chart, mermaid, host, config? }) |
Render SVG + build GraphModel from the parse result. Sets navigable: false (SVG kept) when the type is unsupported or extraction fails |
resolveMermaid(instance?) |
Load the peer mermaid package or validate a host instance |
RenderAccessibleOptions, RenderAccessibleResult |
Types for the render call (navigable on the result) |
Also re-exported from the main entry for convenience.
Core (@inclunet/mermaid-a11y/core)
createNavigator, traversal helpers, announcements, focus strategies, and highlight renderer factory — framework-agnostic.
The public surface is intentionally small.
Accessibility model
Focus: Model B (default)
- One focusable container (
tabindex="0"). - Arrow keys move a logical cursor.
aria-activedescendantpoints at the current node or edge id.- DOM focus stays on the container, so
:focuson nodes does not fire. Highlight is driven bydata-a11y-focus="true". - The container has its own visible
:focus-visiblering (WCAG 2.4.11).
focusStrategy="roving" is implemented behind the same traversal machine for hosts that need roving tabindex. It is not the default.
Role
The widget uses role="group", not role="application".
application is reserved for widgets that must intercept most keys and pull the user out of screen-reader browse mode. This component only handles arrow keys (and Home/End) while the container is focused. group plus aria-label / aria-describedby is the conservative, more predictable choice.
Nodes and edges keep role="img" and aria-label for the active item only. In the default activedescendant strategy, every other shape is aria-hidden="true", so browse mode does not expose a long list of diagram images — navigation happens through the focusable container, the live region, and arrow keys. Use Tab to focus the diagram group; individual shapes are not meant to be visited one by one in browse mode.
focusStrategy="roving" leaves all items in the accessibility tree and moves DOM focus between them for hosts that need that model.
Screen-reader caveat
aria-activedescendant pointing at SVG <g> / <path> children is less tested than the same pattern in HTML. This library degrades by also updating a polite live region on every move, so the user still hears the new node or connection if the descendant id is ignored.
Tested screen readers
Please verify in your environment and add findings via PR. The combinations we design against:
| Screen reader | Browser | OS | Notes |
|---|---|---|---|
| NVDA | Firefox, Chrome | Windows | Live region + aria-activedescendant; browse vs focus mode when the group is focused |
| JAWS | Chrome, Edge | Windows | Same as NVDA; confirm the live region if SVG descendants are silent |
| VoiceOver | Safari | macOS | Confirm aria-activedescendant on SVG; live region is the fallback |
If a combination fails, the live region and aria-current="true" on the logical target are the fallback path. Please file an issue with SR / browser / OS versions.
Architecture
Three layers, with a hard boundary between Mermaid and the a11y core:
- Mermaid adapter —
chartin;{ nodes, edges, direction, svgId map }out. Coupled to Mermaid’s parse API and versioned with it. Graph structure always comes from the parse result (getData/getVertices/getEdges). SVG ids are resolved at render time. - Navigation core — framework-agnostic TypeScript. Traversal state machine, keyboard map, focus strategy, ARIA, live-region text. Unit-tested without React.
- React shell — maps props → parse + render →
createNavigator. Re-installs the core whenchart/configchange. Removes listeners and highlight overlays on unmount.
Do not attach this library to a third-party pre-rendered SVG. There is no enhancer mode and no MutationObserver on foreign markup.
Branch protection
main is protected so everyone except the repository admin/owner contributes through reviewed pull requests with passing CI. See CONTRIBUTING.md and docs/github-ruleset.json.
Publishing
GitHub Actions publishes to npm on a published GitHub Release (or a v* tag). Add an NPM_TOKEN repository secret. The workflow runs npm publish --access public --provenance.
License
MIT