Your chart says nothing to a screen reader
A Canvas chart is a rectangle of paint to the accessibility tree, and every Android chart library leaves it that way. What a useful description contains, why the generator should be a pure function, and where auto-generated summaries stop being enough.
- Accessibility
- Charts
- Compose
Turn on TalkBack and swipe through a screen with a chart on it. On most Android apps, here is what you hear:
Nothing. The focus skips straight past it to whatever comes next.
That's not a bug in the screen reader. A chart drawn on a Canvas is, as far as the accessibility tree is concerned, a rectangle of paint. Compose has no idea it means anything, because meaning is not something you can infer from draw calls. If nobody attached a description, there is nothing to read.
Why the chart libraries don't fix this for you
I went looking before writing FormaUI's charts, on the assumption this was solved. It isn't. MPAndroidChart, Vico and YCharts all render into a canvas or a custom View and none of them ships a data summary for assistive tech. You can add one — every one of them lets you set a contentDescription on the host — but you have to know to, and you have to write the string, and you have to remember to update it when the data changes.
Which is the actual problem. It's not that accessible charts are hard; it's that the default is silence and the fix is manual. Anything manual and invisible gets skipped, because nothing fails when you skip it. Your tests pass. Your designer signs off. The chart looks great.
There is a second-order version of the same trap: a chart that is described, once, with a string like "Revenue chart". That satisfies the linter and tells a blind user precisely nothing they couldn't guess from the heading above it. A label is not a description. If a sighted user can read six values off the screen, a screen-reader user who gets "revenue chart" has been handed a locked door with a sign on it.
What a chart should actually say
The bar for a chart's description is: could someone reconstruct the interesting facts from the sentence alone? For a small categorical chart, that means the numbers. There aren't many of them; just read them out.
"Bar chart with 4 categories. Jan: 12. Feb: 32. Mar: 21. Apr: 45."
The three chart types need three different summaries, and the differences are the interesting part — because each one is an answer to "what is this chart for?"
A donut chart is about proportion, so percentages beat raw values:
"Donut chart with 3 segments. Rent: 50 percent. Food: 30 percent. Fun: 20 percent."
A line chart is usually about trend and magnitude across more points than anyone wants read aloud, so it summarises rather than enumerates:
"Line chart with 5 points. Range 3 to 45. Latest value 27."
That last one is a deliberate loss of information, and it's the design decision I'd defend hardest. A 200-point time series read out point by point is not accessibility, it's a denial-of-service on someone's afternoon. Shape, bounds, and where it ended is what the chart was communicating anyway.
Generating it, and the one API decision that matters
The description is derived from the same data the chart draws, so it can't drift:
internal fun barChartContentDescription(
entries: List<FormaChartEntry>,
valueFormatter: (Float) -> String,
): String {
if (entries.isEmpty()) return "Bar chart with no data"
val categories = if (entries.size == 1) "1 category" else "${entries.size} categories"
val data = entries.joinToString(separator = ". ", postfix = ".") { entry ->
"${entry.label}: ${valueFormatter(entry.value)}"
}
return "Bar chart with $categories. $data"
}
Three things in there are load-bearing out of proportion to their size.
It takes the same valueFormatter the chart draws with. If the visible labels say $1,200, the description says $1,200. Passing the formatter in is what stops a currency chart from announcing 1200.0 — and it costs one parameter.
The empty case has its own sentence. "Bar chart with no data" rather than a chart that describes itself as having zero categories and then lists nothing. Empty states are where auto-generated strings usually go strange.
Singular and plural are handled. "1 category", not "1 categories". This is trivial and I mention it because auto-generated accessibility text is read aloud, where grammatical debris is far more grating than it is on screen. Nobody reviews it, because nobody reads it — they'd have to turn on a screen reader to notice.
And the API decision: the description is auto-generated but overridable.
@Composable
fun FormaBarChart(
entries: List<FormaChartEntry>,
…
contentDescription: String? = null,
)
null means "generate one." A string means "you know your domain better than my template does." The default is the point — a chart that describes itself unless you say otherwise inverts the incentive, because now the accessible path is the one that requires no work. But the override has to exist, because "Q3 revenue by region, trending up" is better than anything a generic formatter will produce, and a library that only offers its own sentence forces a choice between a mediocre description and no description at all.
Pure functions, which is why they're tested
Those builders are ordinary Kotlin functions. Not composables, no @Composable annotation, no theme access, no Compose runtime at all — they take data and return a String.
That's not tidiness. It means the entire content of every chart's accessibility description is testable without a Compose host, a Robolectric runtime, or a semantics tree:
@Test
fun barChartContentDescription_singleEntry_usesSingularCategory() {
assertEquals(
"Bar chart with 1 category. Jan: 12.",
barChartContentDescription(entries, FormaChartDefaults.ValueFormatter),
)
}
Plain JUnit, microseconds, no rule. Which is exactly why edge cases like the singular, the empty list, a custom formatter, and a donut whose values sum to zero all have tests — cheap tests get written. The same assertions expressed as onNodeWithContentDescription(...).assertExists() would be an order of magnitude slower and would only tell you a node exists with some string, not whether the string was any good.
The rule generalises past charts: push accessibility text generation out of the composable and into a pure function. Semantics assertions verify wiring; unit tests verify content. You want both, and only one of them is cheap.
Wiring it up is then one modifier:
val autoDescription = remember(entries, valueFormatter) {
barChartContentDescription(entries, valueFormatter)
}
val resolvedDescription = contentDescription ?: autoDescription
Box(
modifier = modifier
.fillMaxWidth()
.defaultMinSize(minHeight = FormaChartDefaults.MinChartHeight)
.semantics { this.contentDescription = resolvedDescription },
) {
Canvas(Modifier.matchParentSize()) { … }
}
The remember keyed on entries is worth a glance: string-building on every recomposition of a chart that redraws during an animation would be genuinely wasteful, and the key is the same list identity the chart uses to decide whether to replay its entry animation.
That Box-owns-semantics, Canvas-fills-it structure also happens to be the fix for a rendering bug I wrote about separately — Canvas is a Spacer under the hood and reports zero for any non-fixed dimension. Two unrelated problems, one shape of solution.
Where this stops
Being straight about what these descriptions are not.
They're a summary, not an interface. A screen-reader user gets the numbers read as one utterance. They cannot focus an individual bar, tab between segments, or query a point. Real per-datum semantics — a child node per bar with its own description and traversal order — is a bigger piece of work and it's the right thing to build for a chart anyone needs to interrogate rather than read.
They don't help with colour. A donut chart distinguishing segments only by hue is inaccessible to a much larger group than screen-reader users, and a content description doesn't fix it. That's what the legend is for, and it's why the legend carries text rather than colour swatches alone.
They're presentation charts. FormaUI's charts have no tooltips and no touch scrubbing, which conveniently sidesteps the hardest accessibility question in charting — how do you expose an interactive affordance to someone who can't point at it? If you need an interactive chart, use a chart library, and then you own that question.
And the summary can be wrong in a way nothing catches. If you pass a formatter that renders 0.5 as 50% for the visible labels but the underlying values are already percentages, the description is confidently incorrect. The generation is only as good as the data and the formatter you hand it.
The short version
If you ship a component that paints its own pixels and means something:
- Generate a description by default. Manual accessibility work that nothing enforces does not happen, and no test will tell you it didn't.
- Say the numbers, not the noun. "Revenue chart" is a label. "Jan: 12. Feb: 32." is a description.
- Reuse the display formatter so the spoken values match the drawn ones.
- Summarise when enumerating would be cruel — bounds and latest beat 200 data points.
- Handle empty and singular explicitly. Nobody proofreads a string only a screen reader ever says.
- Let callers override it, or they'll choose between your sentence and nothing.
- Keep the generator a pure function so its content is unit-tested rather than merely wired.
None of this is expensive. It's about forty lines per chart type, most of it string formatting, and it's the difference between a chart that exists for everyone and one that a screen reader walks straight past.
FormaUI is an opinionated Material 3 component library for Jetpack Compose — 40 components with the design work already done, including bar, line and donut charts built on Compose Canvas with no third-party chart dependency. Every one describes itself. Try them live in your browser.