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 throws No 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:

  1. Queries the SVG for your elements (e.g. rect.bar, circle.dot, g.box) using the selector you provide.
  2. Extracts __data__ from each element, resolving values via your accessors (x: 'day', y: (d) => d.count, etc.).
  3. 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.
  4. Builds the MAIDR JSON schema (axes, legends, selectors, data points) and either writes it as a maidr-data attribute (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:

// 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/z accessor. 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:

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 with domOrder if 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:

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.