Good pushback in the comments, worth a proper answer instead of a quick reply, because "store the value in a data-* attribute" is a common instinct that doesn't actually solve the problem.
The core issue
A st-core chart is a handful of <div>s: .chart-fill, .chart-line, .chart-dot, .chart-grid. Visually they're a polygon shape. To the accessibility tree, they're empty, unlabeled <div>s with no text content. A screen reader landing on .chart-fill announces... nothing. Not the shape, not the numbers, not even "chart." It's invisible in the literal sense assistive tech cares about.
Why data-* attributes don't fix this
This is the specific thing worth being precise about. Putting data-value="72" on a .chart-dot does not make a screen reader say "72."
data-* attributes exist for JavaScript and CSS hooks, not for the accessibility tree. Browsers don't expose arbitrary data-* values to assistive tech at all. Two ways people try to route around this, and why they're both unreliable:
-
content: attr(data-value)in CSS — technically renders the value visually via generated content, but screen reader support for CSS generated content is inconsistent across NVDA, JAWS, and VoiceOver. Some announce it, some skip it entirely, and it's never been part of the reliable accessibility contract. Don't build on it. -
Reading
data-*via JS and injecting it somewhere on interaction — works, but only if you're injecting it into something the accessibility tree actually surfaces (a live region, visible text, anaria-label). Thedata-*attribute itself is just storage; it does no announcing on its own.
So the value has to end up as real text, or a real ARIA attribute, somewhere in the DOM. That's the actual constraint everything below is solving for.
Two patterns, and when to use each
Method 1 — Visually hidden data table (best for multi-point charts)
The most structured option. A real <table>, hidden from sighted users, fully readable by assistive tech.
.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;
}
<div class="chart" role="img" aria-label="Weekly revenue chart" aria-describedby="chart-data-table">
<div class="chart-fill" aria-hidden="true"></div>
<div class="chart-line" aria-hidden="true"></div>
<div class="chart-grid" aria-hidden="true"></div>
</div>
<table id="chart-data-table" class="sr-only">
<caption>Weekly revenue by day</caption>
<thead>
<tr><th scope="col">Day</th><th scope="col">Revenue</th></tr>
</thead>
<tbody>
<tr><td>Sun</td><td>$450</td></tr>
<tr><td>Mon</td><td>$700</td></tr>
<tr><td>Tue</td><td>$300</td></tr>
<tr><td>Wed</td><td>$900</td></tr>
<tr><td>Thu</td><td>$600</td></tr>
<tr><td>Fri</td><td>$800</td></tr>
<tr><td>Sat</td><td>$500</td></tr>
</tbody>
</table>
Two things doing the real work here:
-
aria-hidden="true"on the decorative divs tells the screen reader to skip them entirely, so it never lands on an empty, unlabeled.chart-filland announces nothing confusing. -
aria-describedbylinks the chart container to the table's id. This is what makes the announcement sequence coherent instead of just "here's a table somewhere on the page, good luck."
What actually gets announced, tabbing through with NVDA or VoiceOver: the chart container reads as "Weekly revenue chart, image" (from role="img" + aria-label), and because of aria-describedby, most screen readers will also read the table's caption right after, or the user can explicitly navigate into it. Once inside the table, standard table navigation takes over, arrow keys or table-specific shortcuts move cell to cell, and each cell reads its column header plus value: "Revenue, column 2 of 2, $900". That's the payoff of using a real <table> instead of a styled list, the semantics come free.
Method 2 — Text summary or live region (best for simple stats)
Overkill to build a full table for a single trend line or one headline number. A sentence does more work:
<div class="chart" role="img" aria-label="Weekly revenue trend, increasing" aria-describedby="chart-summary"></div>
<p id="chart-summary" class="sr-only">
Revenue rose from $450 on Sunday to a peak of $900 on Wednesday, ending the week at $500, a 12% increase overall.
</p>
One paragraph, one clear announcement, no table navigation required for something that doesn't need it.
Combining both, and handling live updates
For anything with a "Randomize Data" button, there's a wrinkle: updating a table's cells doesn't automatically announce anything, screen readers only speak up on their own when something is in an aria-live region. But making the whole table a live region is a bad idea, every cell update fires a fresh announcement and buries the user in noise.
The pattern that actually works well:
-
Keep the full data table static (no
aria-live). It's there for on-demand navigation, the user tabs in and reads it whenever they want, at their own pace. -
Add a separate, short
aria-live="polite"summary just for interaction feedback, updated alongside the chart on every data change:
<p id="chart-live-summary" class="sr-only" aria-live="polite"></p>
function updateChart(values) {
const style = values.map((v, i) => `--st-p${i + 1}: ${100 - v}%;`).join(' ');
document.querySelector('.chart-fill').style.cssText = style;
document.querySelector('.chart-line').style.cssText = style;
document.querySelector('.chart').style.cssText = style;
// Keep the full sr-only table in sync too
const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
document.querySelectorAll('#chart-data-table tbody tr').forEach((row, i) => {
row.children[1].textContent = `$${values[i]}`;
});
// Short, live-announced summary for the interaction itself
const peak = Math.max(...values);
const peakDay = days[values.indexOf(peak)];
document.getElementById('chart-live-summary').textContent =
`Chart updated. Peak value $${peak} on ${peakDay}.`;
}
Clicking "Randomize" now gets a screen reader user a concise, immediate confirmation ("Chart updated. Peak value $900 on Wednesday.") without forcing them to re-navigate a whole table just to know something changed, while the detailed table stays available if they want to dig in.
The short version
-
data-*attributes are storage, not an accessibility mechanism. They announce nothing on their own. -
role="img"+aria-labelgives a one-line identity to the chart. -
aria-hidden="true"on every decorative.chart-*div keeps the screen reader from stumbling into empty presentational elements. -
aria-describedbypointing at a real<table>(or a short<p>for simple charts) is what actually gets the data announced, and gets it announced in context. - For live-updating charts, keep the detailed table silent and pair it with a small
aria-live="polite"region just for change notifications, don't make the whole table live
Top comments (0)