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>
TechniqueAchievesLimit
role="img" plus aria-labelThe chart is announced as a single graphicA summary only; the values are not browsable
A visible data tableExact values for everyoneDuplicates the data in markup
A .sr-only table with a reveal linkValues available without visual clutterNeeds the :target rule to be usable
Text summaries per seriesKey takeaways in wordsMust be maintained alongside the data
Keyboard-accessible filtersThe chart reachable by TabChart.js internals are not keyboard navigable by default
A tooltip on focusValues readable without a mouseRequires 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 needAdaptationWhere it goes
Reduced motionanimation: falseThe chart option, not CSS
Keyboard-only useA control that reads values into a live regionPage markup beside the chart
Screen readerrole="img" plus an aria-label and a tableContainer and markup
Colour vision deficiencyDistinct shapes and dash patternsDataset styling and the legend
Low visionLarger fonts, thicker strokes, more contrastChart defaults
PrintA white background and a higher pixel ratioThe 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.

Options, built-in plugins and tooltips Responsive canvas layout and image export

Last refreshed 2026-09-18.