Canvas gives you pixel-level control over every frame. You can draw shapes, gradients, images, and particles at 60fps. But the moment you try to render a paragraph of text, the illusion breaks. You call fillText(), and the entire paragraph renders on a single, clipped, horizontal line. No wrapping. No mixed styles. No line breaks.
HTML handles all of this effortlessly — word wrap, <b>, <i>, <span style="color:red">, text flowing around floats. But HTML lives in the DOM, and the DOM cannot keep up with a game loop or a real-time animation. You need Canvas for the rendering. You need HTML-level text layout for the typography. Pretext bridges that gap.
This post shows how to get HTML-quality rich text rendering on Canvas — word-wrapped, multi-style, animated — using Pretext as the layout engine.
What Canvas fillText() Actually Does
The Canvas 2D API has exactly two text methods: fillText() and strokeText(). Both work the same way — they render a single run of text at a fixed (x, y) position:
const ctx = canvas.getContext('2d');
ctx.font = '16px sans-serif';
// This renders the ENTIRE paragraph on one line, clipped at canvas edge
ctx.fillText(longParagraph, 10, 30);There is no maxWidth parameter that triggers word wrapping. The optional third argument to fillText() scales the text horizontally to fit — it does not break it into lines.
The measureText() method is slightly more useful, but still limited:
const metrics = ctx.measureText('Hello world');
// What you get:
metrics.width; // total advance width
metrics.actualBoundingBoxAscent; // distance above baseline
metrics.actualBoundingBoxDescent; // distance below baseline
// What you DON'T get:
// - Where to break lines at a given container width
// - Total height of multi-line text
// - Per-word widths for wrapping decisionsThe Canvas text API was designed for labels and annotations — a score counter in a game, a tooltip on a chart. It was never meant for paragraphs, articles, or chat messages. If you want multi-line wrapped text on Canvas, you need to compute the layout yourself.
Traditional Workarounds (and Why They Fall Short)
Developers have tried several approaches to get rich text onto Canvas. None of them are satisfying.
foreignObject in SVG
You can embed HTML inside an SVG <foreignObject>, then draw that SVG onto a Canvas via drawImage(). This technically works — you get full CSS rendering, including word wrap and mixed styles.
But the problems are severe. Safari has long-standing rendering inconsistencies with foreignObject. Drawing the SVG onto Canvas taints it (security restrictions prevent reading pixels back). The approach requires the DOM to be present, which defeats the purpose if you are trying to avoid layout thrashing. And the performance is far too slow for per-frame updates.
html2canvas / dom-to-image
The screenshot approach: render your HTML in the DOM, capture it as a raster image, then draw that image to Canvas. Libraries like html2canvas automate this pipeline.
The result is a flat bitmap. You cannot animate individual characters. You cannot respond to frame-by-frame changes. Each capture takes 50–200ms — orders of magnitude too slow for 60fps. This approach works for static exports (saving a card as an image), not for real-time rendering.
Manual word-by-word rendering
The most common DIY approach: split the text on spaces, measure each word with measureText(), track the x-position, and start a new line when the accumulated width exceeds the container.
// The naive manual approach
const words = text.split(' ');
let line = '';
let y = 20;
for (const word of words) {
const testLine = line ? line + ' ' + word : word;
const testWidth = ctx.measureText(testLine).width;
if (testWidth > maxWidth && line) {
ctx.fillText(line, 10, y);
line = word;
y += lineHeight;
} else {
line = testLine;
}
}
ctx.fillText(line, 10, y);This works for English text with spaces between words. It breaks catastrophically for CJK languages (Chinese, Japanese, Korean have no word spaces), Thai (words are not separated by spaces), bidirectional text (Arabic mixed with English), and any language that requires sophisticated line-breaking rules. It also ignores trailing whitespace handling, hyphenation, and the overflow-wrap behaviors that CSS implements correctly.
Comparison
| Approach | Word wrap | Multi-style | CJK/i18n | Animation | Speed |
|---|---|---|---|---|---|
| foreignObject | Yes | Yes | Yes | No | Slow |
| html2canvas | Yes | Yes | Yes | No | Very slow |
| Manual split | Partial | No | No | Yes | Fast |
| Pretext + fillText | Yes | Yes | Yes | Yes | Fast |
The Pretext Approach: Measure, Layout, Render
Pretext separates the problem into three clean phases. The browser measures (via Canvas measureText()). Pretext computes the layout (pure JavaScript arithmetic). You render (via fillText() per line). Each phase has a clear responsibility, and only the first one touches the browser.
Phase 1: Prepare
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext';
const prepared = prepareWithSegments(text, font);prepareWithSegments() does three things: it segments the text using Intl.Segmenter (handling word boundaries correctly for every language), measures each segment via the Canvas measureText() API, and caches the results. This is the only step that touches the browser. Everything after is pure math.
The font parameter is a CSS font shorthand string — '16px Inter', 'bold 14px/1.5 "Helvetica Neue"', etc. Pretext reads the same font metrics the browser uses, so the layout will match what CSS would produce.
Phase 2: Layout
const result = layoutWithLines(prepared, maxWidth, lineHeight);
// result.lines → [{ text: "The quick brown", width: 142 }, { text: "fox jumps over", width: 128 }, ...]
// result.height → total height in pixels
// result.lineCount → number of lineslayoutWithLines() computes line breaks using the same rules as CSS white-space: normal. It handles word boundaries, CJK characters, trailing whitespace, and overflow-wrap — all without touching the DOM. The result is an array of lines, each with its text content and pixel width.
This call is pure arithmetic on cached character widths. It runs in about 0.001ms — fast enough to call thousands of times per frame.
Phase 3: Render
const ctx = canvas.getContext('2d');
ctx.font = font;
ctx.textBaseline = 'top';
ctx.fillStyle = '#333';
let y = 20;
for (const line of result.lines) {
ctx.fillText(line.text, 20, y);
y += lineHeight;
}You own the rendering. Each line is a single fillText() call. You control position, color, alignment, rotation, shadow, opacity — anything Canvas supports. Pretext gives you the layout data. What you do with it is up to you.
That is the entire pattern. Three lines of Pretext code replace the manual word-splitting loop, and they work correctly for every language and writing system.
Multi-Style Text: Bold, Italic, and Color on Canvas
HTML lets you mix <b>, <i>, and colored spans freely within a paragraph. On Canvas, you need to prepare each style run separately and render them in sequence.
The approach: parse your text into style runs, prepare each one with its own font string, and render them on the same line by tracking the x-cursor:
const runs = [
{ text: 'Pretext makes ', font: '16px Inter', color: '#333' },
{ text: 'rich text on Canvas', font: 'bold 16px Inter', color: '#2563eb' },
{ text: ' actually possible.', font: 'italic 16px Inter', color: '#333' },
];
// Measure each run
const measured = runs.map(run => ({
...run,
result: layoutWithLines(prepareWithSegments(run.text, run.font), Infinity, 24),
}));
// Render sequentially, tracking x position
let x = 20;
const y = 40;
for (const run of measured) {
ctx.font = run.font;
ctx.fillStyle = run.color;
ctx.fillText(run.result.lines[0].text, x, y);
x += run.result.lines[0].width;
}For a single line, this is straightforward — measure each run at Infinity width (no wrapping), then render them back-to-back.
For multi-line mixed-style text, you need a higher-level algorithm that tracks how runs break across lines. Pretext handles the hard part — accurate per-segment measurement — so your run-stitching logic can work with precise width values rather than guessing.
The key insight is that prepareWithSegments() gives you segment-level width data. You can walk the segments, accumulate widths, and decide where to break — even when different segments use different fonts. The measurement is always accurate because it comes from the browser's own measureText().
Text Around Shapes: Editorial Layout on Canvas
One of the most striking effects you can achieve with Canvas text is flowing it around arbitrary shapes — a circle, an image cutout, a moving obstacle. CSS has shape-outside, but it only works with floats, and it cannot update per-frame in a game loop. On Canvas with Pretext, you can do this at 60fps.
The key API is layoutNextLine(). Instead of laying out all lines at a fixed width, you lay out one line at a time, passing a different available width for each line:
import { prepareWithSegments, layoutNextLine } from '@chenglou/pretext';
const prepared = prepareWithSegments(longText, font);
let cursor = { segmentIndex: 0, graphemeIndex: 0 };
for (let y = 0; y < canvasHeight; y += lineHeight) {
// Calculate available width at this y-position (carving out a circle)
const availableWidth = getWidthAroundCircle(y, circleX, circleY, circleR, canvasWidth);
const xOffset = getXOffsetForCircle(y, circleX, circleY, circleR);
const line = layoutNextLine(prepared, cursor, availableWidth);
if (!line) break;
ctx.fillText(line.text, xOffset, y);
cursor = line.end; // feed end cursor into next call
}layoutNextLine() returns { text, width, start, end }. The end cursor tells you where in the text the next line should begin. Feed it back as the cursor for the next call, and the text flows naturally from line to line — even when each line has a different width.
The Pretext Breaker game on this site uses exactly this pattern. Text flows around brick obstacles, the ball, and the paddle — all recalculated every frame. The getTextWallSlots() function computes available width at each y-position by subtracting the space occupied by game objects, then feeds those widths to layoutNextLine() in a loop.
This is something CSS fundamentally cannot do dynamically. shape-outside requires a static float context. Pretext gives you per-line variable-width layout that updates in microseconds.
Animated Text: Character Reveals and Game UI
Because Pretext gives you per-line layout data before any rendering happens, you can animate text character by character with precise positioning.
Typewriter Reveal
A common effect: text appears one character at a time, as if being typed. With Pretext, you compute the full layout once, then render a growing slice on each frame:
const prepared = prepareWithSegments(text, font);
const result = layoutWithLines(prepared, maxWidth, lineHeight);
let charCount = 0;
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
let drawn = 0;
let y = 20;
for (const line of result.lines) {
const remaining = charCount - drawn;
if (remaining <= 0) break;
const visibleText = line.text.slice(0, remaining);
ctx.fillText(visibleText, 20, y);
drawn += line.text.length;
y += lineHeight;
}
charCount++;
if (charCount <= text.length) requestAnimationFrame(animate);
}
animate();The layout is computed once. Each frame just slices the pre-computed lines — no re-measurement, no re-layout, no DOM access.
Game UI
The Pretext Breaker game demonstrates a more advanced pattern. Text blocks are pre-measured using prepareWithSegments() and layoutWithLines(), cached in a Map, and rendered with alignment, stroke, shadow, and rotation effects:
// From the Pretext Breaker renderer (simplified)
function drawBlock(ctx, block, x, y, options = {}) {
const { color = '#fff', align = 'left', strokeColor, shadowBlur } = options;
ctx.save();
ctx.font = block.font;
ctx.textBaseline = 'top';
ctx.fillStyle = color;
if (shadowBlur) {
ctx.shadowColor = options.shadowColor;
ctx.shadowBlur = shadowBlur;
}
let drawY = y;
for (const line of block.lines) {
let drawX = x;
if (align === 'center') drawX = x + (block.width - line.width) / 2;
else if (align === 'right') drawX = x + block.width - line.width;
ctx.fillText(line.text, drawX, drawY);
if (strokeColor) {
ctx.strokeStyle = strokeColor;
ctx.strokeText(line.text, drawX, drawY);
}
drawY += block.lineHeight;
}
ctx.restore();
}The block is measured once, then drawn hundreds of times per second as bricks move, break, and reform. The measurement cost is amortized to zero.
Performance: Why This Works at 60fps
The performance story comes down to one principle: measure once, layout cheaply, render freely.
prepareWithSegments() runs once per unique text + font combination. It takes 0.1–1ms depending on text length. Cache the result in a Map keyed by text + font, and this cost drops to a single map lookup on subsequent frames.
layoutWithLines() and layoutNextLine() are pure arithmetic on cached segment widths. A single call takes roughly 0.001ms. At 60fps you have 16ms per frame — you can call layout() over 10,000 times and still have frame budget left for rendering.
layoutNextLine() is incremental: it does not re-process previous lines. For variable-width flows with 50 lines, it does 50 small computations rather than one large one.
Compare this with the alternatives:
| Approach | Time per text block | Suitable for 60fps? |
|---|---|---|
| html2canvas | 50–200ms | No |
| foreignObject | 10–50ms | No |
| DOM measurement | 0.1–10ms | Borderline |
| Pretext layout() | ~0.001ms | Yes |
The difference is not 2x or 5x — it is three to five orders of magnitude. That is the difference between "possible" and "not possible" for real-time applications.
The Full Pattern: A Reusable Canvas Text Renderer
Here is a complete, copy-pasteable utility that wraps the Pretext pattern into a single function. It handles word wrapping, text alignment, and returns the computed dimensions:
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext';
// Cache prepared text to avoid re-measuring
const cache = new Map();
function drawWrappedText(ctx, text, x, y, options = {}) {
const {
font = '16px sans-serif',
maxWidth = 300,
lineHeight = 24,
color = '#000',
align = 'left',
} = options;
// Prepare (cached)
const cacheKey = `${font}::${text}`;
let result = cache.get(cacheKey);
if (!result) {
const prepared = prepareWithSegments(text, font);
result = layoutWithLines(prepared, maxWidth, lineHeight);
cache.set(cacheKey, result);
}
// Render
ctx.save();
ctx.font = font;
ctx.textBaseline = 'top';
ctx.fillStyle = color;
let drawY = y;
for (const line of result.lines) {
let drawX = x;
if (align === 'center') drawX = x + (maxWidth - line.width) / 2;
else if (align === 'right') drawX = x + maxWidth - line.width;
ctx.fillText(line.text, drawX, drawY);
drawY += lineHeight;
}
ctx.restore();
return { height: result.height, lineCount: result.lineCount };
}Use it like this:
const { height } = drawWrappedText(ctx, articleText, 20, 40, {
font: '15px/1.6 "Inter", sans-serif',
maxWidth: 400,
lineHeight: 24,
color: '#1a1a1a',
align: 'left',
});
// Draw the next block below
drawWrappedText(ctx, secondParagraph, 20, 40 + height + 16, { ... });This pattern works identically with OffscreenCanvas in a Web Worker. Run prepareWithSegments() on the main thread (it needs Canvas access), transfer the prepared data to a Worker, and call layoutWithLines() + render there. Layout and rendering happen entirely off the main thread.
Conclusion
Canvas fillText() was never designed for paragraphs. The browser's full text layout engine — word wrapping, line breaking, whitespace handling, CJK support — is locked behind the DOM. For two decades, developers have worked around this with hacks: hidden divs, foreignObject SVGs, html2canvas screenshots, manual word-splitting loops. Each one trades something essential — performance, correctness, animation capability, or international text support.
Pretext takes a different path. It uses the browser's own measureText() for the hard part (font shaping and glyph measurement), then reimplements the layout logic (line breaking, wrapping, whitespace collapsing) in pure JavaScript arithmetic. The result: HTML-quality text layout on Canvas, at 60fps, with full rendering control over every character.
If you are building a game, a data visualization, an interactive animation, or anything that needs rich text on Canvas — Pretext gives you the layout engine that Canvas never had.
Try it yourself in the Pretext Playground, explore the interactive demos, or play the Pretext Breaker game to see Canvas text rendering in action.
