uPlot Integration
MAIDR provides an adapter for uPlot, the small, fast canvas charting library for time series. uPlot is the engine that draws Grafana's time-series panels, so it is where much of the world's monitoring data is looked at. The adapter reads a live uPlot chart into MAIDR and makes it navigable by keyboard, with audio sonification, text descriptions and braille output, and it keeps that reading in step as the chart streams new data.
Quick Start
Load uPlot and the MAIDR uPlot bundle, then add maidrUPlot.maidrPlugin() to the chart's plugins:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>My uPlot Chart</title>
<!-- 1. Load uPlot and its stylesheet -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.32/dist/uPlot.min.css" />
<script src="https://cdn.jsdelivr.net/npm/uplot@1.6.32/dist/uPlot.iife.min.js"></script>
<!-- 2. Load MAIDR's uPlot adapter (exposes the `maidrUPlot` global) -->
<script src="https://cdn.jsdelivr.net/npm/maidr/dist/uplot.js"></script>
</head>
<body>
<div id="chart"></div>
<script>
// One reading every 5 minutes, in uPlot's default time unit (seconds).
const start = Date.UTC(2026, 0, 12, 9, 0) / 1000;
const times = [];
const cpu = [];
for (let i = 0; i < 24; i++) {
times.push(start + i * 300);
cpu.push(Math.round(35 + 25 * Math.sin(i / 3)));
}
// 3. Create the chart as usual, with the MAIDR plugin.
new uPlot({
title: 'Web server load',
width: 720,
height: 360,
series: [{}, { label: 'CPU %', stroke: '#1f77b4', width: 2 }],
axes: [{ label: 'Time' }, { label: 'Utilisation (%)' }],
plugins: [maidrUPlot.maidrPlugin()],
}, [times, cpu], document.getElementById('chart'));
</script>
</body>
</html>
The uplot.js bundle carries MAIDR itself, so no separate maidr.js is needed. Once the page loads, click the chart (or Tab to it) and MAIDR activates with:
- Audio sonification — tones representing data values
- Text descriptions — spoken via screen readers
- Braille output — refreshable braille display support
- Keyboard navigation — arrow keys to move between data points
Using with npm/Bundlers
npm install maidr uplot
import uPlot from 'uplot';
import { maidrPlugin } from 'maidr/uplot';
import 'uplot/dist/uPlot.min.css';
new uPlot({
title: 'CPU usage',
width: 640,
height: 320,
series: [{}, { label: 'CPU %', stroke: 'steelblue' }],
plugins: [maidrPlugin()],
}, [timestamps, cpu], document.getElementById('chart'));
maidr/uplot never imports uplot: it reads the instance it is handed, so any uPlot 1.6 build the page loads works. The package ships TypeScript types for every export (MaidrUPlotOptions, MaidrUPlotHandle, UPlotSeriesMaidrOptions and the rest).
Binding an existing chart
When you cannot add a plugin — the chart is created by code you do not own — bind the instance instead:
const u = new uPlot(opts, data, document.getElementById('chart'));
const handle = maidrUPlot.bindUPlot(u, { title: 'Web server load' });
bindUPlot(u, options) accepts a chart that has not drawn yet: uPlot draws for the first time just after new uPlot(...) returns, and the binding waits for that (uPlot's ready hook) before reading it. It returns a handle:
| Member | What it does |
|---|---|
id |
The MAIDR chart id |
refresh() |
Re-reads the chart. u.setData(...) does this automatically; call it after changing series or axes (u.addSeries, u.setSeries, …) |
dispose() |
Unmounts MAIDR and puts the chart back where it stood |
Calling bindUPlot again on a chart that is already bound, including one bound by maidrPlugin, returns the existing handle rather than binding twice. u.destroy() unbinds MAIDR on its own.
The chart must be in the document when it is bound. maidrPlugin logs a console warning and skips a chart it cannot read rather than throwing.
How It Works
The adapter:
- Reads the chart — takes uPlot's columnar
u.datatogether with itsseries,axesandscales, and builds one MAIDR subplot from them (see Supported Series). - Mounts MAIDR around the chart — uPlot's
.uplotroot is moved inside MAIDR's accessible figure at the spot where it stood, so the chart looks the same and gains a focusable, navigable interface. - Draws the reader's position back onto the chart — as a box over the focused mark and by moving uPlot's own cursor there (see Highlighting and Click-to-Navigate).
- Follows the chart —
u.setData(...)is re-read (streaming appended points, see Live Streaming), a resize redraws the highlight, andu.destroy()unmounts MAIDR.
Supported Series
uPlot draws every series through a paths function and does not record what kind of mark it drew. The adapter reads the kind off the path uPlot last built for the series, and maps the kinds to MAIDR layers like this:
Stable chart types
| Series | How uPlot draws it | MAIDR reads it as |
|---|---|---|
| Line | the default paths (linear), with or without a fill |
one line layer per y scale, one row per series |
| Multi-line | several line series on one y scale | the same line layer; Up/Down move between the series |
| Stepped or spline line | uPlot.paths.stepped(...) or uPlot.paths.spline(...) |
a row of that scale's line layer |
| Bar | uPlot.paths.bars(...) |
a bar layer per series |
| Stacked bar | bar series joined by bands, read with stacked: true (see Stacked Charts) |
one stacked_bar layer per stack, one row per series, and a total per category |
| Scatter | points only: paths: () => null with points: { show: true }, or uPlot.paths.points(...) |
a point (scatter) layer per series |
| Faceted scatter | every series of a faceted chart (mode: 2) |
a point (scatter) layer per series |
Experimental chart types
These may change without a deprecation period; see Trace type stability.
| Series | How uPlot draws it | MAIDR reads it as |
|---|---|---|
| Area [experimental] | a line series with a fill, read with areas: true, or any series given kind: 'area' |
one area layer per y scale, one row per series |
| Precision-recall curve [experimental] | a line series given kind: 'pr_curve', recall on x |
one pr_curve layer per y scale, one curve per series |
| Percentile band [experimental] | a median line series whose bands name the chart's own bands around it |
one percentile_band layer, its band series absorbed |
A filled line is read as a line unless you ask for areas: it navigates exactly as a line does, and area is an experimental MAIDR type. With areas: true every line series whose fill gives a color is read as an area; a series whose own kind is set keeps that kind.
Layers follow series order and all share one subplot, so a chart that mixes kinds — bars under a line, say — is navigated with Page Up / Page Down between its layers. Line series on different y scales (a second axis on the right) become separate line layers, and the same goes for area series and stacked bars.
A stepped or spline series is read as a plain line: the values announced are the data values, but MAIDR is not told the line is stepped.
Telling MAIDR what a series is
The inference covers uPlot's own path builders. A series drawn with a custom paths function, or a plugin that defeats it, can say what it is with a maidr key on the series (uPlot leaves keys it does not know alone):
series: [
{},
{ label: 'Errors', paths: myCustomBars, maidr: { kind: 'bar' } },
{ label: 'Threshold', maidr: { exclude: true } }, // or `maidr: false`
],
or, without touching the series, through the adapter's series option, keyed by the index in u.series (it wins over a maidr key on the series):
maidrUPlot.maidrPlugin({ series: { 1: { kind: 'bar' }, 2: { exclude: true } } });
| Key | Values | Meaning |
|---|---|---|
kind |
'line' | 'area' | 'bar' | 'scatter' | 'pr_curve' |
Read the series as this, whatever it draws. 'area' reads as MAIDR's experimental area type, and 'pr_curve' as a precision-recall curve. Ignored on a faceted chart, where every series is a scatter. |
exclude |
boolean |
Leave the series out of MAIDR entirely. |
prevalence, ap |
numbers from 0 to 1 | With kind: 'pr_curve': this curve's share of positives (the height of its chance baseline) and its average precision. A percentage is refused with a warning. |
bands |
{ band, lower, upper }[] |
Read this series as the median of a fan chart; see Fan Charts. |
A series keeps the kind it was last seen drawing, so hiding one from the legend does not change what it is read as. A series that has never been drawn — hidden from the legend from the start — has no path to read and is taken as a line, uPlot's default; give it a kind if it is something else. Hidden series are still read; exclude one that MAIDR should not announce.
Fan Charts
A fan chart — a median with nested bands of quantiles around it — is a median line and the bands uPlot fills between pairs of series, bands: [{ series: [upper, lower] }]. A band says nothing about which quantiles its edges are, so the median's maidr options name each band by its index in the chart's own bands and give its two levels:
new uPlot({
bands: [{ series: [2, 1] }, { series: [4, 3] }],
series: [
{},
{ label: 'p5' }, { label: 'p95' }, { label: 'p25' }, { label: 'p75' },
{ label: 'Median', maidr: { bands: [
{ band: 0, lower: 0.05, upper: 0.95 },
{ band: 1, lower: 0.25, upper: 0.75 },
] } },
],
}, data, target);
The levels are fractions, each lower below 0.5 and each upper above it, and the bands must nest — checked as the co-located maidr declaration's bands are, and a list that fails leaves every series read as what it draws. A band index naming nothing, or a pair one band already reads, is reported and left out. The band series are absorbed into the one percentile_band layer, and the overlay marks the quantile the reader is on where its own series draws it.
Stacked Charts
uPlot has no stacking of its own. A stacked chart is drawn from running totals — each series holds its own values plus everything beneath it — and each series is joined to the one beneath it by a band, bands: [{ series: [upper, lower] }], so the fill runs down to that series rather than to zero. Taken at face value, every series but the lowest then reads as the total so far.
Pass stacked: true and each series with a band beneath it is read as its own share: its value less the value of the series the band joins it to.
const bars = () => uPlot.paths.bars({ size: [0.5, 80] });
const warningsTop = warnings.map((v, i) => v + errors[i]);
const infoTop = info.map((v, i) => v + warningsTop[i]);
new uPlot({
title: 'Log lines per day by level',
width: 720,
height: 360,
series: [
{},
{ label: 'Errors', fill: '#d62728', paths: bars(), points: { show: false } },
{ label: 'Warnings', fill: '#ff7f0e', paths: bars(), points: { show: false } },
{ label: 'Info', fill: '#1f77b4', paths: bars(), points: { show: false } },
],
bands: [{ series: [3, 2] }, { series: [2, 1] }],
axes: [{ label: 'Day' }, { label: 'Log lines' }],
scales: { x: { time: true, range: (u, min, max) => [min - 43200, max + 43200] } },
plugins: [maidrUPlot.maidrPlugin({ stacked: true })],
}, [days, errors, warningsTop, infoTop], document.getElementById('chart'));
- Stacked bars. Bar series that take part in any band — the lowest one included — become one
stacked_barlayer per stack — two stacks side by side on one scale are two layers, each with its own total — one row per series, bottom first. Left and Right move between categories and Up and Down through the stack; above the top segment MAIDR adds aSumrow holding each category's total, highlighted as a box around the whole stack. A segment is read, and boxed, from the nearest series beneath it that has a reading there. A stack is read as a grid, so a missing reading in one series counts as 0 there. Horizontal stacks (an x scale withori: 1) are read too. - Stacked lines and areas. Line and area series joined by bands are read as their own shares, but stay
line(orarea) layers: MAIDR is not told they are stacked, and does not announce a total. - Bar series outside any band are read as ordinary
barlayers, as without the option.
stacked is off by default because a band is also how uPlot fills a range — a min/max envelope, a confidence interval — and there the upper series is a value in its own right, not a share on top of the lower one. Turn it on only for charts whose bands are a stack; without it, bands are ignored and each series is read as the values it holds.
Code Examples
Multi-Line Chart
Two series on one y scale become one line layer; Up and Down switch between them.
new uPlot({
title: 'Web server load',
width: 720,
height: 360,
series: [
{},
{ label: 'CPU %', stroke: '#1f77b4', width: 2 },
{ label: 'Memory %', stroke: '#d62728', width: 2 },
],
axes: [{ label: 'Time' }, { label: 'Utilisation (%)' }],
plugins: [maidrUPlot.maidrPlugin()],
}, [times, cpu, memory], document.getElementById('chart'));
Bar Chart
const start = Date.UTC(2026, 2, 2) / 1000;
const days = Array.from({ length: 7 }, (_, i) => start + i * 86400);
const requests = [1820, 2140, 1995, 2410, 2675, 1210, 980];
new uPlot({
title: 'Requests per day',
width: 720,
height: 360,
series: [
{},
{
label: 'Requests',
fill: 'rgba(31, 119, 180, 0.6)',
stroke: '#1f77b4',
paths: uPlot.paths.bars({ size: [0.6, 100] }),
points: { show: false },
},
],
axes: [{ label: 'Day' }, { label: 'Requests' }],
scales: { x: { time: true, range: (u, min, max) => [min - 43200, max + 43200] } },
plugins: [maidrUPlot.maidrPlugin()],
}, [days, requests], document.getElementById('chart'));
Bars drawn horizontally — an x scale with ori: 1 — are read as a horizontal bar layer, with the axes swapped to match what is drawn.
Scatter Plot
A series whose paths draws nothing and whose points are shown is uPlot's points-only series. Turn the x scale's time handling off when x is not a time.
new uPlot({
title: 'Latency by payload size',
width: 720,
height: 360,
scales: { x: { time: false } },
series: [
{ label: 'Payload (KB)' },
{
label: 'Latency (ms)',
stroke: '#2ca02c',
paths: () => null,
points: { show: true, size: 8, fill: '#2ca02c' },
},
],
axes: [{ label: 'Payload (KB)' }, { label: 'Latency (ms)' }],
plugins: [maidrUPlot.maidrPlugin()],
}, [sizes, latency], document.getElementById('chart'));
Labels
| What | Where it comes from, first match wins |
|---|---|
| Chart title | the title option, then uPlot's title |
| X axis label | the xLabel option, then the label of the axis drawn against the x scale, then the x series' label, then Time on a time scale or X |
| Y axis label | the yLabel option, then the label of the axis drawn against the series' y scale, then the series' label (for a layer holding one series), then Value |
| Series name | the series' label, then Series <n> |
uPlot fills in Value (and Time for a time scale's x series) as the label of every series you leave unlabelled. The adapter treats those as unlabelled, so two unnamed lines are announced as Series 1 and Series 2 rather than both as Value.
In a multi-line layer the series name is announced as the group ("Group is CPU %").
Time Axes
uPlot's x scale is a time scale unless you set time: false, and its values are Unix timestamps in seconds by default (ms: 1e-3) or in milliseconds with ms: 1. uPlot does not keep that setting on the instance, so the adapter reads the unit off the values: timestamps below 1011 are taken as seconds, larger ones as milliseconds. Set msPerUnit (1000 for seconds, 1 for milliseconds) if your data could be misread.
Timestamps are converted to milliseconds and announced as dates, as finely as the data needs to tell neighbouring points apart:
| Smallest step between points | Announced as, e.g. |
|---|---|
| a day or more | Jan 12, 2026 |
| a minute or more | Jan 12, 9:05 AM |
| less than a minute | 9:05:30 AM |
Dates are written in US English, in the reader's time zone. A faceted chart's x values are always read as plain numbers.
Missing values (null) are gaps: a line keeps the gap in its row, and bars and scatter points with no value are left out. A bar or scatter series with no values at all has no layer until values arrive.
A chart with no data yet — a dashboard that fills on its first poll — is bound all the same, as an empty chart, and is read as soon as u.setData gives it data. An update that empties the chart empties MAIDR's reading too.
Live Streaming
uPlot has one way to change data: u.setData(newData), which replaces all of it. A streaming dashboard calls it on every tick, usually with the window slid along by a point. The adapter compares each update with the one before:
- When every series is the previous one with points added at the end — and optionally the same number of points dropped from the front, the sliding window a streaming chart keeps — the new points are streamed to MAIDR one by one through
appendData, with MAIDR'smaxWidthwindow set for each series so it drops exactly what uPlot dropped. The highlight stays on the reader's point as the window slides. Monitor mode (M) sonifies and announces each point as it arrives on the focused layer. - Anything else — a revised value, a series added or removed, a window that shrank — silently replaces the data in place.
Either way the reader's position, modes and focus are kept. Nothing extra is needed on the page:
const u = new uPlot({
title: 'Queue depth (live)',
width: 720,
height: 320,
series: [{}, { label: 'Queue depth', stroke: '#9467bd', width: 2 }],
axes: [{ label: 'Time' }, { label: 'Messages' }],
plugins: [maidrUPlot.maidrPlugin({ id: 'uplot-live' })],
}, [times, values], document.getElementById('chart'));
setInterval(() => {
times.push(times[times.length - 1] + 2);
values.push(nextValue());
times.shift();
values.shift();
u.setData([times.slice(), values.slice()]); // MAIDR streams the new reading
}, 2000);
Charts are live by default. Pass live: false for a chart whose data never changes: the figure is then not marked live, so monitor mode is unavailable and a later u.setData is only picked up the next time the chart is focused. See Live & Streaming Data for monitor mode, the sliding window and how updates behave. There is nothing else to call: u.setData is the one way uPlot changes data, and the adapter follows it.
Highlighting and Click-to-Navigate
uPlot draws into a single <canvas>, so there is no element per mark for MAIDR to outline. Instead the adapter draws the highlight in a layer inside uPlot's plotting-area overlay (u.over):
- a box around the focused bar or stack segment (or the whole stack, on a stack's total), or around the focused line or area vertex or scatter point;
- uPlot's own cursor is moved to the same point, so the legend — and anything else that follows the cursor — shows the values being read. At a gap in a line only the cursor moves.
The highlight is a box in the reader's own highlight color from MAIDR's settings (read on every draw, so a change in the settings shows on the next move), with a translucent fill of the same color; highlightColor sets a color for the page instead. uPlot keeps no record of how wide it drew a bar — the width is set inside the paths function — so the adapter measures it off the canvas, following the bar's color across its middle. Where the canvas cannot be read back (one tainted by a cross-origin image, say), or the measurement does not look like a bar, the box falls back to 60% of the space between two x values, uPlot's default bar width. The highlight is redrawn whenever the chart redraws — a resize, a zoom, a new tick — and is taken down when focus leaves the chart, so uPlot's cursor is left to the mouse while the reader is elsewhere.
The overlay also tells a tactile graphics display where the plotting area is, so the chart can be felt by pin.
A left click on the plot moves MAIDR to the data point under uPlot's cursor, so a sighted colleague can point at a mark for a screen-reader user. Of the points at the cursor's position, one per series, the one drawn nearest the click is chosen. On a faceted chart the cursor's nearest point in each series is used.
Configuration Options
maidrPlugin(options) and bindUPlot(u, options) accept the same MaidrUPlotOptions:
| Option | Type | Default | Description |
|---|---|---|---|
id |
string |
maidr-uplot-<root id>, or a generated id |
MAIDR chart id, used for DOM ids. Leave unset on a plugin shared by several charts. |
title |
string |
uPlot's title |
Chart title for announcements |
subtitle |
string |
— | Chart subtitle |
caption |
string |
— | Chart caption |
xLabel |
string |
the x axis' label | X axis label (see Labels) |
yLabel |
string |
each y scale's axis label | Y axis label, used for every layer |
msPerUnit |
number |
inferred | Milliseconds per x unit on a time scale: 1000 for seconds, 1 for milliseconds |
series |
Record<number, { kind?, exclude? }> |
— | Per-series overrides keyed by u.series index (see Telling MAIDR what a series is) |
stacked |
boolean |
false |
Read series joined by bands as a stack, each as its own share, with each stack of bars as a stacked_bar layer (see Stacked Charts) |
areas |
boolean |
false |
Read line series drawn with a fill as area series, an experimental MAIDR type (see Supported Series) |
live |
boolean |
true |
Keep MAIDR in step with u.setData, streaming appended points (see Live Streaming) |
highlightColor |
string |
the reader's highlight color setting | Color of the highlight box |
enabled |
boolean |
true |
false makes maidrPlugin skip binding, for a plugin registered on many charts |
Limitations
- One subplot per chart. Every series of a chart is a layer of one subplot. Several uPlot charts on a page are separate MAIDR figures; synced charts (
cursor.sync) are not joined into one. - Bands are read only with
stacked: true. Without it each series is read as the values it holds — on a stacked chart, the running totals. With it, every band is taken as a stack, so a chart that fills a range with a band as well as stacking cannot be read both ways (see Stacked Charts). - Stacks are read upward from zero. A stack whose segments straddle zero — a diverging stacked bar — is read segment by segment, but its total's highlight runs from the axis to the series furthest from zero rather than around every segment.
- The total is reached by keyboard. A click on a stack selects the segment under it; the total MAIDR adds above the segments is reached with the arrow keys, not by clicking.
- Stacked lines are read as lines. Stacked line and area series are read as their own shares, but as
lineorarealayers, notstacked_area, and with no total. - Filled lines are lines unless
areas: true. Area is an experimental MAIDR type, so it is opt-in. - Bar width is measured from the canvas. Where the canvas cannot be read back, the bar highlight falls back to uPlot's default width and may not match a bar drawn wider or narrower.
- Stepped and spline lines are read as plain lines (see Supported Series).
- Grafana. This adapter works on uPlot charts you create on a page. A Grafana panel plugin that binds Grafana's own time-series panels is separate follow-up work to #1303.
Examples
Complete runnable pages, also in the examples gallery:
examples/uplot/line.html— two time series on one scaleexamples/uplot/bar.html— daily bars on a time axisexamples/uplot/stacked.html— stacked bars read withstacked: true; Up and Down walk the stack to each day's totalexamples/uplot/scatter.html— a points-only seriesexamples/uplot/live.html— a streaming sliding window; press M to hear each new reading
Keyboard Controls
Once a chart is focused, use standard MAIDR keyboard shortcuts:
| Function | Key (Windows) | Key (Mac) |
|---|---|---|
| Move between data points | Arrow keys | Arrow keys |
| Switch series (multi-line) | Up / Down Arrow | Up / Down Arrow |
| Move through a stack to its total | Up / Down Arrow | Up / Down Arrow |
| Switch layers (mixed chart) | Page Up / Page Down | Page Up / Page Down |
| Go to extremes | Ctrl + Arrow | Cmd + Arrow |
| Toggle Monitor Mode (live charts) | M | M |
| Toggle Sonification | S | S |
| Toggle Braille Mode | B | B |
| Toggle Text Mode | T | T |
| Toggle Review Mode | R | R |
| Auto-play | Ctrl + Shift + Arrow | Cmd + Shift + Arrow |
| Stop Auto-play | Ctrl | Cmd |
For the full list, see the Keyboard Controls reference.
API Documentation
For the complete TypeScript API reference, see the API Documentation.