Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,22 @@ same visual choices with `fromXYChartTheme` from `@visx/theme`.
Add `@visx/theme` only when you want shared visual tokens or shadcn/ui-compatible CSS variable
theming for primitive charts.

### `@visx/a11y` adds opt-in accessibility helpers for primitive charts

visx 4.1 introduces `@visx/a11y`, an optional package for adding chart semantics to primitive SVG
charts. It provides server-safe helpers for generated ARIA props, text descriptions, and hidden data
tables, plus a client hook for pre-bound chart props, roving keyboard focus, data-table fallback,
and live announcements.

These helpers do not replace the chart render tree or hide non-data elements automatically. If your
chart includes decorative chrome such as grid lines, axis ticks, background rectangles, or clipping
helpers, render those elements with `aria-hidden="true"` so assistive technology focuses on the
chart title, description, series, data marks, and table fallback.

**What you need to do:** nothing unless you want to opt in. Existing visx charts continue to work.
Add `@visx/a11y` only when you want generated chart semantics, keyboard exploration, or a data-table
fallback for primitive charts.

### Registry-oriented hooks for primitive charts

visx 4.1 adds the first hook and helper APIs used to build primitive charts for the visx chart
Expand Down
1 change: 1 addition & 0 deletions packages/visx-a11y/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package-lock=false
273 changes: 273 additions & 0 deletions packages/visx-a11y/Readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
# @visx/a11y

Accessibility helpers for primitive visx charts without changing the low-level visx primitive model.
The package is split into a server-safe entry for generated semantics and a client root entry for
React hooks and companion components.

## Installation

```sh
npm install --save @visx/a11y
```

## Motivation

visx is intentionally low level: primitives take props, compose freely, and stay out of your app's
state model. That is what makes visx useful for unusual charts, custom dashboards, and product
interfaces that do not fit a fixed charting API.

The tradeoff is that primitive charts tend to rebuild the same accessibility layer:

- chart titles and long descriptions
- series labels and point value labels
- hidden data tables for exact values
- live regions for async chart updates
- keyboard affordances for chart exploration
- rules for hiding decorative SVG chrome

That works for one chart. It gets noisy when a registry or dashboard has many charts built from
different visx packages. Small differences creep in, dense charts can produce too much screen reader
output, and teams end up copying local helpers from chart to chart.

`@visx/a11y` gives those charts a shared semantic layer while keeping the rendering primitives
plain. It does not ship a chart component, impose SVG structure, or hide non-data elements
automatically. Instead, it gives you spreadable props, generated descriptions, table fallbacks, and
small React helpers that you can attach to the primitives you already render.

## Primitive-first design

The a11y package is a semantics layer, not a chart framework.

- visx primitives remain prop-driven.
- Server helpers are pure functions with no DOM or React hook requirements.
- The client hook uses React `useId()` only when `id` is omitted.
- Consumers own the SVG render tree, focus ring styling, and decorative element visibility.
- Data table and announcer components are independently usable.
- Dense charts degrade to summary descriptions above `pointDescriptionThreshold`.
- `keyboardNavEnabled` controls roving focus for point-by-point chart exploration.

This keeps chart registry items and product charts able to opt in one layer at a time. If a chart
needs custom descriptions, a visible data table, a separate live region, or no interactive keyboard
state yet, the primitive API still supports that.

## Basic usage

Use the server entry when a server-rendered chart only needs generated SVG semantics, a text
description, or an HTML table fallback.

```tsx
import type { ChartA11yConfig } from '@visx/a11y/server';
import {
generateChartDescription,
generateDataTableHTML,
getChartAriaProps,
} from '@visx/a11y/server';

type Datum = {
month: string;
revenue: number;
};

const config: ChartA11yConfig<Datum> = {
id: 'revenue-chart',
title: 'Revenue by month',
chartType: 'line',
data,
x: (datum) => datum.month,
y: (datum) => datum.revenue,
yLabel: 'Revenue',
formatY: (value) => `$${value}`,
};

const a11y = getChartAriaProps(config);
const description = generateChartDescription(config);
const tableHtml = generateDataTableHTML(config);
```

Use `useChartA11y` from the root entry when chart components need React-bound props and companion
components.

```tsx
'use client';

import { useChartA11y } from '@visx/a11y';

export function RevenueChart({ data }) {
const a11y = useChartA11y({
id: 'revenue-chart',
title: 'Revenue by month',
chartType: 'line',
data,
x: (datum) => datum.month,
y: (datum) => datum.revenue,
yLabel: 'Revenue',
formatY: (value) => `$${value}`,
});

return (
<>
<svg {...a11y.svgProps}>
<desc id={a11y.descriptionId}>{a11y.description}</desc>
{/* chart marks */}
</svg>
<a11y.DataTable />
<a11y.Announcer />
</>
);
}
```

## Recipes

### Level 1: Chart semantics, description, and table fallback

Start by spreading `svgProps`, rendering the generated description, and adding the pre-bound data
table and live announcer. This gives the chart an accessible name, a chart-level description, and an
exact-value table fallback without changing the visual chart.

```tsx
const a11y = useChartA11y({
id: 'revenue-chart',
title: 'Revenue by month',
chartType: 'line',
data,
x: (datum) => datum.month,
y: (datum) => datum.revenue,
yLabel: 'Revenue',
formatY: (value) => `$${value}`,
});

return (
<>
<svg {...a11y.svgProps}>
<desc id={a11y.descriptionId}>{a11y.description}</desc>
<g>
{data.map((datum) => (
<circle key={datum.month} />
))}
</g>
</svg>
<a11y.DataTable />
<a11y.Announcer />
</>
);
```

### Level 2: Series and point semantics

After Level 1 is in place, spread `getSeriesProps` on each rendered series group and `getPointProps`
on each rendered data mark. This adds per-series labels and per-point value labels for charts below
the configured `pointDescriptionThreshold`.

```tsx
const a11y = useChartA11y({
id: 'revenue-chart',
title: 'Revenue by month',
chartType: 'line',
data,
x: (datum) => datum.month,
y: (datum) => datum.revenue,
yLabel: 'Revenue',
formatY: (value) => `$${value}`,
series: [{ label: 'Revenue' }],
});

return (
<>
<svg {...a11y.svgProps}>
<desc id={a11y.descriptionId}>{a11y.description}</desc>
<g {...a11y.getSeriesProps(0)}>
{data.map((datum, index) => (
<circle key={datum.month} {...a11y.getPointProps(0, index)} />
))}
</g>
</svg>
<a11y.DataTable />
<a11y.Announcer />
</>
);
```

### Level 3: Keyboard navigation and tooltip bridge

Keep `keyboardNavEnabled` enabled when a chart should support point-by-point keyboard exploration.
`useChartA11y` adds roving focus props to each data mark and calls `onPointFocus` when keyboard
focus moves to a point. Use that callback to mirror the same tooltip, crosshair, or active-point
state that pointer interaction uses.

```tsx
const a11y = useChartA11y({
id: 'revenue-chart',
title: 'Revenue by month',
chartType: 'line',
data,
x: (datum) => datum.month,
y: (datum) => datum.revenue,
yLabel: 'Revenue',
formatY: (value) => `$${value}`,
series: [{ label: 'Revenue' }],
onPointFocus: ({ datum, index, seriesIndex }) => {
showTooltip({
datum,
index,
seriesIndex,
});
},
});

return (
<svg {...a11y.svgProps}>
<desc id={a11y.descriptionId}>{a11y.description}</desc>
<g {...a11y.getSeriesProps(0)}>
{data.map((datum, index) => (
<circle
key={datum.month}
className={a11y.focusedPoint?.index === index ? 'is-keyboard-focused' : undefined}
{...a11y.getPointProps(0, index)}
/>
))}
</g>
</svg>
);
```

Use `useChartKeyboardNav` directly when a custom chart already owns its generated ARIA semantics but
still wants the same roving-focus behavior, point focus state, and `onPointFocus` callback.

## Keyboard interaction model

`useChartA11y` starts in chart mode. The chart root is tabbable, and data marks become tabbable only
while keyboard exploration is active. Navigation is enabled by default for charts with at least one
point and no more than `pointDescriptionThreshold` points.

| Key | Result |
| ------------------ | -------------------------------------------------------------------------------------------- |
| `Tab` | Moves focus to or away from the chart root through normal page tab order. |
| `Enter` or `Space` | Enters data mode and focuses the first data point, or the last focused point when returning. |
| `ArrowRight` | Moves to the next point in the current series, wrapping at the end. |
| `ArrowLeft` | Moves to the previous point in the current series, wrapping at the beginning. |
| `ArrowDown` | Moves to the next non-empty series and keeps the closest point index. |
| `ArrowUp` | Moves to the previous non-empty series and keeps the closest point index. |
| `Home` | Moves to the first point in the current series. |
| `End` | Moves to the last point in the current series. |
| `Control` + `Home` | Moves to the first point in the chart. |
| `Control` + `End` | Moves to the last point in the chart. |
| `Escape` | Exits data mode and returns focus to the chart root. |
| `?` or `F1` | Announces the configured keyboard help text through `Announcer`. |

### Decorative chart chrome

`@visx/a11y` annotates the chart elements it receives through the hook. Consumers are responsible
for hiding decorative chart chrome from assistive technology. Grid lines, axis ticks, background
rects, clipping helpers, and other non-data elements should usually be rendered with
`aria-hidden="true"` so screen readers focus on the title, description, series, points, and data
table.

```tsx
<svg {...a11y.svgProps}>
<rect aria-hidden="true" width={width} height={height} />
<GridRows aria-hidden="true" scale={yScale} width={innerWidth} />
<AxisBottom aria-hidden="true" scale={xScale} />
<g {...a11y.getSeriesProps(0)}>{/* data marks */}</g>
</svg>
```
58 changes: 58 additions & 0 deletions packages/visx-a11y/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{
"name": "@visx/a11y",
"version": "4.0.0",
"description": "visx chart accessibility helpers",
"sideEffects": false,
"main": "lib/index.js",
"module": "esm/index.js",
"types": "lib/index.d.ts",
"exports": {
".": {
"types": "./lib/index.d.ts",
"import": "./esm/index.js",
"require": "./lib/index.js"
},
"./server": {
"types": "./lib/server.d.ts",
"import": "./esm/server.js",
"require": "./lib/server.js"
}
},
"files": [
"lib",
"esm"
],
"repository": {
"type": "git",
"url": "git+https://github.com/airbnb/visx.git"
},
"keywords": [
"visx",
"react",
"visualizations",
"charts",
"accessibility",
"a11y"
],
"author": "@hshoff",
"license": "MIT",
"bugs": {
"url": "https://github.com/airbnb/visx/issues"
},
"homepage": "https://github.com/airbnb/visx#readme",
"peerDependencies": {
"@types/react": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0"
},
"publishConfig": {
"access": "public"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
},
"devDependencies": {
"@types/react": "^19.0.0"
}
}
Loading
Loading