Text & Label Rendering in Charts
Everything else in a chart is a rectangle, a line, or a coloured dot — but the moment you add axis ticks, value labels, and a legend, rendering stops being geometry and becomes typography, and typography is where most chart engines quietly fall apart.
Concept overview
Text is the hardest surface in visualization rendering because it fails in four independent dimensions at once: metrics (you cannot lay out a label until you know how wide it is, and width depends on font, weight, and the exact glyph string), crispness (glyph edges are sub-pixel features that blur the instant your backing store is wrong), collision (labels occupy real estate that overlaps neighbouring labels and marks), and internationalization (a string that fits in English overflows in German and reverses in Arabic). This guide sits under the Core Rendering Engines & Tradeoffs overview, and it is the counterpart to the SVG vs Canvas architecture decision — because the single biggest lever on all four problems is which engine draws the glyphs.
The two engines take opposite approaches. In SVG a label is a retained <text> node the browser lays out, measures, hit-tests, and rasterizes for you; you get accessibility, selection, and crispness for free, but you pay a DOM node per label and you cannot ask “how wide is this string?” without touching the layout tree. In Canvas a label is an immediate-mode fillText call that vanishes into a bitmap the instant it is drawn; you own measurement through measureText, you own crispness through devicePixelRatio, and you own collision detection because the engine has forgotten the label exists the moment the call returns. Neither is strictly better — the whole discipline of chart text is knowing which failure mode you are trading away.
When to use what
| Approach | Best for | Measurement | Crispness | Accessibility | Collision handling |
|---|---|---|---|---|---|
Canvas fillText |
Dense charts, 1k+ labels, animation | ctx.measureText() |
You apply DPR | Manual (offscreen table) | You compute it |
SVG <text> |
Vector charts, <500 labels, print | getComputedTextLength() |
Free (vector) | Free (in DOM) | Layout + your logic |
| HTML overlay labels | Rich text, wrapping, links, tooltips | getBoundingClientRect() |
Free (native text) | Free (real DOM) | CSS + your logic |
The decision usually collapses to label count and interactivity. Below a few hundred labels that need selection, links, or screen-reader exposure, SVG <text> or HTML overlays win because you inherit the browser’s layout and accessibility for nothing. Above that — thousands of tick labels on a streaming time series, or text that animates every frame — Canvas is the only engine that keeps you inside the frame budget, and you accept the measurement and accessibility work as the price. HTML overlays are the specialist choice: reach for absolutely-positioned <div> labels when you need wrapping, ellipsis, links, or emoji in labels that a handful of annotations demand and <text> makes awkward.
Core concepts
The unifying primitive is measurement. You cannot place a label, detect that two labels collide, truncate a string to fit, or right-align a value column without first knowing the rendered width of a glyph run. Both engines expose this, and both answers are in the same logical pixel space, so the placement logic on top is engine-agnostic.
// A measurement abstraction that both engines satisfy.
interface TextMetricsLite {
width: number; // advance width in CSS pixels
ascent: number; // above the baseline
descent: number; // below the baseline
}
// Canvas: measureText is synchronous and cheap once the font is set.
function measureCanvas(ctx: CanvasRenderingContext2D, text: string): TextMetricsLite {
const m: TextMetrics = ctx.measureText(text);
// PERF: cache by (font + text); measureText is O(glyphs) and adds up over 10k labels.
return {
width: m.width,
ascent: m.actualBoundingBoxAscent,
descent: m.actualBoundingBoxDescent,
};
}
// SVG: measure a live <text> node; forces layout, so batch these reads.
function measureSvg(node: SVGTextElement): TextMetricsLite {
const len: number = node.getComputedTextLength(); // advance width
const box: DOMRect = node.getBBox(); // tight geometric bounds
return { width: len, ascent: -box.y, descent: box.y + box.height };
}
Two subtleties bite here. First, ctx.measureText returns width in the context’s current font, so you must set ctx.font (and textBaseline) before measuring or you get metrics for the wrong typeface. Second, getComputedTextLength forces a synchronous layout, so reading it in a loop interleaved with writes is a textbook cause of layout thrashing — measure all labels first, then write all positions. That read-then-write batching is covered in depth under DOM Impact & Reflow Optimization.
The second core concept is crispness. SVG glyphs are vector data the browser rasterizes at device resolution every paint, so they are crisp for free at any zoom. Canvas glyphs are baked into a bitmap at draw time, so they blur the instant the backing store does not match the device pixel ratio — the same failure mode that softens lines, covered in Fixing blurry Canvas on high-DPI displays and deepened for zoom in Keeping Canvas text crisp at any zoom.
The third is placement. Once you can measure, you can decide which labels fit, thin the ones that do not, truncate overflow, and wrap long strings — the subject of the three companion guides below.
Implementation pattern
interface Label {
text: string;
x: number; // anchor in CSS pixels
y: number;
width: number; // filled in during the read phase
}
function layoutAxisLabels(
ctx: CanvasRenderingContext2D,
labels: Label[],
minGap: number,
): Label[] {
ctx.font = "12px Inter, system-ui, sans-serif";
ctx.textBaseline = "middle";
// READ phase: measure everything first (no drawing interleaved).
for (const label of labels) {
label.width = ctx.measureText(label.text).width;
}
// DECIDE phase: greedy left-to-right thinning so no two labels overlap.
const kept: Label[] = [];
let lastRight = Number.NEGATIVE_INFINITY;
for (const label of labels) {
const left = label.x - label.width / 2;
if (left >= lastRight + minGap) {
kept.push(label);
lastRight = label.x + label.width / 2;
}
}
// WRITE phase: draw only survivors, snapping baselines to whole pixels.
for (const label of kept) {
ctx.fillText(label.text, Math.round(label.x - label.width / 2), Math.round(label.y));
}
return kept; // A11Y: feed the kept + dropped sets to an accessible table.
}
Performance & memory
Measurement dominates the cost of chart text. ctx.measureText is O(glyphs) per call and is fast individually, but a dense time-series axis re-measuring thousands of candidate tick labels every frame will burn milliseconds you do not have. Cache measured widths keyed by the font-plus-string pair; the same tick labels recur across frames, so a Map<string, number> cache turns per-frame measurement into a one-time cost. SVG getComputedTextLength and getBBox are worse per call because each forces layout, so their cost is not just glyph count but a full reflow — never read them inside a write loop.
Memory splits cleanly by engine. Canvas text has zero retained cost: glyphs are pixels in a bitmap you already allocated, so ten labels and ten thousand cost the same in nodes (though not in draw time). SVG text costs one element, its text node, and its layout box per label, so a chart with 5,000 tick labels carries 5,000 nodes the browser must style, lay out, and hit-test on every reflow — the node explosion the demo above makes visceral. The frame-budget rule of thumb: if labels change every frame, they belong on Canvas; if they are static chrome around an interactive surface, SVG or HTML labels amortize their node cost across a long-lived layout.
Accessibility checklist
Troubleshooting
Labels overlap on a dense axis. You are placing every tick without measuring. Measure widths, then greedily thin labels so survivors never overlap — see Measuring text width to avoid label collisions.
Canvas labels look fuzzy after the user zooms. You scaled a cached bitmap instead of re-rasterizing text at the new scale. Re-run fillText at the target resolution — see Keeping Canvas text crisp at any zoom.
Long category labels get clipped or force horizontal scroll. You are drawing single-line SVG <text> with no wrap or ellipsis. Split into <tspan> lines or truncate with a <title> — see Wrapping and truncating SVG axis labels.
Measurement is slow on every frame. You re-measure identical strings each render. Cache widths keyed by font and text so recurring labels cost nothing after the first frame.
Right-aligned value columns look ragged. You are anchoring on the label’s left edge instead of its measured width. Compute x = columnRight - measuredWidth so the glyph advances line up.
Frequently Asked Questions
Why is text the hardest part of chart rendering?
Because it fails in four independent ways at once. You cannot position a label until you measure its width, which depends on the exact font and glyphs; its edges blur unless the backing store matches device pixels; it collides with neighbouring labels that you must detect and resolve; and a string that fits in one language overflows or reverses in another. Every other chart primitive is simple geometry by comparison.
Should I render chart labels with Canvas or SVG?
Use SVG <text> when you have a few hundred labels that benefit from being selectable, linkable, and accessible for free. Switch to Canvas fillText when you have thousands of labels or text that animates every frame, because SVG’s one-node-per-label cost breaks the frame budget. The crossover is roughly where DOM node count starts to dominate reflow time.
How do I measure text width before drawing it?
On Canvas, set ctx.font then call ctx.measureText(text).width, which is synchronous and returns the advance width in CSS pixels. On SVG, read getComputedTextLength() on a live <text> node, but be aware it forces a layout, so batch all such reads before writing positions. Both answers are in the same logical pixel space, so your placement logic stays engine-agnostic.
Related
- Core Rendering Engines & Tradeoffs — the parent overview of rendering pipelines.
- SVG vs Canvas Architecture — the engine decision that drives every text tradeoff here.
- Measuring text width to avoid label collisions — detect and thin overlapping labels.
- Keeping Canvas text crisp at any zoom — re-rasterize instead of scaling bitmaps.
- Wrapping and truncating SVG axis labels — tspan wrapping and ellipsis with tooltips.