A screen reader, handed a Semiotic chart, announces something terse: "line chart, 9 points." Accurate, useless. The research on accessible visualization is blunt about this:Lundgard & Satyanarayan (IEEE VIS 2021)found that blind and low-vision readers rank statistics andtrends as the most useful things a description can convey — well above a restatement of the chart type.describeChart() generates exactly that: a layered, natural-language description built from the chart's config.
The four-level model
Lundgard & Satyanarayan organize description content into four levels. describeChart() produces the first three from the config alone, and — when you hand it the chart's intent — an opt-in fourth:
- L1 — Encoding. The chart type and what's mapped to which channel ("a line chart of sales by month").
- L2 — Statistics. Ranges, extrema with their labels, mean ("sales ranges from 100 in January to 350 in March").
- L3 — Trend. Overall direction and notable shape over an ordered axis ("rises to a peak of 350 in March", or "after peaking at 400 in February").
- L4 — Intent. The illocutionary sentence — what the chart is asking you to do ("This is an alerting chart; the spike at 11:00 is the point to investigate"). Opt-in: pass the chart's
capability. Deeper domain meaning (why this chart, in this report) still belongs to your summary or an LLM.
Try it
Each line is one semantic level; the combined text is what a screen reader would announce. The L4 line shows how the same chart shape reads differently once you declare its intent — note how the alerting example points at the spike. Computed live in your browser.
L1EncodingA line chart of sales by month.
L2Statisticssales ranges from 4,200 (Jan) to 9,100 (Apr), with a mean of 6,300 across 4 points.
L3TrendOverall sales rises from 4,200 (Jan) to a peak of 9,100 (Apr).
L4IntentThis is a trend chart; read it for the trajectory of sales, which rises from 4,200 (Jan) to 9,100 (Apr).
Combined (what a screen reader hears):
A line chart of sales by month. sales ranges from 4,200 (Jan) to 9,100 (Apr), with a mean of 6,300 across 4 points. Overall sales rises from 4,200 (Jan) to a peak of 9,100 (Apr). This is a trend chart; read it for the trajectory of sales, which rises from 4,200 (Jan) to 9,100 (Apr).
Wiring: ChartContainer is the opt-in layer
Semiotic doesn't bake auto-description into every bare chart. Full accessible chrome — title, caption, and the generated description — lives at the ChartContainer layer, as an explicit opt-in. Set describe and give the container achartConfig (which it already takes for "copy config"), and it renders a screen-reader-only description derived from that config:
JSX
import { ChartContainer, LineChart } from "semiotic" <ChartContainer title="Sales by month" chartConfig={{ component: "LineChart", props: chartProps }} describe // sr-only L1–L3 description // describe={{ visible: true }} // also show it as a visible caption // describe={{ levels: ["l1","l2"] }} // choose verbosity > <LineChart {...chartProps} /> </ChartContainer>
Why opt-in at the container, not automatic on every chart? Because the bare chart space is a deliberate baseline (keyboard nav, focus ring, live region, data table), and the container is where presentation chrome belongs. It also keeps the description in one place instead of competing with the chart's own terse aria-label. Theaccessibility audit knows about this: enabling describe turns itsassistive.features-described finding from a warning into a pass.
Programmatic API
JSX
import { describeChart } from "semiotic/utils" const { text, levels } = describeChart("LineChart", { data: salesData, xAccessor: "month", yAccessor: "sales", }) text // "A line chart of sales by month. sales ranges from … Overall sales rises …" levels.l1 // encoding levels.l2 // statistics levels.l3 // trend // Pick verbosity: describeChart("BarChart", props, { levels: ["l1", "l2"] })
L4 — the communicative act
L1–L3 describe the chart's shape. L4 names itscommunicative act — the verb behind "what is this chart doing?" — and points the reader at the feature that act asks them to act on. It's the production↔reception join: the intent metadata already lives in each chart'scapability descriptor, and feeding it to describeChart turns it into the layer the accessible description was missing.
JSX
import { describeChart } from "semiotic/utils" import { LineChartCapability } from "semiotic/ai" // Pass a full capability descriptor, or a resolved { family, intentScores } // (a suggestion's scores are the most precise source). L4 auto-appends. const { levels } = describeChart("LineChart", props, { capability: { family: "time-series", intentScores: { "change-detection": 5 } }, audience: executiveAudience, // optional: low familiarity → orienting nudge }) levels.l4 // "This is an alerting chart; the peak of 9,100 at March is the point to investigate."
The dominant intent picks one of eleven acts (alerting, tracking, comparing, ranking, apportioning, characterizing, relating, …); the directive clause is built from the same L2/L3 statistics. Resolve the act on its own with resolveCommunicativeAct(component, capability). The default output is unchanged — without a capability,describeChart still stops at L3.
Need all three layers plus the navigation structure as one payload for an AI agent? That'sagent-reader grounding.
Coverage & honesty
L2/L3 are richest for the families where a quantitative measure over a dimension is the point: XY (line, area, scatter),bar, part-to-whole (pie, donut, funnel), and distributions. For network, hierarchy, geo, and single-value charts, describeChart() returns a clean L1 description (type and structure) and stops, rather than inventing a trend that isn't there. It's a strong first draft and a reliable fallback — but L1–L3 describe shape, and L4 the communicative act, not the domain meaning. For the deeper "why" — why this chart, in this report — still write a summary (or pipe the generated text plus your data through an LLM).