apps/docs/content/sdk-features/text-measurement.mdx
The editor measures text to calculate shape bounds, handle text wrapping, and position labels. Two managers handle this: TextManager measures text dimensions using a hidden DOM element, and FontManager loads custom fonts before measurement so dimensions are accurate. Access them through editor.textMeasure and editor.fonts.
The TextManager creates a hidden measurement element on initialization and appends it to the editor container. This element stays in the DOM throughout the editor's lifecycle, so repeated measurements don't pay the cost of creating and removing elements.
// Simplified for clarity - see TextManager.ts for full implementation
const elm = this.editor.getContainerDocument().createElement('div')
elm.classList.add('tl-text', 'tl-text-measure')
elm.setAttribute('dir', 'auto')
this.editor.getContainer().appendChild(elm)
The element is absolutely positioned and hidden from users, but the browser still lays it out, so measurements are accurate.
The measureText method calculates text dimensions. Pass in text content and TLMeasureTextOpts, and it returns a box model with width and height.
const dimensions = editor.textMeasure.measureText('Hello world', {
fontFamily: 'Inter',
fontSize: 16,
fontWeight: 'normal',
fontStyle: 'normal',
lineHeight: 1.35,
maxWidth: null, // No wrapping
padding: '4px',
})
// Returns: { x: 0, y: 0, w: 85, h: 22, scrollWidth: 0 }
The method applies styles to the measurement element, reads the computed dimensions, then restores the previous styles. This means rapid successive measurements don't interfere with each other.
You can also use measureHtml to measure HTML content directly instead of plain text, or measureHtmlBatch to measure many pieces of HTML in one layout pass using a pool of elements.
Set maxWidth to a number and the browser wraps text to fit within that width. The TextManager uses the browser's native text layout algorithm rather than implementing its own wrapping logic.
const wrapped = editor.textMeasure.measureText('This is a long line of text', {
fontFamily: 'Inter',
fontSize: 16,
fontWeight: 'normal',
fontStyle: 'normal',
lineHeight: 1.35,
maxWidth: 100, // Wrap at 100px
padding: '4px',
})
// Returns dimensions accounting for multiple lines
Set maxWidth: null to preserve explicit line breaks and spaces without wrapping. This is useful for measuring single-line text or when wrapping is handled elsewhere.
For SVG export or precise text selection, measureTextSpans breaks text into individual spans based on line breaks and word boundaries. It takes TLMeasureTextSpanOpts:
const spans = editor.textMeasure.measureTextSpans('Hello world\nSecond line', {
width: 200,
height: 100,
padding: 8,
fontSize: 16,
fontWeight: 'normal',
fontFamily: 'Inter',
fontStyle: 'normal',
lineHeight: 1.35,
textAlign: 'start',
overflow: 'wrap',
})
Each span includes the text content and its bounding box. Runs of whitespace become their own spans (widths are illustrative):
;[
{ text: 'Hello', box: { x: 0, y: 0, w: 40, h: 22 } },
{ text: ' ', box: { x: 40, y: 0, w: 5, h: 22 } },
{ text: 'world', box: { x: 45, y: 0, w: 40, h: 22 } },
{ text: 'Second', box: { x: 0, y: 22, w: 47, h: 22 } },
{ text: ' ', box: { x: 47, y: 22, w: 5, h: 22 } },
{ text: 'line', box: { x: 52, y: 22, w: 32, h: 22 } },
]
The algorithm positions a Range around each grapheme, measures it with getClientRects(), then groups graphemes into spans wherever the line position changes or the text switches between whitespace and non-whitespace.
The required overflow option controls how text exceeding the available space is handled:
| Value | Behavior |
|---|---|
wrap | Text wraps to multiple lines |
truncate-clip | Text truncates to the first line, no visual indicator |
truncate-ellipsis | Text truncates with an ellipsis character |
When using truncate-ellipsis, the algorithm first measures the ellipsis width, then subtracts it from the available width and remeasures to find the cut point.
The FontManager loads custom fonts before text measurement. If you measure text before its font loads, you get incorrect dimensions and layout shifts when the font finally becomes available.
Shapes declare which fonts they need by overriding ShapeUtil#getFontFaces and returning TLFontFace objects:
class MyTextShapeUtil extends ShapeUtil<MyTextShape> {
override getFontFaces(shape: MyTextShape): TLFontFace[] {
return [
{
family: 'MyCustomFont',
src: { url: '/fonts/my-custom-font.woff2', format: 'woff2' },
weight: 'normal',
style: 'normal',
},
]
}
}
The FontManager tracks these font requirements and loads them before the shape renders.
Use ensureFontIsLoaded to load a specific font, or requestFonts to batch multiple font loading requests:
// Load a single font
await editor.fonts.ensureFontIsLoaded(fontFace)
// Batch load multiple fonts (batched into a single microtask)
editor.fonts.requestFonts([fontFace1, fontFace2])
The manager caches font loading state to avoid redundant loading. Multiple concurrent requests for the same font share a single loading promise.
To make a computation re-run once a shape's fonts load, call trackFontsForShape inside it. Editor#getShapeGeometry already does this for you, so you only need it in your own caches, like a text size cache:
editor.fonts.trackFontsForShape(shape)
Use loadRequiredFontsForCurrentPage to load all fonts needed by shapes on the current page. The editor calls it before the first render and before exports:
await editor.fonts.loadRequiredFontsForCurrentPage()
// All fonts for shapes on the current page are now loaded
Pass a limit argument to skip the wait entirely when the page needs more than that many fonts. The editor uses the maxFontsToLoadBeforeRender option (see TldrawOptions, default Infinity) for this, so a page with many fonts doesn't block the canvas.
The TextManager doesn't cache measurements itself. Shape utilities cache their own results using reactive computed values, so text is only remeasured when font properties or content change.
The FontManager computes each shape's font faces once and caches them until the shape's props or meta change. Concurrent requests for the same font share one loading promise, and requestFonts batches requests into a single microtask.
The measurement element sets overflow-wrap: break-word so long words can break, width and max-width to control wrapping, and dir="auto" for mixed LTR/RTL content. Line height is resolved to a whole pixel with resolveLineHeightPx so measurement, on-canvas rendering, and export agree across browsers, which otherwise disagree on fractional line boxes. Use the same helper anywhere you render text from a custom shape.
editor.textMeasure to grow to fit.