D3.js Integration
MAIDR ships a dedicated adapter for D3.js, the most widely used low-level SVG-based data visualization library on the web. The adapter turns any D3-rendered chart into an accessible, non-visual experience — audio sonification, text descriptions, braille output, and keyboard navigation — without forcing you to hand-write the MAIDR JSON schema.
Because D3 gives you full control over the DOM, MAIDR cannot auto-detect a chart the way it does for Plotly. Instead, you tell MAIDR which SVG elements represent your data points (via a CSS selector) and how to read the values out of D3's bound __data__ property. The binder handles the rest.
Installation
CDN (vanilla HTML)
<!-- 1. D3 itself -->
<script src="https://d3js.org/d3.v7.min.js"></script>
<!-- 2. MAIDR core runtime -->
<script src="https://cdn.jsdelivr.net/npm/maidr/dist/maidr.js"></script>
<!-- 3. MAIDR D3 adapter (exposes window.maidrD3) -->
<script src="https://cdn.jsdelivr.net/npm/maidr/dist/d3.js"></script>
npm (bundlers / React)
npm install maidr d3
The adapter ships two entry points:
| Import | When to use |
|---|---|
import { bindD3Bar, ... } from 'maidr/d3' |
Vanilla JS or non-React bundler setups. Pure binder functions. |
import { MaidrD3, useD3Adapter } from 'maidr/react' |
React apps. Component wrapper and lower-level hook. |
Quick Start
Vanilla JS
<svg id="my-chart"></svg>
<script>
// 1. Draw your D3 chart exactly as you would today.
const svg = d3.select('#my-chart').attr('width', 500).attr('height', 300);
const data = [
{ day: 'Mon', count: 20 },
{ day: 'Tue', count: 14 },
{ day: 'Wed', count: 23 },
{ day: 'Thu', count: 25 },
{ day: 'Fri', count: 22 },
];
const x = d3.scaleBand().domain(data.map(d => d.day)).range([40, 480]).padding(0.2);
const y = d3.scaleLinear().domain([0, 30]).range([280, 20]);
svg.selectAll('rect.bar')
.data(data)
.join('rect')
.attr('class', 'bar')
.attr('x', d => x(d.day))
.attr('y', d => y(d.count))
.attr('width', x.bandwidth())
.attr('height', d => 280 - y(d.count))
.attr('fill', '#4C78A8');
// 2. After D3 has finished drawing, bind MAIDR.
maidrD3.bindD3Bar(document.getElementById('my-chart'), {
selector: 'rect.bar',
title: 'Tips by Day',
axes: { x: 'Day', y: 'Count' },
x: 'day',
y: 'count',
});
</script>
The binder writes a maidr-data attribute onto the SVG and MAIDR's runtime activates on focus. That's it — click the chart or Tab to it and start pressing arrow keys.
Timing matters. Always call the binder after
selectAll(...).data(...).join(...)has run. Calling it on an empty SVG throwsNo elements found for selector ….
React
The <MaidrD3> component handles the D3-draws-first / MAIDR-binds-second dance for you:
import { useEffect, useRef } from 'react';
import * as d3 from 'd3';
import { MaidrD3 } from 'maidr/react';
function AccessibleBarChart({ data }) {
const svgRef = useRef<SVGSVGElement>(null);
useEffect(() => {
if (!svgRef.current) return;
const svg = d3.select(svgRef.current);
// ... your D3 drawing code using svg ...
}, [data]);
return (
<MaidrD3
svgRef={svgRef}
chartType="bar"
config={{
selector: 'rect.bar',
title: 'Tips by Day',
axes: { x: 'Day', y: 'Count' },
x: 'day',
y: 'count',
}}
deps={[data]}
>
<svg ref={svgRef} width={500} height={300} />
</MaidrD3>
);
}
Prefer composing with <Maidr> yourself? Use the hook:
import { Maidr, useD3Adapter } from 'maidr/react';
function AccessibleBarChart({ data }) {
const svgRef = useRef<SVGSVGElement>(null);
useEffect(() => { /* D3 drawing */ }, [data]);
const { maidrData } = useD3Adapter(svgRef, {
chartType: 'bar',
config: { selector: 'rect.bar', title: 'Tips by Day', x: 'day', y: 'count' },
}, [data]);
if (!maidrData) return <svg ref={svgRef} width={500} height={300} />;
return (
<Maidr data={maidrData}>
<svg ref={svgRef} width={500} height={300} />
</Maidr>
);
}
How It Works
D3 binds the source data for each DOM element to a hidden __data__ property during .data() joins. The adapter:
- Queries the SVG for your elements (e.g.
rect.bar,circle.dot,g.box) using theselectoryou provide. - Extracts
__data__from each element, resolving values via your accessors (x: 'day',y: (d) => d.count, etc.). - Stamps MAIDR-owned
data-maidr-*attributes onto the elements so the visual highlight layer can locate them later, even after React re-renders or D3 data joins reshuffle the DOM. - Builds the MAIDR JSON schema (axes, legends, selectors, data points) and either writes it as a
maidr-dataattribute (autoApply: true, default) or returns it for you to pass to<Maidr>.
The React bindings force autoApply: false internally so that <Maidr> stays the single source of truth.
Configuration Options
All binder functions accept a configuration object that extends a common base:
| Option | Type | Default | Description |
|---|---|---|---|
selector |
string |
required | CSS selector for the data-bearing elements. |
id |
string |
auto-generated | Stable id for the MAIDR chart. |
title |
string |
— | Chart title (announced in text descriptions). |
subtitle |
string |
— | Chart subtitle. |
caption |
string |
— | Chart caption. |
axes |
{ x?, y?, fill? } |
— | Axis labels and options. Each axis may be a plain string (label shorthand) or a full AxisConfig. |
format |
AxisFormat |
— | Global number/date format fallback for axes without their own format. |
autoApply |
boolean |
true |
When true, writes the generated schema to svg[maidr-data]. React forces this to false internally. |
Axis configuration
axes: {
x: 'Height (cm)', // shorthand
y: { label: 'Weight (kg)', min: 40, max: 100, tickStep: 5 }, // full config
fill: 'Species', // heatmap/segmented only
}
Grid-navigation shortcuts (scatter) and tick-aware announcements rely on the full form — provide min, max, and tickStep when you want them.
Data accessors
Every per-axis accessor is either a property key or a function:
// Property name — the default for most common cases
x: 'day'
// Function — for d3.stack() tuples or nested objects
y: (d) => d[1] - d[0]
When you omit an accessor, the binder tries a sensible default (x, y, fill, value, …) and falls back to a small list of aliases (category, label, name, count, amount, …).
Supported Chart Types
| Binder | Chart type | Selector targets | Example |
|---|---|---|---|
bindD3Bar |
Bar | <rect> per bar |
Bar chart |
bindD3Line |
Line / multi-line | <path> per series, optional <circle> points |
Line chart |
bindD3Scatter |
Scatter | <circle> per point |
Scatter plot |
bindD3Box |
Box plot | <g> per box (containing <rect> + <line> + <circle>) |
Box plot |
bindD3Histogram |
Histogram | <rect> per bin |
Histogram |
bindD3Heatmap |
Heatmap | <rect> per cell |
Heatmap |
bindD3Candlestick |
OHLC candlestick | <rect> per candle |
Candlestick |
bindD3Segmented |
Stacked / dodged / normalized bars | <rect> per segment |
Stacked, Dodged |
bindD3Smooth |
Smooth / regression curve | <circle> per sample |
Smooth curve |
Data Examples by Chart Type
Bar Chart
const data = [
{ day: 'Mon', count: 20 },
{ day: 'Tue', count: 14 },
{ day: 'Wed', count: 23 },
];
svg.selectAll('rect.bar')
.data(data).join('rect')
.attr('class', 'bar')
.attr('x', d => x(d.day))
.attr('y', d => y(d.count))
.attr('width', x.bandwidth())
.attr('height', d => height - y(d.count));
maidrD3.bindD3Bar(svgElement, {
selector: 'rect.bar',
title: 'Tips by Day',
axes: { x: 'Day', y: 'Count' },
x: 'day',
y: 'count',
});
Scatter Plot
Pair your scatter binder with explicit axis min/max/tickStep to enable grid-style navigation (Ctrl/Cmd + arrow jumps a tick at a time):
svg.selectAll('circle.dot')
.data(points).join('circle')
.attr('class', 'dot')
.attr('cx', d => x(d.height))
.attr('cy', d => y(d.weight))
.attr('r', 5);
maidrD3.bindD3Scatter(svgElement, {
selector: 'circle.dot',
title: 'Height vs Weight',
axes: {
x: { label: 'Height (cm)', min: 155, max: 195, tickStep: 5 },
y: { label: 'Weight (kg)', min: 50, max: 95, tickStep: 5 },
},
x: 'height',
y: 'weight',
});
Line Chart (single and multi-line)
The adapter supports two layouts:
- Path-only — each
<path>has an array of{x,y}points bound to it. Just give the binder the path selector. - Path + points — each series also has per-point
<circle>markers. ProvidepointSelectorso MAIDR can highlight individual points.
// Each line path gets its own `data-maidr-line-index` stamp so highlights
// keep working across React re-renders and D3 data joins.
maidrD3.bindD3Line(svgElement, {
selector: 'path.line',
pointSelector: 'circle.point',
title: 'Temperature by City',
axes: { x: 'Month', y: 'Temperature (°F)' },
x: 'month',
y: 'temp',
fill: 'city',
});
Multi-line tip. The binder infers legend labels from the
fill/zaccessor. If all series share the same parent<g>, the binder groups points by fill automatically; if each line has its own parent<g>, it scopes the point query per parent.
Box Plot
Each box must be a <g> group containing the IQR <rect>, the median and whisker <line> elements, and optional outlier <circle> elements as direct children:
groups.append('rect') // IQR body
.attr('y', d => y(d.q3))
.attr('height', d => y(d.q1) - y(d.q3));
groups.append('line') // median (horizontal in a vertical boxplot)
.attr('x1', 0).attr('x2', x.bandwidth())
.attr('y1', d => y(d.q2)).attr('y2', d => y(d.q2));
groups.append('line') // lower whisker (vertical)
.attr('x1', bw/2).attr('x2', bw/2)
.attr('y1', d => y(d.min)).attr('y2', d => y(d.q1));
groups.append('line') // upper whisker (vertical)
.attr('x1', bw/2).attr('x2', bw/2)
.attr('y1', d => y(d.q3)).attr('y2', d => y(d.max));
// One <circle> per outlier value, as a direct child of the group.
groups.each(function (d) {
const g = d3.select(this);
[...d.lowerOutliers, ...d.upperOutliers].forEach(v => {
g.append('circle').attr('cx', bw/2).attr('cy', y(v)).attr('r', 3);
});
});
maidrD3.bindD3Box(svgElement, {
selector: 'g.box',
title: 'Distribution by Group',
axes: { x: 'Group', y: 'Value' },
});
The binder classifies siblings by geometry:
<rect>becomes the IQR (iq).- Horizontal
<line>= median; vertical<line>above/below the rect centre = upper/lower whisker. Orientation flips for horizontal boxplots. <circle>above the rect centre = upper outlier; below = lower outlier.
Each sub-part is stamped with data-maidr-box-part="iq|q2|lower-whisker|upper-whisker|lower-outlier|upper-outlier" so visual highlighting works for every box region.
Histogram
svg.selectAll('rect.bar')
.data(bins).join('rect')
.attr('class', 'bar')
.attr('x', d => x(d.x0))
.attr('y', d => y(d.length))
.attr('width', d => Math.max(0, x(d.x1) - x(d.x0) - 1))
.attr('height', d => height - y(d.length));
maidrD3.bindD3Histogram(svgElement, {
selector: 'rect.bar',
title: 'Distribution of Sepal Width',
axes: { x: 'Sepal Width (cm)', y: 'Count' },
x: 'x0',
y: 'length',
xMin: 'x0',
xMax: 'x1',
});
Heatmap
svg.selectAll('rect.cell')
.data(cells).join('rect')
.attr('class', 'cell')
.attr('x', d => x(d.day))
.attr('y', d => y(d.hour))
.attr('width', x.bandwidth())
.attr('height', y.bandwidth())
.attr('fill', d => color(d.value));
maidrD3.bindD3Heatmap(svgElement, {
selector: 'rect.cell',
title: 'Activity Heatmap',
axes: { x: 'Day', y: 'Hour', fill: 'Activity' },
x: 'day',
y: 'hour',
value: 'value',
});
Candlestick
maidrD3.bindD3Candlestick(svgElement, {
selector: 'rect.candle',
title: 'Stock Price',
axes: { x: 'Date', y: 'Price ($)' },
value: 'date',
open: 'open',
high: 'high',
low: 'low',
close: 'close',
});
trend is auto-computed from open vs close when not supplied.
Stacked / Dodged / Normalized Bars
bindD3Segmented handles the three common multi-series bar variants. Pass type: 'stacked_bar' | 'dodged_bar' | 'normalized_bar':
// d3.stack() pattern
for (const s of stack(stackData)) {
svg.selectAll(`rect.bar.${s.key}`)
.data(s.map((d, i) => ({ x: quarters[i], y: d[1] - d[0], fill: s.key })))
.join('rect')
.attr('class', 'bar')
.attr('x', d => x(d.x))
.attr('y', d => y(d.y))
.attr('width', x.bandwidth())
.attr('height', d => height - y(d.y))
.attr('fill', d => colors[d.fill]);
}
maidrD3.bindD3Segmented(svgElement, {
selector: 'rect.bar',
type: 'stacked_bar',
title: 'Revenue by Region',
axes: { x: 'Quarter', y: 'Revenue ($K)', fill: 'Region' },
x: 'x',
y: 'y',
fill: 'fill',
});
DOM order. The binder auto-detects whether your
<rect>s are interleaved by category (subject-major, typical for dodged bars) or grouped by series (series-major, typical for stacked bars). Override withdomOrderif your drawing pattern is unusual.
Smooth / Regression Curve
maidrD3.bindD3Smooth(svgElement, {
selector: 'circle.smooth',
title: 'LOESS Fit',
axes: { x: 'Height (cm)', y: 'Weight (kg)' },
x: 'x',
y: 'y',
svgX: 'svg_x',
svgY: 'svg_y',
});
TypeScript Types
All public types are exported from the maidr/d3 entry (and re-exported from maidr/react for the React wrapper):
import type {
// Per-binder configs
D3BarConfig,
D3LineConfig,
D3ScatterConfig,
D3BoxConfig,
D3HistogramConfig,
D3HeatmapConfig,
D3CandlestickConfig,
D3SegmentedConfig,
D3SmoothConfig,
// Shared
D3BinderConfig,
D3BinderResult,
DataAccessor,
SegmentedTraceType,
// MAIDR schema re-exports
MaidrData,
MaidrLayer,
MaidrSubplot,
Orientation,
TraceType,
} from 'maidr/d3';
The React wrapper adds:
import type {
MaidrD3Props, // props for <MaidrD3>
D3AdapterSpec, // { chartType, config } discriminated union
D3ChartType, // 'bar' | 'line' | 'scatter' | …
UseD3AdapterResult,// { maidrData, error }
} from 'maidr/react';
React Integration
See the live React demo for runnable versions of the D3 bar and scatter examples below, or browse the source in examples/react-app/.
<MaidrD3> — convenience wrapper
The component renders its children bare until the first successful bind, then re-renders them wrapped in <Maidr>. Because the wrapping causes a remount, your D3 drawing effect must re-run on mount — the typical useEffect(() => {...}, [data]) pattern is sufficient.
<MaidrD3
svgRef={svgRef}
chartType="scatter"
config={{ selector: 'circle.dot', x: 'height', y: 'weight' }}
deps={[data]} // re-bind when `data` changes
>
<svg ref={svgRef} width={600} height={400} />
</MaidrD3>
useD3Adapter — lower-level hook
Use the hook when you need access to maidrData outside the <Maidr> wrapper — e.g. to log the extracted schema, merge multiple sources, or conditionally mount.
const { maidrData, error } = useD3Adapter(svgRef, spec, deps);
The hook:
- Reads
svgRef.currentinsideuseEffect, so it always runs after React has committed the SVG. - Re-runs the binder whenever
depschange. - Ignores object identity of
spec— you don't need to memoize it. - Catches binder errors and returns them via
errorso your UI can surface failures without crashing.
Keyboard Controls
Once a chart is focused, use the standard MAIDR shortcuts:
| Function | Key (Windows) | Key (Mac) |
|---|---|---|
| Move between data points | Arrow keys | Arrow keys |
| Go to extremes | Ctrl + Arrow | Cmd + Arrow |
| Jump by tick (scatter grid) | 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.
Integration Comparison
| Feature | D3 Adapter | Recharts Component | Plotly Adapter |
|---|---|---|---|
| Rendering library | D3.js (imperative SVG) | Recharts (declarative React) | Plotly.js (high-level) |
| Setup | Binder call after draw | Wrap chart in <Maidr> |
Just add <script> |
| Chart selectors | CSS selector you provide | Auto from component tree | Auto-generated |
| Data source | D3's __data__ |
Recharts props | Plotly internals |
| Chart types | 9 D3 patterns | Recharts set | 9 Plotly types |
| Dynamic updates | Via deps (React) or re-call (vanilla) |
React lifecycle | Auto-detected |
API Documentation
For the complete TypeScript API reference — every binder, every config field, every type — see the API Documentation.