Accessibility features aren't just for people with disabilities — they help everyone. Keyboard navigation lets power users explore data faster than mousing. Data summaries give anyone a quick statistical overview without scanning a chart. Reduced-motion support prevents distraction. High-contrast themes improve readability in bright sunlight. These features make charts more usable for all of us, in all contexts.
Semiotic renders charts on canvas for performance. Canvas-based rendering presents challenges because the visual output has no DOM structure for assistive technology to traverse. This page documents what Semiotic provides out of the box and what you should add in your application.
Semiotic's accessibility approach is informed by Chartability (Frank Elavsky's audit framework for data visualization accessibility) and aims to address its critical heuristics at the toolkit level. You can grade any chart configuration against those heuristics with the Chartability Audit — available as auditAccessibility(), the npx semiotic-ai --audit-a11y CLI, and an MCP tool — auto-generate rich screen-reader descriptions of a chart's statistics and trends with Chart Descriptions, and expose a screen-reader-navigable tree of the chart's structure with Structured Navigation.
Built-in Accessibility Features
ARIA Labels and Descriptions
Every chart renders with a two-level ARIA structure: role="group" on the outer interactive wrapper (handles keyboard focus and navigation) and role="img" on the inner graphic wrapper (read by assistive technology). SVG overlays include <title> and <desc>. Use title for a brief label, description for a detailed aria-label override, and summary for a screen-reader-only note with trend information or key takeaways.
Chart with Description and Summary
Revenue grew from $12,000 in January to $27,000 in June, with a brief dip in March to $14,000.
import { LineChart } from "semiotic" const frameProps = { /* --- Data --- */ data: [ { month: 1, revenue: 12000 }, { month: 2, revenue: 18000 }, // ... ], /* --- Process --- */ xAccessor: "month", yAccessor: "revenue", /* --- Customize --- */ title: "Monthly Revenue Trend", showGrid: true, /* --- Other --- */ description: "Line chart showing monthly revenue from January to June 2024", summary: "Revenue grew from $12,000 in January to $27,000 in June..." } export default () => { return <LineChart {...frameProps} /> }
JSX
// title → visible heading + fallback aria-label // description → overrides aria-label with detailed text // summary → screen-reader-only note (role="note") <LineChart data={salesData} xAccessor="month" yAccessor="revenue" title="Monthly Revenue Trend" description="Line chart showing monthly revenue from January to June 2024" summary="Revenue grew steadily with a dip in March" /> // Works on all chart types <BarChart title="Quarterly Sales" description="Bar chart comparing quarterly sales figures" ... /> <SankeyDiagram title="Budget Flow" summary="Engineering receives 45% of total budget" ... />
Keyboard Navigation
All charts are focusable via Tab. Once focused, use arrow keys to navigate data — the tooltip follows keyboard focus, and a shape-appropriate dashed ring highlights the active element.
Navigation is graph-based, not a flat list. In multi-series line charts, ArrowRight/Left moves along a series while ArrowUp/Down switches between series at the same x position. In stacked bar charts, ArrowRight/Left moves across categories and ArrowUp/Down moves between stacked segments. In network charts, ArrowRight/Left cycles through a node's neighbors and Enter follows the highlighted edge to that neighbor.
JSX
// Graph-based keyboard navigation — no props needed // XY charts (line, area, scatter): // ←/→ → move along series (x-axis order) // ↑/↓ → switch between series at nearest x position // Ordinal charts (bar, stacked bar): // ←/→ → move within group (across categories) // ↑/↓ → switch between groups (stack segments) // Network charts (force, sankey, chord): // ←/→ → cycle through neighbors // ↑/↓ → cycle neighbors in reverse // Enter → follow edge to highlighted neighbor // Geo charts (choropleth, proportional symbol): // ←/→/↑/↓ → spatial order (flat navigation) // All chart types: // PageDown/PageUp → skip by 10% of data points // Home/End → jump to first/last point // Escape → clear focus
Data Summary
Every chart includes a JIT data summary — a statistical overview plus a sample of rows (5 to start), computed on demand (not on every render). Screen reader users can activate a "View data summary" button inside the chart; sighted users can trigger it from the ChartContainer toolbar. Either way, the summary describes the data shape the way .describe() and .head() do in pandas: field ranges, means, unique categories, then a sample table. The table shows the actual data values (your accessor fields, e.g. month / sales), not pixel coordinates, and a "Show more rows" button pages through to the full dataset in bounded chunks — so a 50k-row chart never instantiates a giant table at once, but the data is never hidden either.
This is useful for everyone, not just assistive technology users. Product managers get a quick sanity check. Data scientists see if the data loaded correctly. Developers debugging a chart can see what the scene graph actually contains.
JSX
import { ChartContainer, LineChart } from "semiotic" // Toolbar button toggles a visible data summary panel <ChartContainer title="Revenue Trend" actions={{ dataSummary: true, export: true }} > <LineChart data={data} xAccessor="month" yAccessor="revenue" /> </ChartContainer> // The summary shows: // "72 data points. month: 1 to 12, mean 6.5. revenue: 12000 to 27000, mean 18500." // + a sample table of the real data values, pageable to all 72 rows // For screen readers, the summary is always available via a hidden button // (accessibleTable={true} by default). The ChartContainer action just // makes it visible to sighted users too.
When a chart receives keyboard focus, a "Skip to data table" link appears for screen readers and sighted keyboard users, allowing them to jump directly to the summary.
JSX
// Data summary is on by default — disable if needed <LineChart data={data} accessibleTable={false} />
Focus Ring
When navigating with the keyboard, a dashed focus ring highlights the currently focused data element. The ring shape adapts to the element type — circles for points and network nodes, rectangles for bars and Sankey nodes. The focus color uses the --semiotic-focus CSS custom property (default: #005fcc).
Live Announcements
When keyboard focus moves to a data point, an aria-live="polite" region announces the focused datum's values. This works automatically for all chart types — no configuration needed. Custom tooltips do not override the aria-live announcement.
Reduced Motion
Semiotic automatically detects prefers-reduced-motion: reduce and fast-forwards data transitions to their final state (no animated interpolation), stops orbit animation ticking, and completes any in-progress layout transitions immediately. Pulse and decay visual encodings still render their static state but skip animated effects. No props needed — this is built into all four Stream Frames.
JSX
// Semiotic handles this automatically — no configuration needed. // Transitions fast-forward to final state, orbit stops ticking. // Pulse/decay encodings render statically (no animation). // // You can also read the preference directly for your own UI: const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches
High Contrast Mode
When the operating system's high contrast or forced-colors mode is active, ThemeProvider automatically applies the HIGH_CONTRAST_THEME if no explicit theme is set. This ensures data marks have sufficient contrast and visibility without any configuration. When the user exits forced-colors mode, the theme reverts to the default.
JSX
import { ThemeProvider, HIGH_CONTRAST_THEME } from "semiotic" // Automatic: ThemeProvider detects forced-colors and applies high-contrast <ThemeProvider> <LineChart data={data} ... /> </ThemeProvider> // Manual: explicitly set high-contrast theme <ThemeProvider theme="high-contrast"> <LineChart data={data} ... /> </ThemeProvider> // Or apply directly via CSS custom properties: // --semiotic-bg: #000; --semiotic-text: #fff; etc.
Accessibility Validation
diagnoseConfig includes accessibility-specific checks that warn when charts are missing accessible descriptions or have color contrast issues. Run it in development to catch issues early:
diagnoseConfig("LineChart", { data, xAccessor, yAccessor })
[MISSING_DESCRIPTION] No title, description, or summary provided. Screen readers will fall back to a generic chart-type label.
JSX
import { diagnoseConfig } from "semiotic/utils" const result = diagnoseConfig("LineChart", { data: myData, xAccessor: "x", yAccessor: "y", // No title or description → MISSING_DESCRIPTION warning }) // result.diagnoses includes: // { code: "MISSING_DESCRIPTION", // message: "No title, description, or summary provided...", // severity: "warning", // fix: "Add a title=\"...\" prop..." } // Accessibility checks included: // - MISSING_DESCRIPTION — no title/description/summary // - LOW_COLOR_CONTRAST — colors < 3:1 against background // - LOW_ADJACENT_CONTRAST — adjacent categories hard to distinguish
Best Practices
Text Alternatives
Use the built-in description and summary props for programmatic accessibility. For visible text alternatives, wrap the chart in a <figure> with a <figcaption>:
JSX
<figure> <LineChart data={salesData} xAccessor="month" yAccessor="revenue" title="Monthly Revenue Trend" description="Line chart showing revenue growth from $12k to $27k" summary="Revenue grew 125% over 6 months with a brief dip in March" /> <figcaption> Revenue grew from $12,000 in January to $27,000 in June, with a brief dip in March. </figcaption> </figure>
Color and Contrast
Use colors with sufficient contrast ratios. Import the pre-tested color-blind safe palette for reliable accessibility:
JSX
import { COLOR_BLIND_SAFE_CATEGORICAL } from "semiotic" // 8-color palette based on Wong 2011 <LineChart data={data} colorBy="region" colorScheme={COLOR_BLIND_SAFE_CATEGORICAL} /> // diagnoseConfig checks contrast automatically: // LOW_COLOR_CONTRAST — color vs background < 3:1 // LOW_ADJACENT_CONTRAST — similar adjacent category colors
Semiotic's built-in aria-live region announces tooltip content automatically. When writing custom tooltip functions, you don't need to add your own aria attributes — the aria-live region handles it:
JSX
// The aria-live region announces data automatically // Custom tooltip only needs to handle the visual: <LineChart data={data} xAccessor="month" yAccessor="revenue" tooltip={(d) => ( <div> <strong>{d.month}</strong>: ${d.revenue.toLocaleString()} </div> )} />
Accessibility Props Reference
| Prop | Type | Default | Description |
|---|
title | string | ReactNode | - | Visible heading; fallback aria-label when description is not set |
description | string | - | Overrides the auto-generated aria-label with a detailed description |
summary | string | - | Screen-reader-only note (role="note") for trends or key takeaways |
accessibleTable | boolean | true | Enable JIT data summary (stats + pageable sample rows) for screen readers |
actions.dataSummary | boolean | false | ChartContainer: toolbar button to show data summary visibly |
Current Limitations
- No per-element ARIA — individual bars, points, and line segments in the canvas do not have ARIA labels. The SVG overlay provides labels for text elements, and the data table provides a complete non-visual alternative.
- Brush/selection is mouse-only — LinkedCharts brush interactions and ScatterplotMatrix crossfilter do not yet have keyboard equivalents.
- Streaming charts — realtime charts continuously update their scene graph. Keyboard navigation works but the point list refreshes as new data arrives.
- Touch navigation — swipe gestures are not yet mapped to the navigation graph. Touch users can use the data table.
Preference hooks
Charts auto-detect prefers-reduced-motion and forced-colors and adapt (animations off, high-contrast focus rings). When you build chrome around a chart — custom intros, transitions, decorative motion — read the same signals so your UI stays consistent with the chart. Two hooks expose them, from semiotic/utils:
JSX
import { useReducedMotion, useHighContrast } from "semiotic/utils" function Panel() { const reduceMotion = useReducedMotion() // boolean, live-updates on change const highContrast = useHighContrast() // boolean return ( <LineChart animate={reduceMotion ? false : { duration: 600 }} {...props} /> ) }
Both return a boolean and re-render on OS-preference change, so they compose like any other reactive value. The chart honors these automatically — the hooks are for the surrounding UI.
Testing Accessibility
- Tooltips — custom tooltip rendering with accessible markup
- Theming — dark mode, high-contrast, and color-blind safe themes
- Realtime Encoding — pulse and decay animation settings (respects reduced-motion)