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, rect and g elements exist. Independent of size; created by the data join.
  • Scalesd3.scaleLinear().domain([...]).range([0, width]). The domain depends on data; the range depends on size.
  • Geometry — attributes like x, y, width, d computed 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.

What Changes When a D3 Chart ResizesFour stacked layers. Data and element structure do not change on resize. Scale domains do not change on resize, but ranges do. Mark geometry such as x, y, width and path data is recomputed from the new ranges. Axis tick counts and label formats are adjusted to the new width.Data + elementsUnchanged: the join keeps existing nodes, listeners and hover state.ScalesDomain unchanged; range updated to the new width and height.Geometryx, y, width, d recomputed from the updated scales.AxesTick count and label format chosen for the new width.

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

TypeScript
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

TypeScript
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).

Axis Ticks by Chart WidthA line chart of the number of x-axis ticks against chart width with the rule of one tick per ninety pixels. At three hundred pixels there are about three ticks, at six hundred about six, and at twelve hundred about thirteen. A fixed tick count of twelve is also shown, which overlaps badly at narrow widths.03.5710.51430048066084010201200chart width in pxx-axis tick labels~1 tick per 90pxfixed 12 ticks

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:

TypeScript
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.

One Observer for a Dashboard of ChartsThree boxes. Twenty chart wrappers register with one shared ResizeObserver and a map of resize functions. A sidebar collapse changes all their widths, and a single callback per frame delivers all twenty entries. Each chart updates its ranges, axes and marks through its own resize function, keeping its elements and state.20 wrappersregistered in one Map1 callback per frameall changed charts at oncePer-chart resizeranges, axes, marks; state kept

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 viewBox for data charts. Text and strokes scale with the chart.
  • Redrawing from scratch on resize. State and listeners are lost; flicker appears.
  • Observing the svg you 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.


↑ Back to Responsive Canvas & Chart Resizing