Observe the chart's wrapper with ResizeObserver, and on each width change update only what depends on size — the scales' range, the SVG's dimensions, axis tick density and mark positions — through D3's update selection, rather than tearing the chart down and redrawing it from scratch.
Problem / Scenario Context
An analytics dashboard renders line and bar charts with D3. The original code sets a fixed viewBox and lets the SVG scale with its container. On narrow screens, axis labels shrink to unreadable 6 px text and 12 tick labels overlap; on wide screens, lines become thick and labels huge. The replacement redraws each chart completely on window.resize — removing the SVG and rebuilding it — which flickers, resets hover state and tooltips, restarts entry transitions, and still misses width changes caused by the dashboard's collapsible sidebar.
D3's data join already separates "what exists" from "where it goes". Responsiveness only needs the second part to be recomputed. The Responsive Canvas & Chart Resizing topic covers the general approach; observing SVG elements with ResizeObserver explains why the wrapper is the right target.
Mechanics Explanation
A D3 chart has three layers with different sensitivity to size:
- Data and structure — which
path,rectandgelements exist. Independent of size; created by the data join. - Scales —
d3.scaleLinear().domain([...]).range([0, width]). The domain depends on data; the range depends on size. - Geometry — attributes like
x,y,width,dcomputed from scales. Depend on size through the ranges. - Axis configuration — tick count and label format. Should depend on size: fewer ticks when narrow.
Resizing therefore means: update ranges, recompute geometry and axes. The elements themselves can stay, keeping their event listeners, hover state and focus. D3's selection.join() with an update function makes this cheap.
Observing the wrapper div rather than the svg matters because the callback writes the SVG's width/height; observing the element you resize invites a loop.
Comparison Table: Responsiveness Strategies for SVG Charts
| Strategy | Text size | Tick density | Hover / focus state | Cost per resize |
|---|---|---|---|---|
Fixed viewBox, scale the SVG |
scales with chart (unreadable when small) | fixed | kept | none |
| Full redraw on resize | constant | adjustable | lost | high; flicker |
| Update ranges + join | constant | adjustable | kept | low |
| Update ranges + join, quantised width | constant | adjustable | kept | lower |
Minimal Reproducible Example
addEventListener('resize', () => {
d3.select('#chart svg').remove(); // throws away nodes, listeners, state
drawChart(document.querySelector('#chart')!, data);
});
declare const d3: typeof import('d3');
declare function drawChart(el: Element, data: Point[]): void;
interface Point { t: Date; v: number }
Hover a point to show its tooltip, then collapse the sidebar: nothing happens. Resize the window: the chart flickers, the tooltip disappears and the line re-animates in.
Production-Safe Solution
import * as d3 from 'd3';
interface Point { t: Date; v: number }
const M = { top: 12, right: 16, bottom: 28, left: 44 };
export function responsiveLineChart(wrapper: HTMLElement, data: Point[]): () => void {
const svg = d3.select(wrapper).append('svg').attr('role', 'img').attr('aria-label', 'Requests per minute');
const g = svg.append('g').attr('transform', `translate(${M.left},${M.top})`);
const xAxisG = g.append('g').attr('class', 'x-axis');
const yAxisG = g.append('g').attr('class', 'y-axis');
const line = g.append('path').attr('fill', 'none').attr('stroke', 'currentColor').attr('stroke-width', 1.5);
// Size-independent parts: created once.
const x = d3.scaleUtc().domain(d3.extent(data, (d) => d.t) as [Date, Date]);
const y = d3.scaleLinear().domain([0, d3.max(data, (d) => d.v)!]).nice();
let lastW = -1;
function resize(width: number, height: number): void {
const w = Math.max(0, Math.round(width / 4) * 4); // quantise: skip sub-4px changes
if (w === lastW) return;
lastW = w;
const innerW = w - M.left - M.right;
const innerH = height - M.top - M.bottom;
svg.attr('width', w).attr('height', height);
x.range([0, innerW]);
y.range([innerH, 0]);
const ticks = Math.max(2, Math.floor(innerW / 90)); // ~one label per 90px
xAxisG.attr('transform', `translate(0,${innerH})`).call(d3.axisBottom(x).ticks(ticks));
yAxisG.call(d3.axisLeft(y).ticks(Math.max(2, Math.floor(innerH / 40))));
line.attr('d', d3.line<Point>().x((d) => x(d.t)).y((d) => y(d.v))(data));
}
const ro = new ResizeObserver(([e]) => {
const box = e.contentBoxSize[0];
resize(box.inlineSize, Math.round(box.inlineSize * 0.45)); // height from width, set on the svg
});
ro.observe(wrapper); // the wrapper, not the svg
return () => { ro.disconnect(); svg.remove(); };
}
The wrapper's height is not observed for sizing (the chart derives height from width), and its height comes from the SVG inside it — so the callback's write to the SVG height changes the wrapper's block size and delivers another entry, whose inline size is unchanged; the quantised lastW check returns early and the loop ends after one extra pass. If the chart should fill a fixed-height container instead, read blockSize from the entry and give the wrapper a definite height in CSS.
Because nodes persist, tooltips, hover highlights and keyboard focus survive resizes, and D3 transitions can be added to the update for smooth re-layout if desired (disable them under reduced motion).
Handling Many Charts on One Dashboard
A dashboard with twenty charts should not create twenty observers. One shared ResizeObserver with a Map from wrapper to resize function delivers every changed chart in a single callback — during a sidebar animation that is one callback per frame instead of twenty — and makes it easy to schedule expensive charts differently:
const charts = new Map<Element, (w: number) => void>();
const shared = new ResizeObserver((entries) => {
for (const e of entries) charts.get(e.target)?.(e.contentBoxSize[0].inlineSize);
});
export function registerChart(wrapper: Element, onWidth: (w: number) => void): () => void {
charts.set(wrapper, onWidth);
shared.observe(wrapper);
return () => { charts.delete(wrapper); shared.unobserve(wrapper); };
}
For charts with thousands of marks, resizing every frame during an animated sidebar collapse can still be heavy. Debounce only the expensive part (recomputing a dense scatter plot) while updating axes immediately, as described in debouncing chart redraws on container resize.
Verification Steps
- Collapse and expand the sidebar; charts should re-layout without flicker.
- Hover a point, then resize; the tooltip and highlight should remain.
- Narrow the window and confirm tick labels thin out rather than overlap.
- Check the console for ResizeObserver loop errors during resizes.
- Record a trace during a sidebar animation and confirm one observer callback per frame.
Common Mistakes to Avoid
- Scaling a fixed
viewBoxfor data charts. Text and strokes scale with the chart. - Redrawing from scratch on resize. State and listeners are lost; flicker appears.
- Observing the
svgyou resize. Observe the wrapper. - A fixed tick count. Labels overlap on narrow charts.
FAQ
Why not just use viewBox and preserveAspectRatio?
Scaling the whole drawing also scales text, strokes and tick spacing, which makes narrow charts unreadable and wide charts clumsy. It is fine for decorative graphics; data charts need layout recomputed in CSS pixels.
Does updating attributes through D3 keep event listeners?
Yes. The elements are the same nodes; only their attributes change. Listeners, focus and hover state stay attached.
How should height be determined?
Either derive it from the width to keep an aspect ratio, as above, or give the wrapper a definite height in CSS and read blockSize from the entry. Avoid a height that depends on the SVG content while the SVG height depends on the wrapper.
Should resize updates be animated?
Usually not during continuous resizing, where transitions would lag behind the container. A short transition after a discrete change, such as a sidebar toggle, can look good — and should be disabled under reduced motion.
Does this approach work for canvas-based D3 charts?
The scale and axis logic is identical. The drawing step is a full redraw of the canvas, sized from the device-pixel box, since canvas has no retained elements to update.
How do I test responsive behaviour?
In a browser test, set the wrapper's width to several values, wait two animation frames after each, and assert on the number of tick elements and the path's bounding box. JSDOM cannot run ResizeObserver or layout.
Related
- Fixing Chart Libraries That Ignore Container Resize — wrapping libraries that do not observe
- Observing SVG Elements with ResizeObserver — which SVG targets report
- Building a Shared ResizeObserver Service — one observer for the dashboard
↑ Back to Responsive Canvas & Chart Resizing