Accessibility and colour choices
Make a canvas chart readable by assistive technology, choose palettes that survive colour blindness, and respect reduced-motion preferences.
A canvas is one opaque pixel buffer
<!-- 1. Name the image. role=img plus a label is the minimum. -->
<div class="chart-frame">
<canvas id="revenue" role="img" aria-label="Revenue by quarter: Q1 820, Q2 932, Q3 901, Q4 1290"></canvas>
</div>
<!-- 2. Provide the data in a table. This is the part that makes the chart
genuinely accessible rather than merely announced. -->
<table class="sr-only" id="revenue-data">
<caption>Revenue by quarter</caption>
<thead><tr><th scope="col">Quarter</th><th scope="col">Revenue</th></tr></thead>
<tbody>
<tr><th scope="row">Q1</th><td>820</td></tr>
<tr><th scope="row">Q2</th><td>932</td></tr>
<tr><th scope="row">Q3</th><td>901</td></tr>
<tr><th scope="row">Q4</th><td>1290</td></tr>
</tbody>
</table>
<a href="#revenue-data" class="chart-data-link">View the data</a>
<style>
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
/* When the link is followed, reveal the table as a normal block. */
.sr-only:target {
position: static; width: auto; height: auto; margin: 1rem 0; clip: auto;
overflow: visible; white-space: normal;
}
</style>| Technique | Achieves | Limit |
|---|---|---|
role="img" plus aria-label | The chart is announced as a single graphic | A summary only; the values are not browsable |
| A visible data table | Exact values for everyone | Duplicates the data in markup |
A .sr-only table with a reveal link | Values available without visual clutter | Needs the :target rule to be usable |
| Text summaries per series | Key takeaways in words | Must be maintained alongside the data |
| Keyboard-accessible filters | The chart reachable by Tab | Chart.js internals are not keyboard navigable by default |
| A tooltip on focus | Values readable without a mouse | Requires a custom interaction handler |
⚠️
Do not reach for
aria-hidden="true" on a canvas to silence a warning. Hiding a data-bearing graphic removes it from the accessibility tree entirely, which is worse than an unlabelled image. Label it, and give the reader a table.Palettes that survive colour blindness
// A palette chosen for distinguishability rather than for taste. The hues are
// separated in lightness as well as in hue, which is what a deuteranope needs.
const accessiblePalette = [
'#1d4ed8', // blue (dark)
'#e11d48', // rose (medium)
'#047857', // emerald (dark)
'#f59e0b', // amber (light)
'#7c3aed', // violet (dark)
'#0e7490', // cyan (medium)
'#b45309', // brown (medium)
'#64748b' // slate (neutral)
];
const options = {
plugins: {
legend: {
position: 'bottom',
labels: {
usePointStyle: true, // shape as well as colour
pointStyle: 'rectRounded',
generateLabels(chart) {
// Give each dataset a distinct shape, so colour is not the only channel.
const shapes = ['circle', 'rect', 'triangle', 'rectRot', 'cross'];
return chart.data.datasets.map((dataset, index) => ({
text: dataset.label,
fillStyle: dataset.borderColor ?? accessiblePalette[index],
strokeStyle: dataset.borderColor ?? accessiblePalette[index],
lineWidth: 1,
pointStyle: shapes[index % shapes.length],
hidden: !chart.isDatasetVisible(index),
datasetIndex: index
}));
}
}
}
},
elements: {
// Dash patterns on lines: a second channel that survives greyscale printing.
line: { borderDash: [] }
}
};
const dashedLines = {
datasets: [
{ label: 'Actual', borderDash: [], borderWidth: 2 },
{ label: 'Forecast', borderDash: [6, 4], borderWidth: 2 },
{ label: 'Target', borderDash: [2, 3], borderWidth: 1.5 }
]
};// Contrast: dataset colours must reach 3:1 against the surface for a graphical
// object, and text must reach 4.5:1. A quick check while developing.
function relativeLuminance(hex) {
const rgb = hex.replace('#', '').match(/.{2}/g)
.map((pair) => {
const channel = parseInt(pair, 16) / 255;
return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
}
function contrastRatio(a, b) {
const [lighter, darker] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x);
return (lighter + 0.05) / (darker + 0.05);
}
console.log(contrastRatio('#1d4ed8', '#ffffff')); // 6.4 - fine for text
console.log(contrastRatio('#f59e0b', '#ffffff')); // 2.2 - graphical only, never text- Never rely on colour alone to convey which series is which. Pair it with a shape, a dash pattern or a direct label.
- Order the palette so that the most important series gets the most distinguishable colour, and keep the assignment stable across every chart on the page.
- For a stacked chart, adjacent segments need separation: a thin border in the surface colour is more effective than a bigger colour difference.
- Check the palette in greyscale. A screenshot converted to grey shows immediately whether the series remain distinguishable.
Motion and interaction
// Respect the preference in the option: Chart.js does not read CSS.
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
function chartOptions(reduced) {
return {
animation: reduced ? false : { duration: 500, easing: 'easeOutQuart' },
animations: reduced ? false : {
y: { from: (ctx) => ctx.chart.scales.y.getPixelForValue(0), duration: 600 }
},
transitions: {
active: { animation: { duration: reduced ? 0 : 200 } }
}
};
}
const chart = new Chart(canvas, {
type: 'bar',
data,
options: chartOptions(reduceMotion.matches)
});
reduceMotion.addEventListener('change', (event) => {
chart.options.animation = event.matches ? false : { duration: 500 };
chart.update();
});// Keyboard access to the values: expose a select that drives a focused point.
// Chart.js has no built-in keyboard navigation, so add a thin control layer.
const select = document.getElementById('point-select');
select.innerHTML = chart.data.labels
.map((label, index) => `<option value="${index}">${label}</option>`)
.join('');
select.addEventListener('change', () => {
const index = Number(select.value);
const label = chart.data.labels[index];
const values = chart.data.datasets.map((dataset) => `${dataset.label}: ${dataset.data[index]}`);
document.getElementById('point-readout').textContent = `${label} - ${values.join(', ')}`;
});| Preference or need | Adaptation | Where it goes |
|---|---|---|
| Reduced motion | animation: false | The chart option, not CSS |
| Keyboard-only use | A control that reads values into a live region | Page markup beside the chart |
| Screen reader | role="img" plus an aria-label and a table | Container and markup |
| Colour vision deficiency | Distinct shapes and dash patterns | Dataset styling and the legend |
| Low vision | Larger fonts, thicker strokes, more contrast | Chart defaults |
| A white background and a higher pixel ratio | The export path |
The pattern that covers most of this at once is a small companion control: a select or a list of the categories, wired to a live region that states the values. It is keyboard accessible, it is readable by a screen reader, it needs no canvas support at all, and it takes about twenty lines.
FAQ
Is an aria-label on the canvas enough?
It is the minimum, not the answer. It announces that a chart exists and summarises it. For the values to be usable, publish them in a table — visible, or hidden behind a reveal link so sighted readers are not given a wall of numbers.
What palette should I use?
One where the hues differ in lightness as well as hue, paired with distinct shapes or dash patterns so colour is never the only channel. Test it in greyscale and against a contrast checker before shipping.
Related
Options, built-in plugins and tooltips Responsive canvas layout and image export
Last refreshed 2026-09-18.