Wrapping and Truncating SVG Axis Labels
Your category axis has labels like “North-West Regional Distribution Centre” and each one either runs off the edge of the SVG or collides with the plot area.
The root cause is that SVG <text> has no concept of a line box: unlike an HTML block, a <text> element renders on a single line forever, ignoring any width you wish it would respect. There is no width attribute that wraps it and no text-overflow that clips it. This guide is part of Text & Label Rendering in Charts, and where its companion on measuring text width to avoid label collisions decides which labels to keep, this one decides how to fit the labels you keep into the space you have: wrapping them across <tspan> lines, truncating them with an ellipsis and a hover tooltip, or rotating dense ticks to trade horizontal footprint for vertical.
Diagnostic checklist
Broken vs fixed
// ❌ BROKEN: a width attribute that SVG ignores; the label overflows the plot.
function addLabel(svg: SVGSVGElement, text: string, x: number, y: number): void {
const el = document.createElementNS("http://www.w3.org/2000/svg", "text");
el.setAttribute("x", String(x));
el.setAttribute("y", String(y));
el.setAttribute("width", "80"); // SVG <text> has no width — silently ignored
el.setAttribute("text-anchor", "middle");
el.textContent = text; // renders on ONE line forever, running off-edge
svg.appendChild(el);
}
// ✅ FIXED: greedily wrap into tspans by measured width, capped at maxLines.
const SVGNS = "http://www.w3.org/2000/svg";
function wrapLabel(
el: SVGTextElement,
text: string,
maxWidth: number,
maxLines = 2,
lineHeight = 1.1,
): void {
const words: string[] = text.split(/\s+/);
const lines: string[] = [];
let current = "";
el.textContent = ""; // clear before measuring probe lines
for (const word of words) {
const probe = current ? `${current} ${word}` : word;
el.textContent = probe;
// PERF: one getComputedTextLength per word forces layout; fine for a handful,
// batch or cache if you wrap thousands of labels in a frame.
if (el.getComputedTextLength() > maxWidth && current) {
lines.push(current);
current = word;
if (lines.length === maxLines - 1) break; // last line takes the remainder
} else {
current = probe;
}
}
lines.push(current);
el.textContent = "";
const x = el.getAttribute("x") ?? "0";
lines.forEach((line, i) => {
const span = document.createElementNS(SVGNS, "tspan");
span.setAttribute("x", x); // reset x each line
span.setAttribute("dy", i === 0 ? "0" : `${lineHeight}em`);
span.textContent = line;
el.appendChild(span);
});
// A11Y: the full label lives in the text node's accessible name automatically.
}
The critical detail is that wrapped lines must be <tspan> children of one <text>, each carrying its own x and a dy offset. A common mistake is to emit a fresh <text> per line, which breaks the accessible name into fragments and forces you to track baselines by hand. Keeping them inside one <text> means the whole label is a single node in the accessibility tree, reads as one string to a screen reader, and inherits the anchor’s text-anchor and font.
Step-by-step fix
// Truncate to fit one line, appending an ellipsis and a <title> tooltip.
function truncateWithTooltip(el: SVGTextElement, full: string, maxWidth: number): void {
el.textContent = full;
if (el.getComputedTextLength() <= maxWidth) return; // already fits
let lo = 0, hi = full.length;
while (lo < hi) { // binary search the longest prefix that fits
const mid = Math.ceil((lo + hi) / 2);
el.textContent = `${full.slice(0, mid)}…`;
if (el.getComputedTextLength() <= maxWidth) lo = mid;
else hi = mid - 1;
}
el.textContent = `${full.slice(0, lo)}…`;
const title = document.createElementNS(SVGNS, "title");
title.textContent = full; // A11Y: full string on hover and keyboard focus
el.appendChild(title);
}
Verification
// Assert every rendered tspan line fits inside the allotted width.
function assertLinesFit(el: SVGTextElement, maxWidth: number): void {
const spans = Array.from(el.querySelectorAll("tspan"));
for (const span of spans) {
const w = (span as SVGTSpanElement).getComputedTextLength();
console.assert(w <= maxWidth + 0.5, `line "${span.textContent}" width ${w} > ${maxWidth}`);
}
}
In DevTools, inspect a wrapped label and confirm it is one <text> element containing multiple <tspan> children rather than several <text> nodes, then hover a truncated tick and check the native tooltip shows the full string. Narrow the container and confirm labels wrap or truncate rather than overflowing the SVG viewport.
Why measurement, not character counts, drives every break
It is tempting to wrap by counting characters — break after twelve letters, truncate at fifteen — and it works until the font is proportional, which it almost always is. In a proportional face “Illinois” and “Wyoming” occupy very different widths despite similar letter counts, so a character rule wraps one too early and lets the other overflow. getComputedTextLength() returns the exact advance width the browser will render, accounting for kerning and the specific glyphs, so a break decision made from it is correct for every string. The cost is that each call forces a synchronous layout, which is why the pattern above measures probe strings on the single node you are about to commit rather than creating and measuring throwaway nodes — you reuse one node’s layout instead of paying for many.
The rotation strategy trades a different axis of space. Rotating labels 45° turns a wide horizontal footprint into a diagonal one whose horizontal extent is roughly width × cos(45°), so densely packed ticks that would overlap flat can clear each other when slanted. The catch is vertical: a rotated label reaches down into the margin by width × sin(45°), so you must reserve that height below the axis or the labels clip against whatever sits underneath. Rotation and wrapping are therefore not interchangeable — rotation buys horizontal room at the cost of vertical, wrapping buys narrower columns at the cost of a taller band, and truncation spends neither but loses information you must give back through the tooltip.
Edge cases & gotchas
- A single word longer than
maxWidth. Greedy word wrapping cannot break “Unternehmensberatungsgesellschaft” because it has no spaces. Fall back to per-character truncation with an ellipsis on that line, or break mid-word only where a soft hyphen is present. text-anchorand per-linex. Every<tspan>must repeat thexattribute, or lines after the first inherit the pen position from the previous line and stair-step to the right instead of stacking.- Measuring before the font loads.
getComputedTextLength()reports fallback-font widths until the web font swaps in, so wrap decisions taken beforedocument.fonts.readyshift once the real face arrives. Re-run wrapping on theloadingdoneevent. - Detached or
display:nonenodes. A<text>that is not in the rendered tree returns a length of zero fromgetComputedTextLength(). Measure only nodes that are attached and visible, or the wrap loop keeps every word on one line. - Ellipsis in right-to-left scripts. The
…character and the truncation side flip in RTL text; anchor the ellipsis on the logical end of the string, not a hard-coded right edge, so Arabic and Hebrew labels truncate on the correct side. - Tooltip is the only affordance. A
<title>shows on mouse hover and keyboard focus but not on touch. For touch surfaces, pair truncation with a tap-to-expand or an accessible data table so mobile users can still read the full label.
Frequently Asked Questions
How do I wrap SVG text across multiple lines?
SVG <text> never wraps on its own, so you split the string into words, measure each candidate line with getComputedTextLength(), and emit the resulting lines as <tspan> children of one <text> node. Give each <tspan> the same x as the parent and a dy of about 1.1em so the lines stack. Keeping them inside a single <text> means the whole label is one node in the accessibility tree.
How do I add an ellipsis to a truncated SVG label?
Measure the full string with getComputedTextLength(); if it exceeds the available width, binary-search the longest character prefix that still fits with a … appended, and set that as the text content. Then append a <title> child containing the untruncated string so the full label appears on hover and keyboard focus, which keeps the information reachable for assistive technology.
Should I rotate axis labels or wrap them?
Rotate when you have many uniform ticks that overlap horizontally and there is spare vertical margin below the axis, because rotation converts wide labels into a narrow diagonal footprint. Wrap when the labels are few but individually long and the band can afford two lines. Rotation buys horizontal space at the cost of vertical height, whereas wrapping narrows each label’s column at the cost of a taller label band.
Related
- Text & Label Rendering in Charts — the parent guide to chart typography.
- Measuring text width to avoid label collisions — deciding which labels to keep before you fit the survivors.
- Preventing axis label overlap on dense time series — the D3 axis-generator angle on crowded ticks.