Accessible price charts
How to make crypto price charts accessible with text summaries, data tables, patterns as well as colors, and tooltips that work without a mouse.
6 min read
On a token page, the price chart is often the largest element and the least accessible one. It is a canvas or an SVG with no text, a crosshair that only appears on hover, and green and red as the only signal of direction. A screen reader user gets nothing, a keyboard user can't read a single value, and someone who can't tell red from green has to guess.
The way out isn't to make the drawing itself perfectly accessible. It's to make the chart one of several ways to get the same information: a sentence that states what it shows, a table with the numbers, and a drawing that doesn't depend on color or a mouse.
Lead with a written summary
1.1.1 Non-text Content (A) requires a text alternative that serves the same purpose as the image. For a price chart, the purpose is answering a few questions: where did the price start and end, how much did it change, and what were the extremes. You can generate that sentence from the same data that draws the chart:
type Point = { time: number; price: number }
const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
const pct = new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 1 })
const weekday = new Intl.DateTimeFormat('en-US', { weekday: 'long' })
export function summarize(points: Point[], name: string, period: string) {
const first = points[0]
const last = points[points.length - 1]
const low = points.reduce((a, b) => (b.price < a.price ? b : a))
const high = points.reduce((a, b) => (b.price > a.price ? b : a))
const change = (last.price - first.price) / first.price
const direction = change > 0 ? 'up' : change < 0 ? 'down' : 'unchanged'
return (
`${name} ${direction} ${pct.format(Math.abs(change))} over ${period}, ` +
`from ${usd.format(first.price)} to ${usd.format(last.price)}. ` +
`Low ${usd.format(low.price)} on ${weekday.format(low.time)}, ` +
`high ${usd.format(high.price)} on ${weekday.format(high.time)}.`
)
}
With a week of data, that produces something like: "Ethereum up 4.2% over the past 7 days, from $3,010.00 to $3,136.42. Low $2,951.10 on Tuesday, high $3,180.55 on Friday."
Show this sentence on screen, as a caption under or above the chart. It helps everyone who wants the gist without studying the line, and a text alternative that everyone can see is less likely to go stale unnoticed. Making token amounts and prices accessible covers how to format the numbers inside it.
Mark up the drawing
With the summary visible, the chart element only needs a short name that says what it is:
<figure>
<svg role="img" aria-label="Ethereum price chart, past 7 days" viewBox="0 0 600 200">
<polyline points={path} fill="none" stroke="currentColor" strokeWidth={2} />
</svg>
<figcaption>{summarize(points, 'Ethereum', 'the past 7 days')}</figcaption>
</figure>
role="img" makes screen readers treat the SVG as a single image instead of wandering into its paths. Canvas charts work the same way: put role="img" and an aria-label on the <canvas> element, and keep the summary in real text next to it.
Small sparklines in a market table are a different case. If the row already has a "7d %" column, the sparkline repeats that information visually, and hiding it with aria-hidden="true" saves screen reader users from hearing "image" in every row. If the sparkline is the only place the trend appears, it needs its own short text. Decorative or informative walks through that decision.
Put the numbers in a table
Some people want the actual values: an analyst using a screen reader, someone who wants to compare days, someone zoomed in to 400% who can't follow the line. A "Show data" toggle that reveals a table answers all of them. For a seven-day chart of hourly data, 168 rows is too many, so summarize into one row per day:
<table>
<caption>Ethereum daily prices, past 7 days, in US dollars</caption>
<thead>
<tr>
<th scope="col">Day</th>
<th scope="col">Open</th>
<th scope="col">High</th>
<th scope="col">Low</th>
<th scope="col">Close</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Tuesday</th>
<td>3,042.18</td>
<td>3,061.90</td>
<td>2,951.10</td>
<td>2,988.45</td>
</tr>
<!-- one row per day -->
</tbody>
</table>
Header cells with scope let screen readers announce the matching headers, such as "Tuesday" and "Low", as people move from cell to cell. Exposing those relationships in markup is what 1.3.1 Info and Relationships (A) asks for. Use a real <table>, not a grid of divs.
Don't let color carry the meaning
1.4.1 Use of Color (A) applies to charts as much as to text. The common failures in crypto dashboards, and their fixes:
| Situation | Color-only version | Add |
|---|---|---|
| Two series, such as ETH vs BTC | a blue line and an orange line | a dashed line for one series, and a label at the end of each line |
| Candlesticks | green up, red down | hollow bodies for up, filled for down |
| Portfolio allocation | colored slices with a separate legend | labels on or next to each slice, or a sorted list beside it |
| Area above or below entry price | green and red fills | a labeled reference line at the entry price |
Direct labels are worth the effort. A legend forces everyone to match colors back and forth, which is hardest for exactly the people who struggle with color.
The marks themselves also need contrast. 1.4.11 Non-text Contrast (AA) asks for 3:1 against adjacent colors for graphical objects that are needed to understand the content, so a price line or candle body must reach 3:1 against the chart background. Faint gridlines are fine, as long as nobody needs them to read a value. See don't rely on color alone for related patterns.
Tooltips that don't need a mouse
Many charts show exact values only through a hover crosshair, which shuts out keyboard and touch users. Make the chart focusable and let arrow keys move the crosshair:
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
const last = points.length - 1
const moves: Record<string, number> = {
ArrowRight: Math.min(active + 1, last),
ArrowLeft: Math.max(active - 1, 0),
Home: 0,
End: last,
}
if (event.key === 'Escape') {
setTooltipOpen(false)
return
}
if (!(event.key in moves)) return
event.preventDefault()
setActive(moves[event.key])
setTooltipOpen(true)
}
Attach this to a wrapper with tabIndex={0} and a visible focus style, and show the tooltip for the active point whenever the wrapper has focus. Be realistic about who this serves. Screen readers in browse mode use the arrow keys themselves, so this mainly helps sighted keyboard users. The summary and the table are what make the chart work with a screen reader.
Tooltips also fall under 1.4.13 Content on Hover or Focus (AA). It must be possible to dismiss the tooltip without moving the pointer or focus, which the Escape handler above covers. The pointer must be able to move onto the tooltip without it disappearing. And the tooltip has to stay visible until the user moves away or dismisses it. On touch screens, a tap on the chart should show the nearest value and keep it visible, since there is no hover.
Ranges, zoom and live updates
Drag-to-zoom brushes are common in trading interfaces. On their own they fail 2.5.7 Dragging Movements (AA), which requires a way to do the same thing with a single pointer and no dragging, unless dragging is essential. Range buttons provide that alternative. Keep the visible text in each button's name and add the expansion for screen readers, so voice control users can still say "click 1W":
<div role="group" aria-label="Time range">
<button type="button" aria-pressed="false">1D<span class="sr-only"> (1 day)</span></button>
<button type="button" aria-pressed="true">1W<span class="sr-only"> (1 week)</span></button>
<button type="button" aria-pressed="false">1M<span class="sr-only"> (1 month)</span></button>
</div>
If the chart updates live, don't put the chart or its summary inside a live region, and provide a way to pause the updates, as 2.2.2 Pause, Stop, Hide (A) requires for automatically updating content shown alongside other content. Update the summary when the range changes, not on every tick. For layout and density across a whole dashboard, see designing crypto dashboards that stay readable.