Chart.js Integration
MAIDR ships a Chart.js plugin that makes every chart on the page accessible — no data attributes, no manual schema, no binder calls. Register the plugin once and any new Chart(...) instance gains audio sonification, text descriptions, braille output, and keyboard navigation.
Quick Start
Add Chart.js and the MAIDR Chart.js bundle, then register the plugin:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>My Chart.js Chart</title>
<!-- 1. Load Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<!-- 2. Load MAIDR's Chart.js adapter -->
<script src="https://cdn.jsdelivr.net/npm/maidr/dist/chartjs.js"></script>
</head>
<body>
<div style="width: 700px; height: 400px">
<canvas id="my-chart"></canvas>
</div>
<script>
// 3. Register the MAIDR plugin globally — every chart on the page
// becomes accessible automatically.
Chart.register(maidrChartjs.maidrPlugin);
// 4. Create your chart normally — MAIDR hooks in automatically
new Chart(document.getElementById('my-chart'), {
type: 'bar',
data: {
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],
datasets: [{ label: 'Tips', data: [20, 14, 23, 25, 22] }],
},
options: {
plugins: { title: { display: true, text: 'Tips by Day' } },
scales: {
x: { title: { display: true, text: 'Day' } },
y: { title: { display: true, text: 'Count' } },
},
},
});
</script>
</body>
</html>
Once the page loads, click the chart (or Tab to it) and MAIDR activates with:
- Audio sonification — tones representing data values
- Text descriptions — spoken via screen readers
- Braille output — refreshable braille display support
- Keyboard navigation — arrow keys to move between data points
No changes to your Chart.js code are required.
How It Works
MAIDR's Chart.js adapter is a standard Chart.js plugin:
- Registration —
Chart.register(maidrChartjs.maidrPlugin)installs the plugin globally for every chart on the page - Extraction — on each chart's
afterInithook, the extractor readschart.config.type,chart.data, andchart.optionsand produces MAIDR's accessibility schema - Activation — a React root is mounted into a wrapper around the canvas, rendering the MAIDR component with full keyboard navigation, audio, text, and braille support
- Highlight overlay — because Chart.js renders into a single
<canvas>, MAIDR draws an absolute-positioned DOM rectangle on top of the canvas at the active element's geometry, kept in sync on resize
Supported Chart Types
Stable chart types
| Chart Type | Chart.js type |
Extra Plugin Required | Example |
|---|---|---|---|
| Bar | 'bar' (one dataset) |
— | Bar chart |
| Stacked Bar | 'bar' with scales.x.stacked / scales.y.stacked |
— | Stacked bar |
| Dodged Bar | 'bar' with multiple datasets (no stacking) |
— | Dodged bar |
| Line | 'line' |
— | Line chart |
| Step | 'line' with stepped on the dataset (or elements.line) |
— | Line chart |
| Scatter | 'scatter' |
— | Scatter plot |
| Box Plot | 'boxplot' |
@sgratzl/chartjs-chart-boxplot |
Box plot |
| Candlestick | 'candlestick' |
chartjs-chart-financial + a date adapter |
Candlestick |
| Heatmap | 'matrix' |
chartjs-chart-matrix |
Heatmap |
| Pie / Doughnut | 'pie', 'doughnut' |
— | Pie chart |
Experimental chart types
These may change without a deprecation period; see Trace type stability.
| Chart Type | Chart.js type |
Extra Plugin Required | Example |
|---|---|---|---|
| Diverging Bar [experimental] | stacked 'bar' with one series negated |
— | Diverging bar |
| Gantt / Range Bar [experimental] | 'bar' with [start, end] data |
— | Gantt chart |
| Waterfall [experimental] | 'bar' with chained [start, end] data |
— | Waterfall |
| Dumbbell [experimental] | horizontal 'bar' with [start, end] data and plugins.maidr.traceType |
— | Dumbbell |
| Area [experimental] | 'line' with fill |
— | Line chart |
| Stacked Area [experimental] | 'line' with fill and a stacked value scale |
— | Line chart |
| Normalized Area [experimental] | stacked area whose categories all total 100 (or 1) | — | Line chart |
| Bump [experimental] | 'line' with scales.y.reverse and ranked values |
— | Bump chart |
| Dot Plot [experimental] | 'line' with showLine: false on a category axis |
— | Dot plot |
| Survival [experimental] | 'line' with stepped and a maidr declaration |
— | Survival curve |
| Precision-Recall Curve [experimental] | 'line' with a maidr declaration |
— | — |
| Percentile Band [experimental] | 'line': a median dataset with a maidr declaration, and each band a dataset filled to another |
— | — |
| Volcano [experimental] | 'scatter' with a maidr declaration |
— | Volcano plot |
| Manhattan [experimental] | 'scatter' with a maidr declaration |
— | Manhattan plot |
| Radar [experimental] | 'radar' |
— | Radar chart |
| Polar Area [experimental] | 'polarArea' |
— | Radar chart |
| Error Bar [experimental] | 'barWithErrorBars', 'lineWithErrorBars', 'scatterWithErrorBars' |
chartjs-chart-error-bars |
Error bar |
| Treemap [experimental] | 'treemap' |
chartjs-chart-treemap |
Treemap |
| Sankey [experimental] | 'sankey' |
chartjs-chart-sankey |
Sankey |
| Word Cloud [experimental] | 'wordCloud' |
chartjs-chart-wordcloud |
Word cloud |
| Venn / Euler [experimental] | 'venn', 'euler' |
chartjs-chart-venn |
— |
| Funnel [experimental] | 'funnel' |
chartjs-chart-funnel |
Funnel |
| Choropleth [experimental] | 'choropleth', 'bubbleMap' |
chartjs-chart-geo |
Choropleth |
| Tree [experimental] | 'tree', 'dendrogram' |
chartjs-chart-graph |
Tree |
| Network [experimental] | 'forceDirectedGraph' |
chartjs-chart-graph |
Network |
| Directed graph [experimental] | 'forceDirectedGraph' with directed: true on every link |
chartjs-chart-graph |
— |
| Parallel Coordinates [experimental] | 'pcp', 'logarithmicPcp' |
chartjs-chart-pcp |
Parallel coordinates |
| Gauge [experimental] | 'doughnut' with circumference under 360 and two values |
— | Gauge |
Notes on these chart types
Pie note: a pie has no Chart.js scales, so there is no axis title to read.
axes.xandaxes.ydefault toCategoryandValue; setplugins.maidr.axesto name what the slice labels and their values actually mean. Multiple datasets are concentric rings, not slices of one circle — each becomes its own MAIDR layer with its own total and percentages, and Page Up / Page Down move between them.
Funnel note: a funnel is read as the stages it draws,
data.labelsnaming them anddataset.datacarrying the magnitudes — plain numbers or{x, y}rows, either way. The reading MAIDR gives it is a bar's with one difference that matters: the pitch carries the retention between adjacent stages rather than the count, and the counts are announced alongside.indexAxis: 'y'draws it on its side and the payload is exchanged with it, as for any bar-family chart. Several datasets are several funnels, one layer each, rather than one segmented chart — a funnel is one population shrinking across ordered stages, so a second series is a second population.
Geo note: both
chartjs-chart-geocontrollers are read as a choropleth — a map is a set of named places each carrying one value, and whether a place is drawn as a shaded region or as a sized bubble changes the picture rather than the reading. The places are named bydata.labels, which is what the plugin's own tooltip announces; a choropleth that declares none falls back to the name inside the GeoJSON it shades (properties.nameand the usual alternatives). The value is read from the parse, soscales.color.property/scales.size.propertymay point it at any field. A region whose value isnullis skipped rather than announced as a zero: the map paints it withmissingrather than a shade. Several datasets are several maps, one layer each.The centroids are asymmetric, and it is the plugin that makes them so. A bubble map carries
longitude/latitudeon every row, so its regions are laid out geographically — up is north, left is west — and MAIDR navigates the map itself. A choropleth carries none: the drawn shape's coordinates are pixels on the canvas, not degrees, so unless a row declarescenter: {longitude, latitude}the map is read as a region list in declared order, which is the poorer reading the data supports rather than a set of positions invented for it. Addcenterto each row to get the spatial walk. Highlighting follows the same split: a list-ordered map outlines the region being announced, and a placed one outlines nothing, because MAIDR's position on a placed map names a spot on the globe rather than a row of the dataset.
Parallel coordinates note:
chartjs-chart-pcplays a chart out the other way up from every other Chart.js type, and the reading follows the drawing rather than the data structure. One dataset is one axis —dataset.labelnames it, and the plugin gives each its own vertical scale — while one entry ofdata.labelsis one observation, the line running across every axis. MAIDR emits the transpose of that: a series per observation, a point per axis, which is what parallel coordinates navigation expects. Arrowing left and right walks the variables of one observation; up and down walks the observations at one variable.Each value is pitched against its own axis, not against one range for the chart. That is the whole point of the chart type and the thing a single scale would destroy: a variable measured in tens beside one measured in thousands would otherwise put every value of the first at the bottom of the register. Scaled per axis, two observations whose pitches swap between adjacent variables are the crossing lines that mean negative correlation — visible on the page, and audible here.
An axis is named by its
dataset.label, which is what the plugin uses for the tick under each axis and in its tooltip; a dataset that displays its owntitleis named by that instead, being the more descriptive name an author chose. A hidden dataset is dropped, because Chart.js lays out no axis for it and it is not a column of the drawn chart. Anullleaves the observation with a position on that axis and no reading, rather than shortening it — the axis is still there and the cursor still reaches it.
logarithmicPcpis read identically. The plugin changes where a value is drawn on its axis, not what the value is, so the announcement and the pitch carry the number itself; a reader hears the value's linear position within its own axis rather than its drawn one.Rows must be plain numbers.
{x, y}object rows are not a spelling this plugin accepts — it throws while parsing them, before MAIDR sees anything.
Venn and Euler note:
chartjs-chart-venn'sextractSetsgives the controller one point per region,{ sets, value }, and bothvennandeulerare read from those points as avennlayer: the sets first, then their overlaps by how many sets they join. A region's name is kept only when the author gave one -- the plugin's default label is the sets joined, which says nothing the sets do not. The regions are painted on the canvas, so they are announced, sonified and brailled but not outlined.
Graph note: all three
chartjs-chart-graphcontrollers take the same flat node list, in which a node names its parent by index; the names come fromdata.labels, and a node the chart never labelled is announced by its position. What separates the readings is not the layout but what the node list may be.treeanddendrogramname a parent per node, so the data is a hierarchy by construction — they are one controller class with amodeoption and are read as one tree, since a dendrogram is a tree drawn with its leaves levelled and naming it apart would name a layout rather than a chart.forceDirectedGraphmay be givenedgesinstead, and it accepts an edge list that closes a cycle, which no hierarchy can hold — so it is read as a network, and reading it as a tree would announce ancestry the chart does not have. No magnitude is emitted for either: the plugin sizes nothing by value.A tree outlines the node it announces. A network outlines nothing, and deliberately: MAIDR names a network's highlight as one link, so that its audio, braille and visual channels cannot disagree about which line is live — and the elements Chart.js can activate here are the nodes. Outlining a node for a link would light up a mark the reader was never told about, which is the failure #814 named.
A
forceDirectedGraphwhose every link resolvesdirected: true— on the dataset, inoptions.elements.edgeLine, as an array or as a scriptable function — is drawn with an arrowhead at each link'starget, and is read as a directed graph: every node in dataset order, each with the nodes whose arrows point at it as its inputs. One link without a visible arrowhead (directedfalse, orarrowHeadSize: 0) keeps the whole chart a network. A directed graph does outline: MAIDR walks it in topological order rather than dataset order, but names the node it is on by its declared index, and the plugin draws one node element per row in dataset order — so the node announced is the node outlined.
Error bar note: the three cartesian controllers of
chartjs-chart-error-barsall read the same way — an estimate and the interval around it — because the mark each draws at the estimate is not something a reader is told. Several datasets become one series each, every estimate naming its dataset, so a dodged interval chart keeps the comparison it was drawn for. A datum may carry nested intervals (yMin: [8, 7]); the outermost pair is announced, which is the interval the drawn whiskers reach, and the inner ones are not. A datum written as a plain number draws no whiskers and is announced as an estimate with no interval — which is not the same as an interval of width zero.polarAreaWithErrorBars, the plugin's fourth controller, is not read: a radial spoke has nowhere to carry a bound, so reading it would announce the estimate and drop the uncertainty.
Radar note: a radar and a polar area are drawn against a single radial
rscale, soaxes.yreadsscales.r.title.textandaxes.xdefaults toCategory— setplugins.maidr.axesto name what the spokes and their magnitudes mean. Both are read as a multi-series layer, one row per dataset and one column per spoke, with each spoke's stereo position following its angle rather than its index.
Charts Chart.js Does Not Declare
Chart.js has no configuration for a waterfall, a bump chart, a normalized area or a diverging bar — each is a recipe built out of a plain bar or line chart — so MAIDR reads them off the values. Each test is deliberately strict, because announcing a chart as something it is not is worse than announcing it plainly:
- Waterfall — one series of
[start, end]bars on the default vertical index axis where each step begins where the previous one ended. Bars that sit on the baseline (startof0) are read as the opening, closing or a subtotal. Unchained intervals are read as a gantt instead. A waterfall is one running total, so only the first dataset is read — a chart with several series of intervals is a gantt. - Gantt — any other
[start, end]bar chart.indexAxis: 'y'is the ordinary schedule, one lane per label; several datasets put several intervals in the same lane, each named by its dataset label. A lane whose entry isnullstays a navigable row with nothing booked. Bounds may be numbers orDates; on atype: 'time'scale MAIDR announces both ends as dates and measures lengths in milliseconds, so setplugins.maidr.unitwhen the schedule reads in days or sprints. - Normalized area — two or more stacked, filled line datasets whose every category totals the same whole (100 or 1, within half a percent so rounded shares still count).
- Bump — a line chart with
scales.y.reversewhose values at every period are a permutation of1..Nacross the series. The reversed axis alone is not enough; the permutation is what makes the rank reading safe. - Diverging bar — a stacked bar chart whose datasets each sit wholly on one side of the baseline, with both sides occupied. The values stay signed: MAIDR pitches the magnitude and announces the side.
- Dot plot — a line chart whose every dataset sets
showLine: falseon a category axis. This one Chart.js does say outright: switching the line off is its own way of drawing a Cleveland dot plot. Unjoined points along a linear axis are a scatter plot drawn by the line controller, and stay one. - Gauge — a
doughnutswept through less than the full circle (circumferenceunder 360) whose single dataset holds exactly two values: the measure, and the rest of the dial drawn empty. The remainder is spent on the dial'smaxrather than announced as a second reading.
Charts Only the Page Can Declare
Three readings are shape-identical to another recipe, so no test on the config or the values can reach them. Set plugins.maidr.traceType to say which figure the chart is; the declaration wins over every heuristic above, in both directions — declaring pie on a two-slice half-doughnut keeps it a pie, and declaring gantt on a chained bar chart keeps it a schedule.
Box plot and violin —
indexAxis: 'y'draws either on its side, and MAIDR announces it as horizontal. Neither payload moves with it: a five-number summary has no pair to exchange. What moves is which axis title names the group and which names the measurement, and, for a box, which way the arrow keys walk — along the sections of one distribution rather than across the distributions.Dumbbell (
traceType: 'dumbbell') — a horizontal floating bar chart, which is the same[start, end]datum a one-interval-per-lane gantt uses.plugins.maidr.startLabelandendLabelname the two ends ("1990", "2020"); without them the reader is told which dot they are on but not which year it is. Rows with no pair are skipped rather than kept as empty rows, unlike a gantt lane. A dumbbell is one pair per row, so only the first dataset is read — several intervals in the same row are a gantt.Survival (
traceType: 'survival') — astepped: 'after'line, which is how every staircase is drawn. Chart.js ignores properties it does not know, so ride the two things a survival figure carries and a step chart does not on the points themselves:{x, y, censored: true}for a censoring mark and{yMin, yMax}for the confidence band. Each dataset is one arm, gathered into a single layer.Precision-recall (
traceType: 'pr_curve') — a line of precision against recall, which is how every line is drawn. Ride the threshold each point was scored at on the point itself,{x: recall, y: precision, threshold}; recall is read fromx, or from a numeric label on a category axis. Each dataset is one curve, gathered into a single layer. To say a curve'sprevalence(the share of positives, which sets the chance baseline) or itsap, give its dataset amaidrblock — see below.Gauge (
traceType: 'gauge') — for a dial the geometry above misses, and for the target and bands Chart.js records only as styling:plugins.maidr.targetis the bullet marker andplugins.maidr.bandsis a[{ to, label }]list in ascending order.
The maidr Block on a Dataset
plugins.maidr.traceType says what the chart is, which is all a figure drawn as one dataset needs. A figure drawn as several — a Manhattan plot with a dataset per chromosome, a volcano beside its unchanged genes — needs to say it per dataset, and needs to name the columns its points carry. That is the co-located maidr block:
datasets: [{
label: 'chr1',
data: [{ x: 1_000_000, y: 8.2, snp: 'rs1234', chr: 1 }],
maidr: { type: 'manhattan', label: 'snp', group: 'chr', significance: 7.3 },
}]
Chart.js has no reserved slot for third-party metadata, but it passes dataset properties it does not know through untouched — the same mechanism a survival curve's censored datum rides on — so the block sits directly on the dataset. It wins over plugins.maidr.traceType, which is retained as the chart-wide shorthand; where the two disagree the block wins and MAIDR names both.
Every block is checked when it is read. A type that names no trace, a key the declared type does not accept (significanse), or a value that is not what its key takes (significanceDirection: 'Below') is reported to the console and dropped, and the chart is read exactly as it would have been with no block at all. Nothing is ever guessed in its place.
Volcano (type: 'volcano') and Manhattan (type: 'manhattan') are the two readings this unlocks on a Chart.js scatter. Both are drawn as a plain scatter — a volcano puts effect size against significance, a Manhattan puts genomic position against it — and both are read through a threshold rather than point by point: MAIDR opens with how many points clear the line, and the rotor offers those points as a navigation unit so the few dozen that matter are a few dozen keystrokes away rather than twelve thousand.
| key | what it takes | what it does |
|---|---|---|
label |
a property name on your datum | What each point is — a gene, a SNP, a probe. Identity is the payload on these charts; the coordinates are the two numbers the axes already describe. Defaults to label, then snp, id, name, gene, probe. |
group |
a property name on your datum | The region a point belongs to — its chromosome. Defaults to group, then chromosome, chrom, chr, region. |
significance |
a number | The cutoff on the y axis, in the units the chart is drawn in — 1.3 for p < 0.05 on a -log10(p) axis, 7.3 for genome-wide significance. |
significanceDirection |
'above' or 'below' |
Which side is the significant one. 'above' suits the transformed axes these charts usually carry; a raw p axis needs 'below'. |
effect |
a number | The effect-size cutoff on the x axis, applied to its magnitude. Meaningful on a volcano; a Manhattan's x is a position, so leave it out. |
merge |
a boolean | Whether the following datasets drawn the same way join this layer. true by default for a Manhattan, false for a volcano. |
title, name |
strings | The layer's announced title, and its name among sibling layers. |
Two rules are worth knowing before you write one:
- A field name you write is used verbatim.
label: 'symbol'readssymboland nothing else; if no row carries it, MAIDR says so and leaves the identity out rather than quietly resolvinggeneinstead. Leave the key out to get the default chain. - No cutoff is ever inferred. Chart.js states no line anywhere in its config, and a guessed one would sort every point in the figure onto the wrong side of it, silently. A layer that declares no
significanceis still emitted — it just reports no findings, and warns naming the field.
merge is what makes a 22-dataset Manhattan one navigable trace. The declaration goes on the first dataset; every following dataset drawn the same way that carries no block of its own is folded into that layer, up to the next dataset that declares something. Highlighting follows the merge — a column reaches the points sharing that x in whichever dataset drew them.
Precision-recall (type: 'pr_curve') is the reading this unlocks on a Chart.js line. Every line dataset of the chart is one curve of the layer, and what a block says is about its own dataset's curve — a curve whose dataset carries no block gets no baseline rather than a neighbour's, since curves are not always scored on the same data:
| key | what it takes | what it does |
|---|---|---|
threshold |
a property name on your datum | The decision threshold the point was scored at. Defaults to threshold, then thresholds, cutoff. A datum with none is announced without one. |
prevalence |
a number from 0 to 1 | The share of positives in the data the curve was scored on — the precision a classifier that guesses keeps, and so the height of the chance baseline. Never inferred; a percentage is refused rather than rescaled. |
ap |
a number from 0 to 1 | The curve's average precision as the producer computed it. Left out, MAIDR measures it from the points. |
merge |
a boolean | Accepted for the other adapters' sake; a Chart.js precision-recall chart is one figure and always reads as one layer. |
title, name |
strings | The layer's announced title, and its name among sibling layers; the first block in chart order gives them. |
Percentile band (type: 'percentile_band') reads a fan chart — a median with nested bands of quantiles around it. Chart.js has no range dataset, so each band is two line datasets, one filled to the other with fill ('-1', an absolute index, or { target }), and nothing says which quantiles their edges are. The block goes on the median dataset and names each band by the label of the dataset that fills:
datasets: [
{ label: 'p5', data: p5, fill: false },
{ label: 'p95', data: p95, fill: '-1' }, // the 90% band: p95 filled down to p5
{ label: 'p25', data: p25, fill: false },
{ label: 'p75', data: p75, fill: '-1' }, // the 50% band
{ label: 'median', data: median, maidr: { type: 'percentile_band', bands: [
{ series: 'p95', lower: 0.05, upper: 0.95 },
{ series: 'p75', lower: 0.25, upper: 0.75 },
] } },
]
The dataset it fills to is the band's other edge, and which edge is the high one is read from the values; a pair that crosses is no band. The levels are fractions, each lower below 0.5 and each upper above it, and the bands must nest — a band in percentages or bands that cross are refused and the chart reads as the undeclared one. A band naming no dataset, one that fills to the axis or a constant rather than another dataset, or one whose datasets another band already reads, is reported and left out, and the layer keeps the rest. Every edge is matched to the median by position on a category axis and by x for {x, y} data, and an edge that draws nothing at one of the median's positions is a gap there. The layer takes only the datasets it names; every other dataset — the history a forecast's fan continues, say — keeps its own reading. The highlight outlines the quantile the reader is on as the point its own dataset draws there.
A block whose type this adapter has no construct for (a hexbin on a scatter, a volcano on a bar chart) is reported and ignored. type: 'survival' on a 'line' dataset reaches exactly the same reading plugins.maidr.traceType: 'survival' does, and type: 'pr_curve' the same as plugins.maidr.traceType: 'pr_curve'.
The readings that take the whole chart at once — a survival curve, a precision-recall figure, a dumbbell, a waterfall, a gantt, a gauge — are one figure, so they take one answer. Several datasets may carry a block (a survival curve's arms each say what they are), but where two name different types the first in chart order wins and MAIDR names every type it found, the same way it does when a block and plugins.maidr.traceType disagree. Volcano, Manhattan and scatter are read per dataset instead, so each block there is honoured on its own dataset.
plugins.maidr.traceType: 'volcano' or 'manhattan' still reads a chart whose datasets carry no block of their own: it names the trace type and the default chains still find an identity on each point. What it cannot carry is a cutoff, so a figure with one — which is most of them — wants the block.
Code Examples
Stable chart types
Bar Chart
<div style="width: 700px; height: 400px">
<canvas id="bar-chart"></canvas>
</div>
<script>
Chart.register(maidrChartjs.maidrPlugin);
new Chart(document.getElementById('bar-chart'), {
type: 'bar',
data: {
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
datasets: [{
label: 'Daily Activity Count',
data: [45, 72, 89, 64, 53, 95, 38],
backgroundColor: '#4682b4',
}],
},
options: {
plugins: { title: { display: true, text: 'Daily Activity Count' } },
scales: {
x: { title: { display: true, text: 'Day of Week' } },
y: { title: { display: true, text: 'Count' }, beginAtZero: true },
},
},
});
</script>
Line Chart
<div style="width: 700px; height: 400px">
<canvas id="line-chart"></canvas>
</div>
<script>
Chart.register(maidrChartjs.maidrPlugin);
new Chart(document.getElementById('line-chart'), {
type: 'line',
data: {
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
datasets: [
{ label: 'Revenue', data: [120, 200, 150, 240, 310, 280], borderColor: '#2ca02c', tension: 0.2 },
{ label: 'Expenses', data: [90, 130, 110, 170, 200, 190], borderColor: '#d62728', tension: 0.2 },
],
},
options: {
plugins: { title: { display: true, text: 'Monthly Revenue vs Expenses' } },
scales: {
x: { title: { display: true, text: 'Month' } },
y: { title: { display: true, text: 'USD (thousands)' } },
},
},
});
</script>
Scatter Plot
<div style="width: 700px; height: 400px">
<canvas id="scatter-chart"></canvas>
</div>
<script>
Chart.register(maidrChartjs.maidrPlugin);
new Chart(document.getElementById('scatter-chart'), {
type: 'scatter',
data: {
datasets: [{
label: 'Iris Setosa',
data: [
{ x: 5.1, y: 3.5 }, { x: 4.9, y: 3.0 }, { x: 4.7, y: 3.2 },
{ x: 4.6, y: 3.1 }, { x: 5.0, y: 3.6 }, { x: 5.4, y: 3.9 },
{ x: 4.6, y: 3.4 }, { x: 5.0, y: 3.4 },
],
backgroundColor: '#9467bd',
}],
},
options: {
plugins: { title: { display: true, text: 'Sepal Length vs Sepal Width' } },
scales: {
x: { title: { display: true, text: 'Sepal Length (cm)' } },
y: { title: { display: true, text: 'Sepal Width (cm)' } },
},
},
});
</script>
Stacked Bar Chart
Both axes marked stacked: true produces a stacked bar chart. The MAIDR extractor maps multi-dataset stacked bars to its STACKED trace type.
<div style="width: 700px; height: 400px">
<canvas id="stacked-bar-chart"></canvas>
</div>
<script>
Chart.register(maidrChartjs.maidrPlugin);
new Chart(document.getElementById('stacked-bar-chart'), {
type: 'bar',
data: {
labels: ['Q1', 'Q2', 'Q3', 'Q4'],
datasets: [
{ label: 'East', data: [120, 150, 180, 200], backgroundColor: '#2196F3' },
{ label: 'West', data: [90, 110, 130, 145], backgroundColor: '#FF9800' },
{ label: 'South', data: [60, 80, 95, 110], backgroundColor: '#4CAF50' },
],
},
options: {
plugins: { title: { display: true, text: 'Revenue by Region' } },
scales: {
x: { title: { display: true, text: 'Quarter' }, stacked: true },
y: { title: { display: true, text: 'Revenue ($K)' }, beginAtZero: true, stacked: true },
},
},
});
</script>
Dodged (Grouped) Bar Chart
Multi-dataset bars without stacked flags render side-by-side and map to the DODGED trace type.
<div style="width: 700px; height: 400px">
<canvas id="dodged-bar-chart"></canvas>
</div>
<script>
Chart.register(maidrChartjs.maidrPlugin);
new Chart(document.getElementById('dodged-bar-chart'), {
type: 'bar',
data: {
labels: ['Math', 'Science', 'English'],
datasets: [
{ label: 'Grade A', data: [30, 35, 40], backgroundColor: '#4CAF50' },
{ label: 'Grade B', data: [45, 40, 35], backgroundColor: '#2196F3' },
{ label: 'Grade C', data: [20, 25, 15], backgroundColor: '#FF9800' },
],
},
options: {
plugins: { title: { display: true, text: 'Student Grades by Subject' } },
scales: {
x: { title: { display: true, text: 'Subject' } },
y: { title: { display: true, text: 'Count' }, beginAtZero: true },
},
},
});
</script>
Box Plot
Requires the @sgratzl/chartjs-chart-boxplot plugin. Its v4 UMD bundle auto-registers the boxplot controller and elements.
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script src="https://cdn.jsdelivr.net/npm/@sgratzl/chartjs-chart-boxplot@4"></script>
<script src="https://cdn.jsdelivr.net/npm/maidr/dist/chartjs.js"></script>
<div style="width: 700px; height: 400px">
<canvas id="boxplot-chart"></canvas>
</div>
<script>
Chart.register(maidrChartjs.maidrPlugin);
new Chart(document.getElementById('boxplot-chart'), {
type: 'boxplot',
data: {
labels: ['Group A', 'Group B', 'Group C'],
datasets: [{
label: 'Distribution',
data: [
{ min: 15, q1: 25, median: 35, q3: 45, max: 55, outliers: [5, 8, 62, 70] },
{ min: 20, q1: 30, median: 42, q3: 52, max: 65, outliers: [12, 72] },
{ min: 10, q1: 22, median: 30, q3: 40, max: 50, outliers: [58] },
],
backgroundColor: 'rgba(135, 206, 235, 0.5)',
borderColor: 'rgb(135, 206, 235)',
borderWidth: 1,
}],
},
options: {
plugins: { title: { display: true, text: 'Distribution by Group' } },
scales: {
x: { title: { display: true, text: 'Group' } },
y: { title: { display: true, text: 'Value' }, beginAtZero: true },
},
},
});
</script>
Candlestick
Requires chartjs-chart-financial and a date adapter (this example uses Luxon). Load order matters: date library → date adapter → financial plugin.
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script src="https://cdn.jsdelivr.net/npm/luxon@3"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@1"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-chart-financial@0.2.1"></script>
<script src="https://cdn.jsdelivr.net/npm/maidr/dist/chartjs.js"></script>
<div style="width: 700px; height: 400px">
<canvas id="candlestick-chart"></canvas>
</div>
<script>
Chart.register(maidrChartjs.maidrPlugin);
const day = (n) => luxon.DateTime.fromISO('2024-01-' + String(n).padStart(2, '0')).toMillis();
new Chart(document.getElementById('candlestick-chart'), {
type: 'candlestick',
data: {
datasets: [{
label: 'Stock Price',
data: [
{ x: day(1), o: 100, h: 110, l: 95, c: 105 },
{ x: day(2), o: 105, h: 115, l: 100, c: 112 },
{ x: day(3), o: 112, h: 118, l: 108, c: 109 },
{ x: day(4), o: 109, h: 114, l: 104, c: 113 },
{ x: day(5), o: 113, h: 120, l: 110, c: 118 },
],
}],
},
options: {
plugins: { title: { display: true, text: 'Weekly Stock Price' } },
scales: {
x: { type: 'time', time: { unit: 'day' }, title: { display: true, text: 'Day' } },
y: { title: { display: true, text: 'Price ($)' } },
},
},
});
</script>
The MAIDR extractor derives trend from close vs open and volatility from high - low. Chart.js's financial plugin does not carry volume data, so the MAIDR payload records volume as 0.
Heatmap (Matrix)
Requires chartjs-chart-matrix. Matrix datasets use flat {x, y, v} entries — the MAIDR extractor collects unique X and Y labels in first-seen order and produces a points[y][x] grid.
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-chart-matrix@2"></script>
<script src="https://cdn.jsdelivr.net/npm/maidr/dist/chartjs.js"></script>
<div style="width: 700px; height: 400px">
<canvas id="heatmap-chart"></canvas>
</div>
<script>
Chart.register(maidrChartjs.maidrPlugin);
const tasks = ['Math', 'Code', 'Writing', 'Reasoning'];
const models = ['GPT-4', 'Claude', 'Gemini'];
const scores = [
[92, 88, 85, 90],
[89, 91, 93, 88],
[86, 84, 82, 85],
];
const data = [];
for (let yi = 0; yi < models.length; yi++) {
for (let xi = 0; xi < tasks.length; xi++) {
data.push({ x: tasks[xi], y: models[yi], v: scores[yi][xi] });
}
}
new Chart(document.getElementById('heatmap-chart'), {
type: 'matrix',
data: {
datasets: [{
label: 'Task Performance',
data,
backgroundColor: (context) => {
const value = context.raw?.v ?? 0;
const alpha = Math.max(0, Math.min(1, (value - 70) / 25));
return `rgba(255, 99, 132, ${alpha})`;
},
borderColor: 'rgba(255, 99, 132, 0.8)',
borderWidth: 1,
width: ({ chart }) => (chart.chartArea?.width ?? 0) / tasks.length - 1,
height: ({ chart }) => (chart.chartArea?.height ?? 0) / models.length - 1,
}],
},
options: {
plugins: { title: { display: true, text: 'Model Scores by Task' }, legend: { display: false } },
scales: {
x: { type: 'category', labels: tasks, title: { display: true, text: 'Task' }, offset: true, grid: { display: false } },
y: { type: 'category', labels: models, title: { display: true, text: 'Model' }, offset: true, grid: { display: false } },
},
},
});
</script>
Pie / Doughnut Chart
<div style="width: 500px; height: 500px">
<canvas id="pie-chart"></canvas>
</div>
<script>
Chart.register(maidrChartjs.maidrPlugin);
new Chart(document.getElementById('pie-chart'), {
// 'doughnut' works the same way: it is a pie with a cutout, and the cutout
// changes nothing about the data or the navigation.
type: 'pie',
data: {
labels: ['Apples', 'Bananas', 'Cherries', 'Dates', 'Elderberries'],
datasets: [{
label: 'Units Sold',
data: [30, 50, 20, 15, 10],
backgroundColor: ['#4682b4', '#d2691e', '#6b8e23', '#8b4789', '#b8860b'],
}],
},
options: {
plugins: {
title: { display: true, text: 'Fruit Sales' },
maidr: { axes: { x: 'Fruit', y: 'Units' } },
},
},
});
</script>
Left and Right move between slices; Up and Down are out of bounds, since a pie is a single row. Each slice announces its label, its value, and its share of the whole — "Fruit is Apples, Units is 30, Percentage is 24.0%". Slices whose value is null or NaN are dropped rather than counted as zero, so a gap in the data neither takes a share of the total nor pins the bottom of the range the other slices are pitched against.
Experimental chart types
These may change without a deprecation period; see Trace type stability.
Volcano Plot [experimental]
<div style="width: 700px; height: 400px">
<canvas id="volcano-chart"></canvas>
</div>
<script>
Chart.register(maidrChartjs.maidrPlugin);
new Chart(document.getElementById('volcano-chart'), {
type: 'scatter',
data: {
datasets: [{
label: 'Differential expression',
data: [
{ x: -3.1, y: 6.8, gene: 'TP53' }, { x: -2.4, y: 4.1, gene: 'BRCA1' },
{ x: -0.9, y: 1.1, gene: 'ACTB' }, { x: 0.4, y: 0.8, gene: 'RPL13A' },
{ x: 1.9, y: 3.4, gene: 'VEGFA' }, { x: 3.4, y: 7.2, gene: 'CDKN1A' },
],
// A volcano is a plain scatter until the dataset says otherwise.
maidr: { type: 'volcano', label: 'gene', significance: 1.3, effect: 1 },
}],
},
options: {
plugins: { title: { display: true, text: 'Treated vs. control' } },
scales: {
x: { title: { display: true, text: 'log2 fold change' } },
y: { title: { display: true, text: '-log10(p)' } },
},
},
});
</script>
Manhattan Plot [experimental]
One dataset per chromosome is how the alternating colours are drawn. merge — on by default for a Manhattan — folds them into a single navigable cloud, so only the first dataset carries a block.
<div style="width: 700px; height: 400px">
<canvas id="manhattan-chart"></canvas>
</div>
<script>
Chart.register(maidrChartjs.maidrPlugin);
new Chart(document.getElementById('manhattan-chart'), {
type: 'scatter',
data: {
datasets: [
{
label: 'chr1',
data: [
{ x: 5, y: 1.2, snp: 'rs1001', chr: 1 },
{ x: 44, y: 8.6, snp: 'rs1004', chr: 1 },
],
pointBackgroundColor: '#1f77b4',
maidr: { type: 'manhattan', label: 'snp', group: 'chr', significance: 7.3 },
},
{
label: 'chr2',
data: [
{ x: 106, y: 2.2, snp: 'rs2001', chr: 2 },
{ x: 133, y: 4.9, snp: 'rs2003', chr: 2 },
],
pointBackgroundColor: '#ff7f0e',
},
],
},
options: {
plugins: {
title: { display: true, text: 'Genome-wide association study' },
legend: { display: false },
},
scales: {
x: { title: { display: true, text: 'Genomic position (kb)' } },
y: { title: { display: true, text: '-log10(p)' } },
},
},
});
</script>
Treemap [experimental]
Requires chartjs-chart-treemap. Declare groups and key and the hierarchy is read as it is drawn: each rectangle's group name becomes the node's name, its value becomes the magnitude, and the grouping fields give the ancestry. Arrow keys walk siblings; Up and Down move between levels.
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-chart-treemap@4"></script>
<script src="https://cdn.jsdelivr.net/npm/maidr/dist/chartjs.js"></script>
<div style="width: 700px; height: 400px">
<canvas id="treemap-chart"></canvas>
</div>
<script>
Chart.register(maidrChartjs.maidrPlugin);
new Chart(document.getElementById('treemap-chart'), {
type: 'treemap',
data: {
datasets: [{
label: 'Population',
tree: [
{ continent: 'Asia', country: 'Japan', pop: 125 },
{ continent: 'Asia', country: 'Korea', pop: 52 },
{ continent: 'Europe', country: 'France', pop: 67 },
{ continent: 'Europe', country: 'Spain', pop: 47 },
],
groups: ['continent', 'country'],
key: 'pop',
}],
},
});
</script>
A node's value is announced only where it is its own: a group whose value is exactly its children's total is left for the trace to sum, and a group whose declared value differs is announced as declared rather than corrected.
A treemap has no Chart.js scales, so the axes are named after the dataset instead — groups joined for axes.x and key for axes.y. Set plugins.maidr.axes to override either.
A flat tree of numbers draws rectangles the plugin gives no names to, and data.labels is not read by the controller. Those nodes are announced by their position — 1, 2, 3 — so declare groups whenever the nodes have names worth hearing.
Sankey [experimental]
Requires chartjs-chart-sankey. The plugin's {from, to, flow} rows are read as they are written — the nodes are derived from the edges, so nothing has to be declared twice. Left and right follow the largest ribbon; up and down walk the other nodes in the same column.
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-chart-sankey@0.15"></script>
<script src="https://cdn.jsdelivr.net/npm/maidr/dist/chartjs.js"></script>
<div style="width: 700px; height: 400px">
<canvas id="sankey-chart"></canvas>
</div>
<script>
Chart.register(maidrChartjs.maidrPlugin);
new Chart(document.getElementById('sankey-chart'), {
type: 'sankey',
data: {
datasets: [{
label: 'Energy',
data: [
{ from: 'Coal', to: 'Electricity', flow: 34 },
{ from: 'Gas', to: 'Electricity', flow: 20 },
{ from: 'Electricity', to: 'Homes', flow: 30 },
{ from: 'Electricity', to: 'Industry', flow: 24 },
],
labels: { Homes: 'Residential' },
}],
},
});
</script>
labels maps a node key to the name the chart displays, and MAIDR announces the label rather than the key.
Sankey note: a sankey is the one supported Chart.js type MAIDR does not outline. A flow diagram is navigated by node while the chart's elements are flows, and nothing in the navigation event names the node — so the adapter declines rather than outlining a ribbon chosen by position. Audio, text and braille are unaffected.
Word Cloud [experimental]
Requires chartjs-chart-wordcloud. The terms go in data.labels and their weights in the dataset, which is the ordinary Chart.js split — and it is the reading, so a word's weight is announced as the number the author gave rather than recovered from how large it was drawn.
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-chart-wordcloud@4"></script>
<script src="https://cdn.jsdelivr.net/npm/maidr/dist/chartjs.js"></script>
<div style="width: 700px; height: 400px">
<canvas id="wordcloud-chart"></canvas>
</div>
<script>
Chart.register(maidrChartjs.maidrPlugin);
new Chart(document.getElementById('wordcloud-chart'), {
type: 'wordCloud',
data: {
labels: ['accessible', 'chart', 'audio', 'braille'],
datasets: [{ label: 'Terms', data: [40, 25, 12, 8] }],
},
});
</script>
Terms are read in the order they were declared, not in the order the layout happened to place them — the largest word is drawn first on screen, but a reader sweeping left and right gets the author's order.
A term whose weight is null is skipped rather than announced as zero: a weight is what terms are compared by, and a zero would make the term look like the least common one rather than one the chart has no count for.
Multi-Panel Charts (Axis Stacking)
Chart.js has no facet API, but since v3.7 a single chart can render stacked panels via axis stacking: two or more scales of the same axis kind share a stack name and are laid out in separate, non-overlapping bands. Datasets pick their panel with yAxisID (or xAxisID).
MAIDR detects this layout automatically and exposes each panel as its own subplot: y-stacked scales become an N-rows-by-1-column figure, x-stacked scales become 1-row-by-N-columns (left to right). Navigation starts at the subplot level — arrow keys move between panels, Enter drills into a panel, Escape returns. Each panel announces its own value-axis label (from that scale's title.text, which also names the panel) while sharing the common index axis.
Because Chart.js draws to a <canvas>, MAIDR cannot measure panel geometry from the DOM the way it does for SVG charts, so y-stacked figures follow the MAIDR grammar's native (matplotlib-style) row convention: grid rows are ordered bottom-first. Up/Down arrows always move the way the panels look on canvas (Up goes to the panel above), but panel numbering is announced bottom-up — "Subplot 1" is the bottom panel and navigation enters the figure there.
<div style="width: 700px; height: 500px">
<canvas id="stacked-panels-chart"></canvas>
</div>
<script>
Chart.register(maidrChartjs.maidrPlugin);
new Chart(document.getElementById('stacked-panels-chart'), {
type: 'line',
data: {
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
datasets: [
{ label: 'Price', data: [102, 110, 108, 121, 119, 127] }, // default yAxisID: 'y'
{ label: 'Volume', data: [34, 51, 42, 65, 48, 70], yAxisID: 'y2' },
],
},
options: {
plugins: { title: { display: true, text: 'Stock Price and Trading Volume' } },
scales: {
x: { title: { display: true, text: 'Month' } },
y: { stack: 'panels', stackWeight: 2, title: { display: true, text: 'Price ($)' } },
y2: { stack: 'panels', stackWeight: 1, offset: true, title: { display: true, text: 'Volume (M shares)' } },
},
},
});
</script>
Notes:
- Every supported chart type works inside panels; each panel's datasets are extracted with that panel's own scale, so per-panel
stacked: truestill maps to theSTACKEDtrace type while another panel staysDODGED. - Datasets that omit
yAxisID/xAxisIDare assigned to the first declared scale of that axis kind, exactly as Chart.js resolves them — including when your scales use custom ids likeprice/volume. - Classic dual-axis charts (two y scales overlaying the same plot area) are not panels and remain a single subplot, exactly as before. Matching Chart.js layout rules, scales only band together when they share both the same
stackname and the sameposition— different stack names, or the same name on opposite edges (e.g.left/right), stay a dual-axis overlay. - Multiple
Chartinstances arranged in a page grid are still separate MAIDR figures; grouping several charts into one figure is not yet supported.
For programmatic use, extractChartData(chart) returns the extracted MAIDR schema together with a layerDatasetIndices map that ties each figure-unique layer id back to the Chart.js datasets that produced it.
Keyboard Controls
Once a chart is focused, use standard MAIDR keyboard shortcuts:
| Function | Key (Windows) | Key (Mac) |
|---|---|---|
| Move between data points | Arrow keys | Arrow keys |
| Go to extremes | Ctrl + Arrow | Cmd + Arrow |
| Toggle Sonification | S | S |
| Toggle Braille Mode | B | B |
| Toggle Text Mode | T | T |
| Toggle Review Mode | R | R |
| Auto-play | Ctrl + Shift + Arrow | Cmd + Shift + Arrow |
| Stop Auto-play | Ctrl | Cmd |
For the full list, see the Keyboard Controls reference.
Integration Comparison
| Feature | Vanilla JS (CDN) | React Component | Chart.js Adapter |
|---|---|---|---|
| Setup | maidr-data attribute with JSON |
data prop on <Maidr> |
Chart.register(maidrPlugin) once |
| Data source | Manual JSON schema | Manual JSON schema | Auto-extracted from Chart.js |
| Element addressing | Manual CSS selectors | Manual CSS selectors | Auto-generated from canvas elements |
| Configuration | Required | Required | Zero configuration |
| Chart types | All MAIDR types | All MAIDR types | 25 Chart.js types (incl. plugins) |
| Dynamic charts | Manual init | React lifecycle | Auto-handled per chart |
npm Installation (Optional)
For bundler-based projects:
npm install maidr chart.js
import { Chart, registerables } from 'chart.js';
import { maidrPlugin } from 'maidr/chartjs';
Chart.register(...registerables, maidrPlugin);
Then create charts as usual — every instance gains MAIDR accessibility.
API Documentation
For the complete TypeScript API reference, see the API Documentation.