Preventing Axis Label Overlap on Dense Time Series
Your time-series x-axis renders so many date labels that they overlap into an unreadable smear, especially on narrow containers.
This is a tick-density problem, and it sits under Scales & Axes Configuration, the companion guide that covers how D3 maps a domain to pixels and lays out ticks. The default axisBottom(scale) asks the scale for a suggested number of ticks based on range size, but it has no idea how wide each formatted label is — so on dense time data the labels collide. The fix is to take control of how many ticks render, which ones, and how they are drawn.
The root of the problem is that D3’s axis component and your label text live in two different coordinate systems that never talk to each other. The axis computes tick positions purely from the scale’s range in pixels — by default it aims for roughly one tick per 50–100 pixels — and it places a label at each. But the width of a rendered label depends on the format string, the font, and the locale, none of which the axis measures. So the axis happily emits ten ticks across 600 pixels (60 pixels apart) while each timestamp label needs 90 pixels to render, and the labels run into each other. Nothing is broken in D3’s sense; the component did exactly what it was told. The mismatch is that you let it pick a count without accounting for how much room a label actually occupies.
Once you see it that way, the three real levers fall out naturally. You can reduce the number of labels so the per-label slot is wider than a label needs (ticks(count) or tickValues); you can make each label narrower so more fit in the same slot (a shorter tickFormat); or you can change the label’s effective horizontal footprint by rotating it so its width is projected onto a smaller horizontal span. Most robust axes use the first two together and fall back to rotation only when the container is genuinely too narrow for even a reduced, shortened set.
Diagnostic checklist
Strategies and when each applies
Broken vs fixed
// ❌ BROKEN: default ticks + full timestamps overflow a narrow axis.
import { scaleTime } from 'd3-scale';
import { axisBottom } from 'd3-axis';
import { select } from 'd3-selection';
function drawAxis(host: SVGGElement, domain: [Date, Date], width: number): void {
const x = scaleTime().domain(domain).range([0, width]);
select(host).call(axisBottom(x)); // D3 picks ~width/100 ticks, ignores label length
// 30+ full date labels collide into an unreadable strip.
}
// ✅ FIXED: density derived from width, short format, rotation when tight.
import { scaleTime } from 'd3-scale';
import { axisBottom } from 'd3-axis';
import { timeFormat } from 'd3-time-format';
import { select } from 'd3-selection';
function drawAxis(host: SVGGElement, domain: [Date, Date], width: number): void {
const x = scaleTime().domain(domain).range([0, width]);
// PERF: target ~80px per label so tick count tracks container width on resize.
const targetCount = Math.max(2, Math.floor(width / 80));
const fmt = timeFormat('%H:%M'); // short format reclaims horizontal room
const g = select(host).call(
axisBottom(x).ticks(targetCount).tickFormat((d) => fmt(d as Date)),
);
// Rotate only when labels are still tighter than ~60px apart.
if (width / targetCount < 60) {
g.selectAll<SVGTextElement, unknown>('text')
.attr('text-anchor', 'end')
.attr('transform', 'rotate(-35)')
.attr('dx', '-0.4em')
.attr('dy', '0.4em');
}
// A11Y: axis ticks inherit the axis role; keep contrast >= 3:1 on tick text and lines.
}
The fix derives tick count from the pixel range so it adapts to the container, uses a compact timeFormat, and rotates only when even the reduced set is too tight.
A note on ticks(count) versus tickValues: they are not interchangeable. ticks(count) is a hint — D3 rounds your requested count to the nearest “nice” time interval (a whole minute, hour, or day) and may return slightly more or fewer ticks than you asked for, which is usually what you want because the resulting labels land on human-friendly boundaries. tickValues(array) is an override — D3 places a tick at exactly each value you supply and nowhere else, with no nicing. Use ticks(count) when you want responsive, evenly readable spacing and do not care about the exact moments; reach for tickValues only when specific instants must be marked, such as day boundaries on an intraday chart or the start of each deployment on a metrics timeline. Mixing them up — passing raw data timestamps to tickValues and wondering why the spacing looks ragged — is a common cause of an axis that is technically not overlapping but still reads as cluttered.
Step-by-step fix
Verification
Check that no two tick labels overlap by comparing their bounding boxes:
const texts = select(host).selectAll<SVGTextElement, unknown>('.tick text').nodes();
let overlaps = 0;
for (let i = 1; i < texts.length; i++) {
const prev = texts[i - 1].getBoundingClientRect();
const cur = texts[i].getBoundingClientRect();
if (cur.left < prev.right) overlaps++;
}
console.assert(overlaps === 0, `${overlaps} overlapping tick labels remain`);
Visually, shrink the container to your smallest breakpoint and confirm labels stay legible and the count drops rather than the text colliding.
Edge cases and gotchas
- Irregular timestamps. If samples are not evenly spaced,
.ticks(n)still places ticks on nice calendar boundaries, which may not sit on data points; for sensor-style irregular data, see customizing D3 time scales for irregular timestamps. - Locale and width. Localized month or day names can be much wider; measure with the actual locale formatter, not the English default.
- High-DPI rounding. On retina displays sub-pixel tick positions can make labels look misaligned; round positions or let the
viewBoxhandle scaling. - Multi-resolution time formats. A single short format reads poorly when the visible domain spans both minutes and days —
14:30is fine within an hour but ambiguous across a week. D3’sscale.tickFormat()returns a multi-scale formatter that shows the most significant unit that changed at each tick (the hour at hour boundaries, the day at midnight), which keeps labels short while preserving context. Prefer it over a fixedtimeFormatstring when the zoom range is wide or user-controllable. - First and last tick clipping. The leftmost and rightmost labels can extend past the chart’s edges and get clipped by the SVG viewBox. Either reserve a few extra pixels of horizontal margin for half a label’s width on each side, or set
text-anchortostarton the first tick andendon the last so they grow inward instead of off-canvas.
Related
- Scales & Axes Configuration — the parent guide on scales and axis layout.
- Customizing D3 Time Scales for Irregular Timestamps — when the time domain is not evenly spaced.
- Making D3 Charts Redraw on Container Resize — recompute tick density when the width changes.