Last updated

Vega-Lite Integration

MAIDR provides an adapter for Vega-Lite that converts your charts into accessible, navigable visualizations with audio sonification, text descriptions, and braille output.

Quick Start

Load Vega, Vega-Lite, vega-embed, MAIDR core, and the Vega-Lite adapter, then call maidrVegaLite.embed() with your spec — it runs vegaEmbed internally and attaches MAIDR once the chart has rendered:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>My Vega-Lite Chart</title>
    <!-- 1. Load Vega-Lite and its dependencies -->
    <script src="https://cdn.jsdelivr.net/npm/vega@5"></script>
    <script src="https://cdn.jsdelivr.net/npm/vega-lite@5"></script>
    <script src="https://cdn.jsdelivr.net/npm/vega-embed@6"></script>
    <!-- 2. Load MAIDR core and the Vega-Lite adapter -->
    <script src="https://cdn.jsdelivr.net/npm/maidr/dist/maidr.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/maidr/dist/vegalite.js"></script>
  </head>
  <body>
    <div id="chart" aria-label="Bar chart loading" style="min-height: 400px"></div>

    <script>
      const spec = {
        $schema: 'https://vega.github.io/schema/vega-lite/v5.json',
        width: 600,
        height: 400,
        title: 'Number of Tips by Day',
        data: {
          values: [
            { day: 'Sat', count: 87 },
            { day: 'Sun', count: 76 },
            { day: 'Thu', count: 62 },
            { day: 'Fri', count: 19 },
          ],
        },
        mark: 'bar',
        encoding: {
          x: { field: 'day', type: 'nominal', title: 'Day' },
          y: { field: 'count', type: 'quantitative', title: 'Count' },
        },
      };

      // 3. Render + bind MAIDR in a single call.
      maidrVegaLite.embed('#chart', spec, { id: 'tips-bar' });
    </script>
  </body>
</html>

embed() returns a Promise<{ view }> — await it (or use .then() / .catch()) when you need the underlying Vega view or want to handle render failures.

If you already drive vegaEmbed yourself (e.g. you need the view for additional logic before MAIDR mounts), use bindVegaLite instead. You must await result.view.runAsync() between embedding and binding so the SVG is in the DOM by the time MAIDR mounts:

// renderer must be 'svg' — MAIDR navigates the SVG output, not canvas.
const result = await vegaEmbed('#chart', spec, { renderer: 'svg', actions: false });

// vegaEmbed() resolves when the view is *constructed*, not when it has *rendered*.
// runAsync() resolves only after the first paint — guarantees the SVG exists.
await result.view.runAsync();

maidrVegaLite.bindVegaLite(result.view, spec, { id: 'tips-bar' });

Once the page loads, click on the chart (or Tab to it) and MAIDR activates with:

How It Works

The Vega-Lite adapter:

  1. Inspects the spec — reads the mark, encoding, and any composite blocks (layer, hconcat, vconcat, concat, facet, repeat).
  2. Resolves the data — queries the compiled Vega View for runtime datasets (so transforms such as bin and aggregate are honoured), and falls back to inline data.values when the view isn't available.
  3. Maps the mark to a MAIDR trace type — see the table below.
  4. Builds CSS selectors for visual highlighting against the SVG that Vega renders inside the embed container.
  5. Mounts the MAIDR UI on the rendered SVG.

Because Vega-Lite renders asynchronously through vegaEmbed(), the adapter must be called from inside the vegaEmbed(...).then(...) callback (or after await vegaEmbed(...)), once the SVG and the compiled view are both available.

Supported Chart Types

Stable chart types

Vega-Lite mark Encoding hint MAIDR trace type Example
bar (default) Bar vegalite-bindbar.html
bar bin: true on x or y Histogram vegalite-bindhistogram.html
bar color/fill field, default stack Stacked bar vegalite-bindstacked.html
bar color/fill, stack: null or false Dodged (grouped) bar vegalite-binddodged.html
bar color/fill, stack: 'normalize' Normalized stacked bar vegalite-bindnormalized.html
line, trail, area — Line vegalite-bindline.html
line, trail, area interpolate: 'step', 'step-before', 'step-after' Step vegalite-bindline.html
line, trail a regression or loess transform Smooth —
point, circle, square, tick — Scatter vegalite-bindscatter.html
rect — Heatmap vegalite-bindheatmap.html
boxplot — Box plot (vertical & horizontal) vegalite-bindbox.html
arc theta encoding Pie (mark.innerRadius makes it a doughnut) vegalite-pie.html
text text channel, both positional channels continuous Scatter, each point carrying its name —
text in a layer:, x/y fields matching a sibling layer's absorbed — the names go to the layer it labels —

Experimental chart types

These may change without a deprecation period; see Trace type stability.

Vega-Lite mark Encoding hint MAIDR trace type Example
bar stacked, with each series wholly one side of the baseline Diverging bar (pyramid, Likert) [experimental] vegalite-diverging.html
line, trail a window rank/dense_rank whose output column is on y Bump [experimental] vegalite-bump.html
line, trail a fold transform with detail splitting the polylines Parallel coordinates [experimental] vegalite-parallel.html
line, trail x named or titled recall and y precision, every point from 0 to 1, or declared with usermeta.maidr: { type: 'pr_curve' } Precision-recall curve [experimental] —
area a row facet over a density transform grouped by the facet field Ridgeline [experimental] vegalite-ridgeline.html
bar x + x2 (or y + y2) fields, other axis nominal/ordinal Gantt (ranged bar) [experimental] vegalite-gantt.html
bar the same, plus a window sum building a running total Waterfall (either orientation) [experimental] —
point, circle, square, tick one positional channel nominal/ordinal Dot plot (vertical & horizontal) [experimental] —
errorband + line layers the band's extent: 'iqr', the line the median of the same y over the same quantitative x Percentile band [experimental] —
line + named area / errorband layers the line declaring usermeta.maidr: { type: 'percentile_band', bands: [...] } Percentile band [experimental] —
errorbar, errorband — Error bar [experimental] vegalite-errorbar.html
arc radius bound to a field Polar area (coxcomb, rose) [experimental] —
geoshape a color or fill field, or a declared value Choropleth map [experimental] vegalite-choropleth.html
rule + point layers shared category and value channels Lollipop [experimental] —
rule + point layers, or line + point where the line has a detail naming the category two values per category, told apart by color Dumbbell [experimental] vegalite-dumbbell.html

Notes on these chart types

A trail is read as the line it is: Vega-Lite describes it as a line whose width can vary, and the two compile to the same one-path-per-series geometry over the same channels, so every line reading above applies to a trail unchanged. The varying width is the one thing lost — a line point carries a coordinate pair and no thickness — which is a second encoding of something a reader already reaches by navigating.

An arc mark is only a pie when it has a theta encoding: theta is the channel carrying the slice magnitudes, and an arc without one has no values to sonify, announce, or take percentages of. Such a spec is left unbound rather than announced as a pie whose numbers MAIDR would have to invent. An arc whose radius reads a data field is a polar area instead, where the wedge's length is the magnitude and its angle only says which category it is.

Two of these charts have no mark of their own in Vega-Lite and are authored as a pair of layers: a lollipop is a rule from the baseline plus its dots, and a dumbbell is a connector plus two dots per category (Vega-Lite's own "Ranged Dot Plot"). MAIDR collapses each pair into one trace, so the stem's magnitude and the gap between a pair are announced rather than dropped. The two ends of a dumbbell are named from the colour field — "1995" and "2000" rather than "start" and "end" — since those names are the whole content of the comparison. A line connector has to say what it joins, by naming the category in detail the way the Ranged Dot Plot does: a line under a dot layer without one is the ordinary line-with-markers idiom, and a two-series version of it would otherwise be read as a comparison of its two series.

Five more charts have no mark of their own either and are recognised from the transform that built them. A line is a bump chart only when the ranked column is the one actually plotted on y — the same window rank is also how a spec picks a top-n before drawing the underlying magnitude, which is an ordinary line. A folded line split by detail is parallel coordinates, and the fold's raw value column is handed to the trace rather than any pre-normalised one, so each axis keeps its own units. A row facet of density curves is a ridgeline, whose panel order is read from the compiled view rather than the facet domain, because Vega sorts that domain before laying the cells out. A diverging bar is decided per series — every series wholly one side of the baseline, and both sides used — which recognises a pyramid and a Likert scale while leaving an ordinary stacked bar that merely dips negative alone. And a line or trail over a regression or loess transform is a smooth, because those two transforms replace the rows with a fit: the points on the page are a model evaluated at a grid rather than observations, which is what a smooth means and what every other producer already calls it. A density transform also replaces the rows and is deliberately left alone, since the author picks line or area for it and both are ordinary readings of a curve.

A precision-recall curve has no mark of its own either — scikit-learn's and TensorBoard's are both redrawn as a line — so the axes are what say so, and both have to: x whose field or title is exactly recall and y whose field or title is exactly precision, case aside, unaggregated and unbinned, with every point a number from 0 to 1 on both. One curve per colour series, or per layer when each curve is its own line layer. A chart drawn the other way round, a title such as "Recall at k", or rates written as percentages stays an ordinary line. The chart does not say what share of the data was positive, so no chance baseline is declared, and the average precision is measured from the points.

An interquartile band drawn with its median is a third such pair: an errorband with extent: 'iqr' (or center: 'median' and no extent, which compiles to the same thing) layered with a line whose y is the median of the same field over the same quantitative x. Vega-Lite computes that band as the first and third quartiles and its centre as the median, so the pair is read as one percentile band at levels 0.25, 0.5 and 0.75, the band outlined as its area and the median as its line. Every part has to be stated: the quartiles are read from the compiled view's lower_, center_ and upper_ columns and the line's median has to equal the band's centre at every x, neither layer may split into series or add a transform of its own, and a band of any other extent — a mean with its standard error, a confidence interval — is still an error bar, since no quantile names it. Without a compiled view no quartile is computed, and the two layers are read apart.

A text mark is read two ways, and which one applies is decided by what else is in the spec rather than by an option. On its own it is a labelled scatter — the countries-against-GDP figure — and both positional channels have to be continuous for that: a text on a categorical axis is a value written over a bar or into a heatmap cell, and the mark beside it already announces that number, so reading it as well would announce every count twice. Inside a layer:, a text layer whose x and y read the same fields as a sibling layer is that layer's labels: it is absorbed rather than read, and its text channel is handed to the layer it labels, which keeps its own trace type. The match is on the fields, not on the order of the layers or where the glyphs land. Only a scatter ends up carrying the names, since a scatter point is the one thing in the grammar that holds one — labels written over a bar or a line are handed over and ignored, leaving those layers' payloads unchanged. A text mark with no text channel has nothing to say either way: standing alone it is left unread, and over another layer it is still absorbed but adds no names.

A geoshape mark is the one mark that names itself: it draws geography and nothing else compiles to it, so a map needs no annotation to be recognised. It is only a choropleth once a value shades it, which is why a color or fill field is required — a geoshape with neither is the outline layer such a map is usually drawn on top of, carries no data, and is left unread rather than announced as a chart with no values.

A ranged bar — gantt or waterfall — needs the axis opposite its two bounds typed in the spec as nominal or ordinal. A type Vega-Lite infers from the data is written down nowhere MAIDR can read it, and without one the bar reads as an ordinary bar whose magnitude is its lower bound alone, so MAIDR warns to the console when it sees a two-bound bar with an untyped category axis.

Highlighting is withheld, rather than pointed at the wrong element, in two cases: an errorband draws every sample into a single <path>, and a gantt whose lanes are interleaved in the data cannot be sliced back apart in DOM order. Both still sonify and announce normally.

Code Examples

Stable chart types

Bar chart

const spec = {
  $schema: 'https://vega.github.io/schema/vega-lite/v5.json',
  width: 600,
  height: 400,
  title: 'Number of Tips by Day',
  data: {
    values: [
      { day: 'Sat', count: 87 },
      { day: 'Sun', count: 76 },
      { day: 'Thu', count: 62 },
      { day: 'Fri', count: 19 },
    ],
  },
  mark: 'bar',
  encoding: {
    x: { field: 'day', type: 'nominal', title: 'Day' },
    y: { field: 'count', type: 'quantitative', title: 'Count' },
  },
};

maidrVegaLite.embed('#chart', spec, { id: 'tips-bar' });

See examples/vegalite-bindbar.html for a runnable version.

Stacked bar chart

const spec = {
  $schema: 'https://vega.github.io/schema/vega-lite/v5.json',
  width: 600,
  height: 400,
  title: 'Passengers by Class and Survival',
  data: {
    values: [
      { class: 'First',  survived: 'No',  count: 80 },
      { class: 'First',  survived: 'Yes', count: 136 },
      { class: 'Second', survived: 'No',  count: 97 },
      { class: 'Second', survived: 'Yes', count: 87 },
      { class: 'Third',  survived: 'No',  count: 372 },
      { class: 'Third',  survived: 'Yes', count: 119 },
    ],
  },
  mark: 'bar',
  encoding: {
    x: { field: 'class', type: 'nominal', title: 'Class' },
    y: { field: 'count', type: 'quantitative', title: 'Passengers' },
    color: { field: 'survived', type: 'nominal', title: 'Survived' },
  },
};

See examples/vegalite-bindstacked.html for a runnable version.

Dodged (grouped) bar chart

Set stack: null (or false) on the quantitative axis to switch from stacked to dodged.

const spec = {
  $schema: 'https://vega.github.io/schema/vega-lite/v5.json',
  width: 600,
  height: 400,
  title: 'City Populations (thousands)',
  data: {
    values: [
      { city: 'NYC', year: '2020', pop: 8336 },
      { city: 'NYC', year: '2025', pop: 8258 },
      { city: 'LA',  year: '2020', pop: 3979 },
      { city: 'LA',  year: '2025', pop: 3898 },
      { city: 'CHI', year: '2020', pop: 2693 },
      { city: 'CHI', year: '2025', pop: 2665 },
    ],
  },
  mark: 'bar',
  encoding: {
    x: { field: 'city', type: 'nominal', title: 'City' },
    y: { field: 'pop',  type: 'quantitative', stack: null, title: 'Population' },
    color: { field: 'year', type: 'nominal', title: 'Year' },
  },
};

See examples/vegalite-binddodged.html for a runnable version.

Normalized (100%) stacked bar chart

const spec = {
  $schema: 'https://vega.github.io/schema/vega-lite/v5.json',
  width: 600,
  height: 400,
  title: 'Survival Share by Class',
  data: {
    values: [
      { class: 'First',  survived: 'No',  count: 80 },
      { class: 'First',  survived: 'Yes', count: 136 },
      { class: 'Second', survived: 'No',  count: 97 },
      { class: 'Second', survived: 'Yes', count: 87 },
      { class: 'Third',  survived: 'No',  count: 372 },
      { class: 'Third',  survived: 'Yes', count: 119 },
    ],
  },
  mark: 'bar',
  encoding: {
    x: { field: 'class', type: 'nominal', title: 'Class' },
    y: { field: 'count', type: 'quantitative', stack: 'normalize', title: 'Share' },
    color: { field: 'survived', type: 'nominal', title: 'Survived' },
  },
};

See examples/vegalite-bindnormalized.html for a runnable version.

Histogram

const spec = {
  $schema: 'https://vega.github.io/schema/vega-lite/v5.json',
  width: 600,
  height: 400,
  title: 'Distribution of Values',
  data: {
    values: Array.from({ length: 100 }, () => ({
      value: Math.round(Math.random() * 100),
    })),
  },
  mark: 'bar',
  encoding: {
    x: { field: 'value', type: 'quantitative', bin: true, title: 'Value' },
    y: { aggregate: 'count', title: 'Frequency' },
  },
};

Put the bin on y and the count on x and the same distribution is drawn on its side; MAIDR reads it as a horizontal histogram, announcing the bin range against the axis the bins run along.

See examples/vegalite-bindhistogram.html for a runnable version.

Line chart

const spec = {
  $schema: 'https://vega.github.io/schema/vega-lite/v5.json',
  width: 600,
  height: 400,
  title: 'Monthly Average Temperature',
  data: {
    values: [
      { month: 'Jan', temp: 28 }, { month: 'Feb', temp: 32 },
      { month: 'Mar', temp: 45 }, { month: 'Apr', temp: 55 },
      { month: 'May', temp: 68 }, { month: 'Jun', temp: 78 },
      { month: 'Jul', temp: 82 }, { month: 'Aug', temp: 80 },
      { month: 'Sep', temp: 72 }, { month: 'Oct', temp: 58 },
      { month: 'Nov', temp: 42 }, { month: 'Dec', temp: 30 },
    ],
  },
  mark: 'line',
  encoding: {
    // `sort: null` keeps the rows' order; Vega-Lite sorts an ordinal axis.
    x: { field: 'month', type: 'ordinal',     title: 'Month', sort: null },
    y: { field: 'temp',  type: 'quantitative', title: 'Temperature (F)' },
  },
};

See examples/vegalite-bindline.html for a runnable version.

Scatter plot

const spec = {
  $schema: 'https://vega.github.io/schema/vega-lite/v5.json',
  width: 600,
  height: 400,
  title: 'Horsepower vs. Miles per Gallon',
  data: {
    values: [
      { hp: 130, mpg: 18 }, { hp: 165, mpg: 15 }, { hp: 150, mpg: 18 },
      { hp: 150, mpg: 16 }, { hp: 140, mpg: 17 }, { hp: 198, mpg: 15 },
      { hp: 220, mpg: 14 }, { hp: 215, mpg: 14 }, { hp: 225, mpg: 14 },
      { hp: 190, mpg: 15 }, { hp:  97, mpg: 24 }, { hp:  88, mpg: 22 },
      { hp:  70, mpg: 18 }, { hp:  76, mpg: 21 }, { hp:  86, mpg: 27 },
    ],
  },
  mark: 'point',
  encoding: {
    x: { field: 'hp',  type: 'quantitative', title: 'Horsepower' },
    y: { field: 'mpg', type: 'quantitative', title: 'Miles per Gallon' },
  },
};

See examples/vegalite-bindscatter.html for a runnable version.

Heatmap

const spec = {
  $schema: 'https://vega.github.io/schema/vega-lite/v5.json',
  width: 600,
  height: 400,
  title: 'Weekly Activity Heatmap',
  data: {
    values: [
      { day: 'Mon', hour: 'Morning',   value: 5 },
      { day: 'Mon', hour: 'Afternoon', value: 8 },
      { day: 'Mon', hour: 'Evening',   value: 3 },
      { day: 'Tue', hour: 'Morning',   value: 7 },
      { day: 'Tue', hour: 'Afternoon', value: 6 },
      { day: 'Tue', hour: 'Evening',   value: 4 },
      { day: 'Wed', hour: 'Morning',   value: 9 },
      { day: 'Wed', hour: 'Afternoon', value: 5 },
      { day: 'Wed', hour: 'Evening',   value: 7 },
    ],
  },
  mark: 'rect',
  encoding: {
    x: { field: 'day',   type: 'ordinal', title: 'Day' },
    // `sort: null` keeps the rows' order; Vega-Lite sorts an ordinal axis.
    y: { field: 'hour',  type: 'ordinal', title: 'Time of Day', sort: null },
    color: { field: 'value', type: 'quantitative', title: 'Activity Level' },
  },
};

See examples/vegalite-bindheatmap.html for a runnable version.

Box plot

Vega-Lite's boxplot is a compound mark — it computes the five-number summary (min, Q1, median, Q3, max) plus outliers internally from raw rows. MAIDR reads that summary back from the rendered SVG so you can navigate each box and its outliers with arrow keys.

const spec = {
  $schema: 'https://vega.github.io/schema/vega-lite/v5.json',
  width: 600,
  height: 400,
  title: 'Score Distribution by Group',
  data: {
    values: [
      // Group A: tight bulk 60–80 + 2 lower & 2 upper outliers
      ...Array.from({ length: 28 }, () => ({
        group: 'A',
        score: 60 + Math.round(Math.random() * 20),
      })),
      { group: 'A', score: 30 },
      { group: 'A', score: 35 },
      { group: 'A', score: 95 },
      { group: 'A', score: 98 },
      // Group B: tight bulk 65–75 + 1 lower & 2 upper outliers
      ...Array.from({ length: 28 }, () => ({
        group: 'B',
        score: 65 + Math.round(Math.random() * 10),
      })),
      { group: 'B', score: 40 },
      { group: 'B', score: 92 },
      { group: 'B', score: 96 },
      // Group C: bulk 50–70 + 2 lower & 1 upper outlier
      ...Array.from({ length: 28 }, () => ({
        group: 'C',
        score: 50 + Math.round(Math.random() * 20),
      })),
      { group: 'C', score: 20 },
      { group: 'C', score: 25 },
      { group: 'C', score: 100 },
    ],
  },
  mark: 'boxplot',
  encoding: {
    x: { field: 'group', type: 'nominal',      title: 'Group' },
    y: { field: 'score', type: 'quantitative', title: 'Score' },
  },
};

Both vertical (categorical x, quantitative y) and horizontal (categorical y, quantitative x) box plots are supported. See examples/vegalite-bindbox.html for a runnable version.

Pie chart

const spec = {
  $schema: 'https://vega.github.io/schema/vega-lite/v5.json',
  width: 400,
  height: 400,
  title: 'Units Sold by Fruit',
  data: {
    values: [
      { fruit: 'Apples', units: 30 },
      { fruit: 'Bananas', units: 50 },
      { fruit: 'Cherries', units: 20 },
      { fruit: 'Dates', units: 15 },
    ],
  },
  mark: 'arc',
  encoding: {
    theta: { field: 'units', type: 'quantitative', title: 'Units' },
    color: { field: 'fruit', type: 'nominal', title: 'Fruit' },
  },
};

maidrVegaLite.embed('#chart', spec, { id: 'fruit-pie' });

An arc has no x or y to name its axes after, so the slice labels come from the color (or fill) channel and the magnitudes from theta, and the layer's axis labels are taken from those two channels' titles. Left and Right move between slices; Up and Down are out of bounds, since a pie is a single row. Each slice announces its label, its value, and its share of the whole — "Apples, 30, 26.1%". Adding mark: { type: 'arc', innerRadius: 60 } makes it a doughnut, which reads identically.

See examples/vegalite-pie.html for a runnable version.

Experimental chart types

These may change without a deprecation period; see Trace type stability.

Choropleth map [experimental]

const states = [
  { id: 53, state: 'Washington', rate: 4.9, lon: -120.4, lat: 47.4 },
  { id: 41, state: 'Oregon', rate: 4.6, lon: -120.6, lat: 43.9 },
  { id: 6, state: 'California', rate: 5.3, lon: -119.7, lat: 37.2 },
  { id: 32, state: 'Nevada', rate: 5.8, lon: -116.6, lat: 39.3 },
];

const spec = {
  $schema: 'https://vega.github.io/schema/vega-lite/v5.json',
  width: 640,
  height: 400,
  title: 'Unemployment rate, western states',
  data: {
    url: 'https://cdn.jsdelivr.net/npm/vega-datasets@2/data/us-10m.json',
    format: { type: 'topojson', feature: 'states' },
  },
  transform: [
    {
      lookup: 'id',
      from: { data: { values: states }, key: 'id', fields: ['state', 'rate', 'lon', 'lat'] },
    },
    { filter: 'isValid(datum.rate)' },
  ],
  projection: { type: 'albersUsa' },
  mark: 'geoshape',
  encoding: {
    color: { field: 'rate', type: 'quantitative', title: 'Unemployment rate (%)' },
  },
  usermeta: {
    maidr: { type: 'choropleth', region: 'state' },
  },
};

maidrVegaLite.embed('#chart', spec, { id: 'states-choropleth' });

A map has no x or y channel, so MAIDR names its two axes after the two things it does carry: what a region is called, and what shades it. The region name is read from the lookup transform's join key — the one field present on every drawn feature and distinct between them, including a nested one such as properties.name. Failing that, from the first tooltip entry that is not the shaded value itself. A map that names its regions in neither place is left unread, because numbering them 0, 1, 2 would announce a position in the file as if it were a place.

Geometry the join left unmatched carries no value. Those regions are dropped rather than shaded zero — a region announced as 0 is a claim the map does not make, and on a rate it is the lowest value on the scale. Once regions are dropped they no longer line up one-to-one with the drawn shapes, so the layer loses its highlighting; a filter on the joined column, as above, keeps the two aligned.

Declaring what a map means

usermeta is Vega-Lite's own slot for third-party metadata, and usermeta.maidr is where a spec says what MAIDR cannot read off the drawing. On a choropleth it carries four optional fields, each named exactly like the value it fills:

field what it names default
region the column holding each region's name the lookup join key, then a tooltip field
value the column holding the value shading it the color / fill field
lon centroid longitude, degrees east a lon, longitude or long column
lat centroid latitude, degrees north a lat or latitude column

region is worth writing whenever the join key is a code rather than a name: the map above joins on the numeric FIPS id, so without it the reader is told "6" rather than "California".

lon and lat are what buy back everything spatial. With them the arrow keys move across the map — up is north, down is south, left is west, right is east — and the description names where the high values sit and which way the gradient runs. Without them the map is read as a region list in declared order, which is a poorer reading but the one the data supports. MAIDR does not invert the projection to synthesise the pair: degrees are the only accepted form, anything else is left out, and a coordinate that is not one is dropped rather than coerced — a wrong compass direction is worse than no compass at all.

Declaring a precision-recall curve

The axis-name reading above is narrow on purpose. A curve whose columns are named otherwise, whose rates carry no title, or whose figure has a known baseline says so with type: 'pr_curve' on the line (or trail, or stepped line) layer:

{
  mark: 'line',
  encoding: { x: { field: 'r', type: 'quantitative' }, y: { field: 'p', type: 'quantitative' } },
  usermeta: { maidr: { type: 'pr_curve', threshold: 'cut', prevalence: 0.3, ap: 0.82 } },
}
field what it carries default
threshold the column holding the decision threshold each point was scored at a threshold, thresholds or cutoff column
prevalence the share of positives the curve was scored on, a number from 0 to 1 — the height of the chance baseline none: never inferred, and a percentage is refused rather than rescaled
ap the average precision as the producer computed it, from 0 to 1 measured from the points
merge whether plain line layers layered after this one join it as further curves true

The points and the highlight are the line's own; only what is announced changes. prevalence and ap describe one curve, so they are used only on a layer that draws one: a layer split into several curves by color gives one block for curves that were rarely scored on equally common positives, so the two are reported and left out, and each curve that needs a baseline is drawn as its own layer with its own block. A line layered after the declared one, with matching axes, joins it as a further curve and borrows nothing from its block; set merge: false to keep it a line.

Declaring a fan chart

Any other fan chart — a forecast's 50% and 90% intervals around its median, a distribution over training steps — is a line and one range layer per band, an area (or an errorband given its own bounds) with y and y2 as its two edges. Nothing in a range says which quantiles its edges are, so the median line declares them, naming each band layer by its Vega-Lite name:

{
  data: { values: rows },
  layer: [
    { name: 'p5-95', mark: 'area', encoding: { x: { field: 'step', type: 'quantitative' }, y: { field: 'p5', type: 'quantitative' }, y2: { field: 'p95' } } },
    { name: 'p25-75', mark: 'area', encoding: { x: { field: 'step', type: 'quantitative' }, y: { field: 'p25', type: 'quantitative' }, y2: { field: 'p75' } } },
    {
      mark: 'line',
      encoding: { x: { field: 'step', type: 'quantitative' }, y: { field: 'p50', type: 'quantitative' } },
      usermeta: { maidr: { type: 'percentile_band', bands: [
        { series: 'p5-95', lower: 0.05, upper: 0.95 },
        { series: 'p25-75', lower: 0.25, upper: 0.75 },
      ] } },
    },
  ],
}

The levels are fractions, each lower below 0.5 and each upper above it, and the bands must nest; a list that fails is reported and every layer is read as what it draws. A band naming no sibling layer, one that draws no y-to-y2 range over the median's x, or one another fan already reads, is reported and left out. The edges are matched to the median by x, and the band layers become part of the fan rather than layers of their own. A named layer's marks are named after it — p5_95_marks for p5-95, through Vega-Lite's own varName — so the selectors name each band's area by that name and the median by its line, as measured on vega-lite 5.23.0 with vega 5.33.1, and the highlight is a marker at the x being read, on the edge of the band a quantile bounds or on the median's line. The declaration belongs on a line inside a layered spec; on a lone line it has no bands to name and is reported.

Where a declaration is read

A declaration outranks every heuristic, but only where the marks can back it: a maidr block declaring a choropleth on a spec whose mark is not a geoshape, or a precision-recall curve on one that draws no line, is reported to the console and the chart is read as what was actually drawn. The block belongs on the spec node that becomes one layer — a single-view spec, a layer[i] child, a concat or facet leaf. One written on a composite parent — a layer, concat, facet or repeat node — names none of the layers below it and is ignored. title and name are accepted on any layer and override what it is announced as.

Field names given explicitly are used verbatim, with no fallback: a name that no row carries is a typo worth reporting, and MAIDR says so to the console rather than quietly substituting a column you did not ask for.

See examples/vegalite-choropleth.html for a runnable version.

Multi-panel charts (facet, repeat, concat)

Vega-Lite's view-composition operators all convert to a multi-subplot MAIDR figure — one accessible panel per chart cell. Multi-panel figures start in subplot navigation: arrow keys move between panels (each announces its facet value or repeated field), Enter drills into a panel's data points, Escape returns to panel navigation, and PageUp/PageDown switch layers inside a panel.

Supported compositions:

Composition Spec shape Panel grid
Row/column facet facet: { row?, column? } + spec facet values → grid rows × columns (missing row×column combinations are skipped, matching the rendered chart)
Facet shorthand row / column channels inside encoding same as the facet operator
Wrapped facet facet: { field } + columns: N values wrap into rows of N panels
Repeat repeat: { row?, column? } or repeat: [...] + columns one panel per repeated field (the child's { repeat: ... } field references are substituted per panel)
Concatenation hconcat / vconcat / concat + columns one panel per child spec; concat wraps into rows of columns panels
const spec = {
  $schema: 'https://vega.github.io/schema/vega-lite/v5.json',
  title: 'Barley Yield by Site',
  data: { values: barleyYields },
  facet: { column: { field: 'site', type: 'nominal' } },
  spec: {
    mark: 'bar',
    encoding: {
      x: { field: 'variety', type: 'nominal' },
      y: { field: 'yield', type: 'quantitative' },
    },
  },
};

maidrVegaLite.embed('#chart', spec, { id: 'barley-facet' });

Each panel announces its facet value (e.g. "site: Crookston") or repeated field name as its title, and highlighting is scoped per panel — the adapter stamps each rendered facet cell with a data-maidr-cell attribute at bind time so selectors only ever match their own panel's marks. All selectors are additionally prefixed with the chart container's id (one is generated if the container has none), so several charts on one page — even two copies of the same spec — never highlight each other's elements.

Notes and current limitations:

See examples/vegalite-facet-bar.html (facet operator, wrapped facet, shorthand, and repeat) and examples/vegalite-hconcat-box.html (concatenation) for runnable versions.

API Reference

embed(target, spec, options?)

Renders the Vega-Lite spec and mounts MAIDR in a single call. This is the recommended entry point for most integrations — it wraps vegaEmbed() and bindVegaLite() together and handles the asynchronous render internally.

Argument Type Description
target string | HTMLElement A CSS selector or HTMLElement that hosts the chart.
spec VegaLiteSpec The Vega-Lite specification.
options.id string (optional) Unique ID for the MAIDR instance.
options.title string (optional) Override for the chart title used in announcements.
options.embedOptions EmbedOptions (optional) Forwarded to vegaEmbed. Default: { actions: false }.

Returns a Promise<{ view: vega.View }> so callers can await the underlying Vega view or chain .catch() to handle render failures.

maidrVegaLite.embed('#chart', spec, { id: 'tips-bar' })
  .then(({ view }) => console.log('Chart ready', view))
  .catch((err) => console.error(err));

Requires the global vegaEmbed function (from the vega-embed script) to be loaded on the page. If vegaEmbed is not available, embed() throws a clear error.

bindVegaLite(view, spec, options?)

Lower-level entry point that mounts MAIDR on the SVG produced by an existing vegaEmbed() call. Use this when you need full control over the embed lifecycle (custom vegaEmbed options, post-processing, etc.).

Argument Type Description
view vega.View The compiled Vega view returned by vegaEmbed(...).view.
spec VegaLiteSpec The original Vega-Lite specification.
options.id string (optional) Unique ID for the MAIDR instance. Defaults to the view container's id, or a timestamp fallback.
options.title string (optional) Override for the chart title used in announcements.

Callers must await view.runAsync() before calling bindVegaLite(). vegaEmbed(...) resolves when the view is constructed, not when it has rendered — calling bindVegaLite() immediately after vegaEmbed() resolves can race the first paint and produce a "No SVG found" error on slow or aggregated specs (histograms, complex transforms). The view must also be created with renderer: 'svg'; MAIDR cannot navigate canvas output. Prefer embed() which performs both steps for you.

vegaLiteToMaidr(spec, view?, options?)

Lower-level converter that returns a MAIDR schema object without mounting the UI. Useful when you want to set the maidr attribute manually or post-process the schema.

import { vegaLiteToMaidr } from 'maidr/vegalite';

const maidr = vegaLiteToMaidr(spec, view, { id: 'my-chart' });
container.setAttribute('maidr', JSON.stringify(maidr));

Using with npm / bundlers

import { embed } from 'maidr/vegalite';

await embed('#chart', spec, { id: 'my-chart' });

Or, for fine-grained control:

import { bindVegaLite } from 'maidr/vegalite';
import vegaEmbed from 'vega-embed';

const result = await vegaEmbed('#chart', spec, { renderer: 'svg' });
await result.view.runAsync(); // wait for the first paint
bindVegaLite(result.view, spec, { id: 'my-chart' });

The package exposes both ES (vegalite.mjs) and UMD (vegalite.js) builds plus TypeScript declarations.

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
Go to extremes Ctrl + Arrow Cmd + Arrow
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.