"This chart is accessible" is one of the easier claims to make and one of the hardest to falsify. A bar chart drawn on a <canvas> is, to a screen reader, a single opaque image with no DOM to inspect, no elements to tab through, and nothing an automated checker like axe can even see. So how do you know? Usually you don't, until someone who relies on a screen reader tells you. We wanted a way to ask the question earlier, wheter in a unit test, in CI, or in the moment an AI agent generates a chart, without pretending a machine can answer all of it. The result is auditAccessibility(), and the most interesting thing about it is what it refuses to tell you.
Why this matters
Data visualization accessibility has a measurement problem. The WCAG success criteria were written for documents and forms; they don't say much about whether a trend is perceivable without sight. The field's best answer isChartability, Frank Elavsky's heuristic framework. It extends WCAG's POUR principles (Perceivable, Operable, Understandable, Robust) with three more that are specific to data:Compromising (is there a real non-visual path to the same information?),Assistive (does the interface reduce labor, e.g. by describing the trend instead of making you reconstruct it point by point?), and Flexible (does it respect the user's own settings?). Fifty heuristics; fourteen marked critical.
Chartability is a checklist for a human auditor, and it says so plainly: you cannot "pass" it 100%, and it assumes you'll test with real assistive technology. That posture is the whole point. The failure mode for accessibility tooling isn't missing a check, it'saccessibility theater: a green dashboard that lulls a team into shipping something no blind user can actually use. Any honest static audit has to draw a hard line between "I checked this and it's fine," "I checked this and it's broken," and "I cannot check this, please go test it yourself." Most tools blur the third into the first. Semiotic tries not to.
The audit, run live
Here's the real output of auditAccessibility() computed in your browser, right now for a perfectly ordinary, perfectly under-labeled line chart: data, accessors, a faint color, and no title or description. The kind of config an LLM hands back on its first try.
✗ LineChart: accessibility audit (Chartability POUR-CAF) 3 blocking failure(s) — 8/13 critical heuristics pass · 4 warning(s) · 4 to verify manually PERCEIVABLE ✗ perceivable.low-contrast [critical]: 1 color(s) fall below 3:1 contrast vs #ffffff: #eeeeee (1.2:1). → Darken (light background) or lighten (dark background) those colors, or use COLOR_BLIND_SAFE_CATEGORICAL. ✓ perceivable.content-only-visual [critical]: A screen-reader data table + live region expose the data non-visually (accessibleTable is on). ✓ perceivable.small-text [critical]: Semiotic's default tick and axis-label fonts are 12px, meeting Chartability's 9pt/12px floor. ○ perceivable.seizure-risk [critical]: No flashing detected statically. Confirm no element flashes more than 3×/sec. → Manual check — most static charts pass trivially. OPERABLE ✓ operable.single-input-modality [critical]: Built-in keyboard navigation (arrows/Home/End/PageUp-Down/Enter) mirrors mouse hover. ✓ operable.tab-stops: The chart takes a single tab stop and navigates data with arrow keys — the recommended pattern for dense charts (no per-datum tab stops to wade through). ⚠ operable.interaction-cues [critical]: Chart is interactive but nothing explains how to use it. → Describe the interaction in a summary or nearby text (keyboard navigation, what hover/click reveals). ✓ operable.controls-override-at [critical]: Keyboard handlers fire only while the chart has focus, so they don't hijack page/app screen-reader shortcuts. ✓ operable.focus-indicator: A shape-adaptive focus ring (var(--semiotic-focus)) marks the focused element. UNDERSTANDABLE ✗ understandable.title-summary-caption [critical]: No title, description, or summary — the screen reader falls back to a generic label. → Add title/description/summary on the chart, or wrap it in a ChartContainer (the opt-in layer for title/caption/description chrome). ✗ understandable.explain-purpose [critical]: Nothing explains the chart's purpose or how to read it. → Add a summary/description, or wrap in a ChartContainer with a title and the describe option (the opt-in full-accessibility layer). ⚠ understandable.axis-labels: Missing axis label: xLabel, yLabel. Ticks alone may not name the variable. → Set xLabel and yLabel to name each axis's variable and units. ROBUST ○ robust.conforms-to-standards: WCAG 2.1 / Section 508 conformance can't be settled from config alone. → Run an automated checker (axe) on the rendered output and test with real assistive tech. ○ robust.semantically-valid: Whether interactive elements expose correct roles/names is a render-time, screen-reader question. → Verify with a screen reader that buttons read as buttons, the chart as an image/group, etc. ✓ robust.fragile-technology-support: Charts render on canvas with an SVG overlay and render to SVG in SSR, so access isn't tied to one rendering path. COMPROMISING ✓ compromising.table [critical]: A human-readable data table is provided (accessibleTable). ⚠ compromising.table-static: The data table is read-only — not downloadable, sortable, or filterable. → Wrap the chart in a ChartContainer and enable its data-download action (opt-in, so deployments can withhold it where export isn't allowed). ✓ compromising.shareable-state: Chart state serializes via toConfig/toURL/copyConfig. ✓ compromising.navigable-structure: Keyboard navigation steps through points and switches series/groups. ASSISTIVE ✓ assistive.data-density [critical]: 3 data points — a reasonable density for non-visual consumption. ⚠ assistive.features-described: No text describes the visually apparent trends, extrema, or outliers. → Enable ChartContainer's describe option (auto-generates via describeChart()), or write a summary covering the key trend and notable points. ✓ assistive.skippable-navigation [critical]: A "Skip to data table" link lets screen-reader users bypass point-by-point navigation. FLEXIBLE ✓ flexible.user-style-respected [critical]: Styling flows through CSS custom properties and honors forced-colors mode, so user/user-agent style changes cascade in. ✓ flexible.reduced-motion: prefers-reduced-motion is auto-detected; transitions fast-forward and looping animation stops. ○ flexible.zoom-reflow: Fixed width/height — verify the chart survives browser zoom and reflow without clipping or loss of function. → Use responsiveWidth/responsiveHeight so the chart reflows to its container. Ref: Chartability (POUR-CAF) — https://chartability.github.io/POUR-CAF/. Not a pass/fail cert; pair with manual screen-reader testing (NVDA+Firefox, JAWS+Chrome, VoiceOver+Safari).
Three hard failures, all author-actionable: no title or summary (so a screen reader announces "XY chart" and nothing else), no explanation of how to read it, and a stroke color at 1.2:1 against white. But look at the checkmarks. The chart already passes most of itscritical heuristics and the author did nothing to earn them.
Semiotic was already the most accessible chart library but that was a low bar
Every Semiotic chart ships keyboard navigation, a shape-adaptive focus ring, a "skip to data table" link, a screen-reader data table that shows real values, reduced-motion and forced-colors handling, and serializable state. Those satisfy a stack of Chartability criticals like single input modality, AT-shortcut safety, "is there a table?", skippable navigation, and user-style respect. A good audit shouldn't make you prove what the toolkit guarantees; it should tell you what you're getting for free and then spend its attention on the gaps only you can close. So the report leads with passes, not just failures. The signal an author needs is the short list of things that are actually theirs to fix.
The status that matters most is "manual"
Every finding lands in one of five buckets, and the design lives in the distinctions between them:
- pass - provable from the config, or guaranteed by a built-in.
- fail - provable breakage. A critical fail makes the whole audit
ok: false and exits the CLI non-zero. - warn - a likely problem or a default worth a second look. It doesn't block.
- manual - this is the honest one. The thing genuinely cannot be settled from a config, so the audit names the Chartability test to run by hand instead of guessing.
- not-applicable - the heuristic doesn't fit this chart (a data table for a single big number).
A static analyzer can confirm that two hex colors clear 3:1. It cannot confirm that your themed, CSS-variable-driven colors clear 3:1 once they resolve in the browser, and it certainly cannot confirm that the chart reads correctly in NVDA. Tools that paper over that gap and report "contrast: pass" when they never saw the rendered pixels are worse than no tool, because they manufacture false confidence. So when the audit can't see something, it says so, and tells you exactly which manual test closes the gap. The manualitems aren't the audit failing to do its job. They're the audit doing the part of its job that matters most: drawing the boundary of what automation knows.
A couple of checks are quietly doing more than a checklist lookup. Contrast reuses the same WCAG math the library's diagnoseConfig already uses, so there's one source of truth. And the reading-level heuristic that Chartability asks for runs a Flesch–Kincaid estimate over your description and summary text, because "is this readable?" is a question you can partially answer from the words themselves.
Three surfaces, one function
The audit is a pure function of (component, props) with no DOM, so it's SSR-clean which is what lets it run everywhere the decision matters:
// 1. In code / tests import { auditAccessibility } from "semiotic/utils" const { ok, summary, findings } = auditAccessibility("LineChart", props) // 2. In CI (exits non-zero on a critical fail) npx semiotic-ai --audit-a11y '{"component":"LineChart","props":{...}}' // 3. As an MCP tool, so an AI agent can grade its own chart // before handing the code back to youThat last one is why the project prioritized this first. Semiotic is increasingly used by models generating charts; an audit the model can call on its own output turns "I hope this is accessible" into a checkable step in the loop that includes catching the missing-title failure you saw above before it ever reaches a human.
A deliberate non-feature: forced data download
Chartability asks that the accessible table be downloadable or sortable. The obvious move is to bolt a "download CSV" button onto every chart. We didn't, on purpose. Plenty of deployments must be able to withhold raw-data export for governance, licensing, privacy or other reasons and a charting library that forces exfiltration onto every chart is a non-starter for them. So data download is an opt-inChartContainer action, and the audit's remediation points there rather than reporting a hard failure. Accessibility work that ignores the constraints of real deployments doesn't ship, and a feature that doesn't ship helps no one.
When to reach for it (and when not)
Reach for the audit as a fast triage and a regression gate: in CI to stop a chart from losing its title, in an agent loop to self-check generated configs, as a teaching aid that explains why each heuristic matters. Do not reach for it as proof of accessibility. A clean audit means "no config-level problems I can see and here are the manual tests still owed". It is the start of the work, not a certificate. Themanual items are homework, not noise, and the one warning worth staring at is "visually apparent features are not described".
The pattern beyond charts
The honest-static-analyzer shape generalizes well past data viz:
- Type checkers and linters. The good ones distinguish "definitely wrong" from "can't tell"
any and // eslint-disable are themanual status with a different name. - Security scanners. SAST tools that flag "needs human review" instead of asserting safe-or-exploitable are trusted; the ones that cry wolf or rubber-stamp get ignored.
- Other canvas / WebGL visualization. Map libraries, game UIs, and WebGL dashboards have the same "no DOM to audit" problem and need the same config-level grading plus explicit manual boundaries.
- Performance budgets. A bundle-size check can prove a number; it can't prove the page feels fast. Same line between the measurable and the experiential.
In every case the credibility comes from the tool knowing the edge of its own knowledge and saying so.