Generating a Text Summary of a Chart Trend
A screen-reader user lands on your chart and hears “graphic” — no direction, no peak, no sense of whether the line went up or down.
The fix is a generated sentence or two that states the shape of the data — starting value, ending value, the extremes, the overall direction, and any notable outlier — attached to the chart so assistive technology reads it automatically. This page is part of alternative representations for charts, and it takes the shared derived facts described there and turns them into readable prose exposed through aria-describedby. The goal is not a literary description; it is a reliable, machine-generated gist that always matches the numbers the chart draws.
Diagnostic checklist
Broken vs fixed
// ❌ BROKEN: a hand-written summary that the data will silently outgrow.
function renderChart(root: HTMLElement, series: Series): void {
const svg = root.querySelector("svg")!;
// A11Y: static description written once; when the series updates in Q3 this
// sentence still claims the old peak, actively misinforming AT users.
svg.setAttribute("aria-label", "Revenue rose steadily through the first half");
// ...draw marks...
}
// ✅ FIXED: generate the summary from the series and connect it with aria-describedby.
function renderChart(root: HTMLElement, series: Series): void {
const svg = root.querySelector("svg")!;
const facts = deriveFacts(series); // shared O(n) extraction
const summary: string = summarise(series, facts);
// A11Y: the summary is real, selectable, translatable text in the DOM,
// not pixels — screen readers, translation, and reflow all work on it.
let desc = root.querySelector<HTMLParagraphElement>(".chart-summary");
if (!desc) {
desc = document.createElement("p");
desc.className = "chart-summary visually-hidden";
desc.id = `summary-${series.name.replace(/\s+/g, "-").toLowerCase()}`;
root.appendChild(desc);
}
desc.textContent = summary; // regenerate on every data change
// A11Y: role=img gives the SVG a single accessible object; describedby adds detail.
svg.setAttribute("role", "img");
svg.setAttribute("aria-label", `${series.name} over time`);
svg.setAttribute("aria-describedby", desc.id); // WCAG 1.1.1 text alternative
// ...draw marks...
}
The critical distinction is that the fixed version’s description is derived, so it cannot lie. When the series gains a Q3 that overtakes the old peak, deriveFacts finds the new maximum and summarise writes a new sentence on the same render pass that redraws the line. The broken version’s prose is a promise the code stops keeping the moment the data moves.
The summary generator
The generator is a small, deterministic template over the derived facts. Keep it factual and terse; the summary competes for a screen-reader user’s attention with everything else on the page, so it should state the shape and stop.
// A11Y: build the sentence from facts so wording always matches the drawn data.
function summarise(series: Series, f: SeriesFacts): string {
const dir: string =
f.direction === "flat"
? "stayed roughly flat"
: `${f.direction === "rising" ? "rose" : "fell"} ${Math.abs(Math.round(f.changePct))}%`;
const parts: string[] = [
`${series.name} ${dir} from ${f.first.yFormatted} in ${f.first.xLabel} ` +
`to ${f.last.yFormatted} in ${f.last.xLabel}.`,
`The peak was ${f.max.yFormatted} in ${f.max.xLabel} and the low was ` +
`${f.min.yFormatted} in ${f.min.xLabel}.`,
];
const outlier: Point | null = findOutlier(series);
if (outlier) {
parts.push(`${outlier.xLabel} stands out at ${outlier.yFormatted}, well outside the trend.`);
}
return parts.join(" ");
}
// Flag a single point more than 2 standard deviations from the mean.
function findOutlier(series: Series): Point | null {
const ys: number[] = series.points.map((p) => p.y);
const mean: number = ys.reduce((a, b) => a + b, 0) / ys.length;
const variance: number = ys.reduce((a, b) => a + (b - mean) ** 2, 0) / ys.length;
const sd: number = Math.sqrt(variance);
if (sd === 0) return null;
let worst: Point | null = null;
let worstZ = 2; // threshold; only report if it exceeds 2 SD
for (const p of series.points) {
const z: number = Math.abs((p.y - mean) / sd);
if (z > worstZ) { worstZ = z; worst = p; }
}
return worst;
}
Step-by-step fix
// Called on initial render and on every data update.
function updateSummary(root: HTMLElement, series: Series): void {
const facts = deriveFacts(series);
const el = root.querySelector<HTMLElement>(".chart-summary");
if (el) el.textContent = summarise(series, facts); // one write, mutate in place
}
Verification
// Assert the summary cites the real extremes and is wired to the chart.
const facts = deriveFacts(series);
const summary = summarise(series, facts);
console.assert(summary.includes(facts.max.yFormatted), "summary must mention the peak value");
console.assert(summary.includes(facts.min.yFormatted), "summary must mention the low value");
const svg = document.querySelector("svg")!;
const descId = svg.getAttribute("aria-describedby");
console.assert(!!descId && !!document.getElementById(descId), "aria-describedby must resolve to an element");
In DevTools, open the Accessibility pane, select the chart node, and confirm its computed Description is the generated sentence, not empty. Then run a real screen reader (VoiceOver, NVDA, or Orca) over the chart and verify it announces the trend, the peak, and the low — not just “image”.
Why aria-describedby and not aria-label
Both put text into the accessibility tree, but they play different roles and screen readers treat them differently. aria-label sets the accessible name — the short thing announced first, ideal for “Revenue over time”. aria-describedby supplies the accessible description — supplementary detail announced after the name, and it points at existing DOM text rather than duplicating a string in an attribute. A trend summary is description, not name: it is the detail a user asks to hear after the headline. Using describedby also keeps the sentence as real, translatable, selectable DOM text that reflows and localises, whereas a long aria-label is an opaque attribute string that translation tools and text reflow ignore. Reserve aria-label for the concise name and let describedby carry the generated paragraph.
Edge cases & gotchas
- Empty or single-point series. Guard before summarising; “rose 0% from X to X” is nonsense. For one point, state the single value; for none, say the series is empty rather than emitting a broken sentence.
- Flat data. A near-zero change should read “stayed roughly flat”, not “rose 0%”. Use a small threshold (around 1%) to classify flatness so trivial noise is not reported as a trend.
- Over-eager outlier detection. Standard-deviation thresholds fire constantly on volatile data. Cap the report to a single most-extreme point and only above a firm threshold, or the summary becomes a list of every wobble.
- Locale and units. Always summarise from the pre-formatted
yFormattedstrings, not raw numbers, so currency, grouping, and units match the chart and the user’s locale rather than emitting a bare float. - Stale summary after filtering. Interactive filters change the visible series; if you regenerate the chart but not the summary, the description describes hidden data. Route both through one update function.
- Politeness on live updates. If the chart streams, do not fire the summary into an assertive live region on every tick. Update the
describedbytext quietly and let a separate throttled region announce material changes, as covered in the live-region guidance.
Frequently Asked Questions
Should the trend summary be visible on screen or hidden for screen readers only?
Either can be correct, and a visible summary often helps everyone, including sighted users skimming a dashboard. If you hide it, use a visually hidden utility class that keeps the text in the accessibility tree rather than display: none, which removes it entirely. The safest default is a concise visible caption plus the fuller generated description referenced by aria-describedby.
How detailed should an auto-generated chart summary be?
Terse. State direction, the start and end values, the peak and the low, and at most one genuine outlier — then stop. A summary that recites every data point duplicates the data table and exhausts the listener. The summary is the headline; the table is the full record, so let each do its job.
Does the summary replace an accessible data table?
No. The summary conveys the gist quickly but deliberately omits most values, so a user needing an exact figure for a specific point still needs the table. Ship both: the generated sentence for orientation and the table for precise, row-by-row access to the underlying numbers.
Related
- Alternative representations for charts — the guide that frames summaries, tables, audio, and exports.
- Offering a downloadable data table fallback — the exact-values companion to the summary.
- Exposing chart data as an accessible data table — semantic table markup screen readers navigate.
- ARIA live regions for real-time data streams — announcing summary changes without flooding the buffer.