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

Three strategies for fitting a long SVG label One long label shown three ways: wrapped into two tspan lines, truncated with an ellipsis plus a title tooltip, and rotated forty-five degrees under the axis. One label, three fits Wrap into tspans North-West Regional Distribution Centre two lines, dy = 1.1em Truncate + tooltip North-West Reg… full text in <title> Rotate 45° North-West Reg. measure with getComputedTextLength() before every break Never trust a width attribute on <text> — SVG ignores it
SVG text never wraps itself. You measure each candidate line, then either split it across tspans, cut it to an ellipsis with the full string preserved in a title, or rotate the whole label to reclaim horizontal space.
Axis labels: colliding vs thinned One dense axis drawn twice — every tick labelled, then thinned and rotated. Same axis, two label strategiesJan 01Jan 08Jan 15Jan 22Jan 29Feb 05Feb 12Feb 19Feb 26Mar 05Mar 12Mar 19Every tick drawn → glyphs collideJan 01Jan 15Jan 29Feb 12Feb 26Mar 12Keep every Nth, rotate 35° → readable
Why and truncating svg axis labels matters: label every tick and glyphs collide; keep every Nth and rotate, and the axis stays legible.

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-anchor and per-line x. Every <tspan> must repeat the x attribute, 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 before document.fonts.ready shift once the real face arrives. Re-run wrapping on the loadingdone event.
  • Detached or display:none nodes. A <text> that is not in the rendered tree returns a length of zero from getComputedTextLength(). 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.
SVG vs Canvas render-cost crossover Frame cost against element count for a DOM-node SVG chart and a single-element Canvas. element count (×1k)frame costSVG (DOM nodes)Canvas (1 element)
The render-cost crossover underlying and truncating svg axis labels: SVG cost climbs with node count while Canvas stays flat, which is why heavy scenes switch engines.

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.