Accessible Heatmaps
Permalink to "Accessible Heatmaps"Heatmaps — activity by hour and weekday, error rates by service and region, correlation matrices, calendar contribution grids — encode a number as a colour in each cell. Colour lightness is a weak channel for precise reading even for people with typical vision, and the hue-based palettes many libraries default to (rainbow, red-green) are unreadable for colour-blind users. For screen reader users, a heatmap drawn on canvas is nothing at all. Yet a heatmap is a table at heart: rows, columns and a value in each cell. Built that way, it becomes one of the more accessible chart types.
This guide belongs to chart type-specific patterns.
Spec reference
Permalink to "Spec reference"- SC 1.3.1 Info and Relationships (A) — row and column headers for every cell; a table or grid structure.
- SC 1.4.1 Use of Color (A) — colour cannot be the only way the value is conveyed; show numbers, or bins named in a legend with patterns.
- SC 1.4.3 Contrast (AA) — any text on coloured cells (values) needs 4.5:1, which usually means switching text colour by cell lightness.
- SC 1.4.11 Non-text Contrast (AA) — if adjacent cells must be distinguished to understand the chart, consider borders.
- SC 2.1.1 Keyboard (A) — hover values must be reachable by focus.
When to use — and when not to
Permalink to "When to use — and when not to"Use a heatmap to show patterns across two categorical (or binned) dimensions — when things happen, where errors cluster. Always include values (in cells or on focus) and a summary of the pattern.
Do not use a heatmap when users need exact comparisons between a few values; a bar chart is clearer. Do not use rainbow or red-green palettes. The misapplication: a 24 × 7 activity heatmap with hover-only values and a gradient legend without numbers.
Annotated code example
Permalink to "Annotated code example"<table class="heatmap" aria-describedby="hm-sum">
<caption>Error rate by service and region, last 24 hours</caption>
<thead><tr><th scope="col">Service</th><th scope="col">EU</th><th scope="col">US</th><th scope="col">APAC</th></tr></thead>
<tbody>
<tr>
<th scope="row">Checkout</th>
<!-- Value as text; bin class drives colour and text contrast (SC 1.4.1, 1.4.3) -->
<td class="bin-0">0.2%</td><td class="bin-0">0.4%</td>
<td class="bin-3">3.1% <span class="flag">over budget</span></td>
</tr>
</tbody>
</table>
<p id="hm-sum">2 cells exceed the 2% error budget: Checkout in APAC (3.1%) and Payments in US (2.4%).</p>
/* Sequential palette with increasing darkness; text colour flips for contrast */
.heatmap td.bin-0 { background: #edfdf4; color: #111714; }
.heatmap td.bin-1 { background: #a8f0c8; color: #111714; }
.heatmap td.bin-2 { background: #219150; color: #ffffff; }
.heatmap td.bin-3 { background: #0d3320; color: #ffffff; font-weight: 700; }
.heatmap td { border: 1px solid #ffffff; min-inline-size: 4.5ch; text-align: end; }
For large heatmaps where cells are too small for text, render values on focus and in the table view, and keep the bins few (four to six) so the legend can name them.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Event | Expected announcement | AT deviations |
|---|---|---|
| Table navigation | “APAC, Checkout, 3.1% over budget” | Headers read on change |
| Interactive grid variant, arrows | Same, via roving focus | — |
| Summary | Hot spots named | — |
| Forced colours | Backgrounds lost; values and “over budget” remain | That is why values must be text |
Integration context
Permalink to "Integration context"Palette selection is covered in accessible sequential and diverging palettes, and dark-mode variants in dark mode chart palettes. For interactive heatmaps, cell navigation follows implementing roving tabindex for custom data grids. Geographic heatmaps (choropleths) are covered in table alternatives for choropleth maps.
Gotchas
Permalink to "Gotchas"Too many bins. Continuous colour scales cannot be named. Bin into four to six labelled ranges for the legend, even if the drawing uses a continuous scale.
Correlation matrices. Diagonals and mirrored halves duplicate information; show only one triangle in the table version.
Calendar heatmaps. Contribution-style grids should announce dates in full (“Tuesday 2 September, 7 commits”), not grid coordinates.
Common mistakes
Permalink to "Common mistakes"- Rainbow or red-green palettes.
- Values only on hover.
- Gradient legends with no numbers.
- Text on dark cells that fails contrast.
Framework notes
Permalink to "Framework notes"ECharts heatmaps support visualMap with pieces for binned legends and aria for generated descriptions; enable label.show for values. Highcharts heatmaps with the accessibility module expose cells to keyboard navigation; set dataLabels.enabled and a sequential colorAxis. Nivo and D3 heatmaps render SVG cells; add role, names and focus handling, or render an HTML table as the primary view.
// ECharts: binned legend and values in cells
visualMap: { type: 'piecewise', pieces: [{ lt: 1, label: 'Under 1%' }, { gte: 1, lt: 2, label: '1–2%' }, { gte: 2, label: 'Over 2%' }] },
series: [{ type: 'heatmap', label: { show: true, formatter: p => p.value[2] + '%' } }]
Worked example
Permalink to "Worked example"An observability tool’s latency heatmap (services × minutes) used a rainbow palette and hover tooltips. Two engineers with deuteranopia could not distinguish “slow” from “normal”. The team switched to a single-hue sequential palette with five named bins, rendered a 15-minute aggregated table view with values for screen reader users, added keyboard focus with “Checkout, 10:42, p95 820 ms, slow” announcements, and summarised hot spots at the top. The rainbow version is gone; nobody asked for it back.
Testing checklist
Permalink to "Testing checklist"Design system notes
Permalink to "Design system notes"Ship a heatmap component that renders an HTML table by default, with binned palette tokens verified for contrast in both themes and text colours chosen automatically per bin. Offer a canvas mode only for very large heatmaps, paired with an aggregated table.
FAQ
Permalink to "FAQ"Can a heatmap be an HTML table?
Yes, and for moderate sizes that is the most accessible option: headers, values and colour together.
How do I handle very large heatmaps?
Aggregate for the accessible table (by hour instead of minute), keep the full-resolution drawing for visual overview, and provide focus-based details.
Which palette should I use?
A single-hue sequential palette for magnitudes; a diverging palette with a neutral midpoint for values above and below a reference.
Do cells need borders?
Thin borders help distinguish adjacent cells, especially in forced colours mode.
Should values always be shown?
Whenever space allows. Otherwise on focus and in the table.
How should the summary be phrased?
Name the extremes and patterns: “Errors peak in APAC between 02:00 and 04:00; all other regions stay below 1%.”
Should a heatmap's table be sortable?
Sorting by a column (a region, an hour) helps users find the worst cells quickly. Keep the original order as the default so the table matches the drawing.
Related
Permalink to "Related"- Sequential and diverging palettes — choosing colours
- Table alternatives for choropleth maps — geographic heatmaps
- Accessible histograms and distributions — another binned view