canvas-txt: text boxes for the HTML5 canvas
Multiline text on HTML5 Canvas
At a glance
- What is it?
- canvas-txt is a small zero dependency TypeScript library that draws wrapped, aligned and justified text into a box on an HTML5 canvas, with line breaking that comes from Intl.Segmenter so CJK, Thai, emoji and combining marks are handled correctly. It splits layout from painting so a render loop can measure once, and it deliberately leaves the interaction layer, the draggable box, to the application.
- Who is it for?
- canvas-txt fits a canvas application that needs real text layout rather than a single fillText call, especially when the content is not English, because the wrapping is driven by Intl.Segmenter and the box model includes justification, ellipsis and a max line count, and it fits server side image generation through node-canvas or skia-canvas with no type casts.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 2 days ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.
Editorial analysis
A box of text in one call, with no dependencies
canvas-txt is a library for drawing text boxes on an HTML5 canvas, and its pitch is that the integration is one call:
import { drawText } from 'canvas-txt'
drawText(ctx, 'Lorem ipsum dolor sit amet', {
x: 100, y: 200, width: 200, height: 200,
fontSize: 24,
})There is no wrapping code to write, no alignment arithmetic, no clipping logic, and the call returns the resulting geometry, so a caller can learn how tall the text turned out to be. The library is MIT licensed, published to npm as canvas-txt, and the README states the two properties that decide whether it fits: zero dependencies, and roughly 4 kB gzipped.
That size and dependency count is what lets the same code run in four quite different places. The documentation says it works in browsers, in workers through OffscreenCanvas, with node-canvas and with skia-canvas, and the API is typed against a minimal TextContext so that a node-canvas or skia-canvas context does not need a cast. That is the difference between a library that is theoretically portable and one that is actually used in a worker and in a server side render in the same repository.
The project is organised as a pnpm monorepo, with the library under packages/ and a documentation application under apps/ that carries the live playground, a Vue component. The root package.json pins pnpm 10.12.4 and requires Node 22 or newer, and it lists canvas and esbuild as the only built dependencies, which are the native bits needed by the test environment.
Wrapping that comes from Intl.Segmenter, not from a word splitter
The feature that separates canvas-txt from a naive implementation is the line breaking. The README states that line breaks come from Intl.Segmenter, and the consequences are specific: CJK and Thai break at real word boundaries, and emoji and combining marks never split mid-codepoint.
That is a real problem rather than a detail. A wrapper built on splitting on spaces produces one enormous unbreakable token for a Chinese sentence, which then overflows the box or gets clipped mid-character. Intl.Segmenter gives the platform's own segmentation of the text into grapheme clusters and words, and for Thai it handles the case where a word has no spaces at all. The grapheme cluster part matters for emoji, where a family emoji or a flag is several code points that must not be separated.
The rest of the box model is equally deliberate. There is horizontal and vertical alignment, with align defaulting to center and accepting left, center or right. There is true justification, which the documentation highlights as producing no injected characters, meaning the text is spaced by the renderer rather than by padding strings with spaces. There is overflow handling of hidden or ellipsis, and a maxLines option. Debug mode draws the box and the alignment guides so you can see what the layout decided.
The combination is what a design tool needs, and it is the reason the library exists rather than a ten line measureText and fillText helper.
Layout once, paint every frame
drawText is described as a convenience wrapper, and the documentation points at animation loops, canvas editors and data grids as the cases it is not ideal for. For those, there is a second API.
import { layoutText, drawTextLayout } from 'canvas-txt'
const layout = layoutText(ctx, text, { width: 300, height: 200, fontSize: 18 })
drawTextLayout(ctx, layout, { x: 50, y: 40 })
drawTextLayout(ctx, layout, { x: 400, y: 40, style: { fill: '#888' } })
The layout is computed once and the cached result is painted at as many positions as you like, in as many styles as you like, without measuring the text again. That matters because measuring text is the expensive part, and in a render loop that redraws every frame it is the difference between a smooth animation and a slow one.
The layout object is also a data structure, and the comments in the original snippet show its shape: layout.lines holds entries of text, x, y, width and words, while layout.height, layout.width and layout.clipped describe the box and whether the content overflowed, not just a cache. Each entry in layout.lines carries the text, its x and y position, its width and the words that make it up, which is exactly what you need to hit test a click against a line, to highlight a word, or to draw your own decoration. layout.height, layout.width and layout.clipped tell you whether the content overflowed the box and was cut.
On performance, the README claims per-font width caches and memoised layouts, and states that it is faster than canvas-hypertxt on every workload, with exact measuring. That comparison is the project's own claim rather than an independent measurement, so treat it as a design argument: the caches are keyed by font, and the exactness comes from measuring rather than estimating.
The interaction layer is deliberately not in the library
The image at the top of the README shows a text box with selection handles and alignment guides, and the documentation is careful to say that the layer that makes it draggable is yours to build. The instructions are concrete: keep the box rectangle in your own state, hit test pointer events against its corners and edges, update the state on drag, and re-render. The documentation points at a JSFiddle that does drag, resize from any handle and live re-wrapping in about 100 lines, and at the playground source, a Vue component, which uses the same approach.
The reasoning is stated, and it is a good one. The rendering is the piece that is identical everywhere, while pointer handling belongs to the application's event model, whether that is React state, a Vue component, or an immediate mode game loop. Staying out of it is what allows the library to drop into any of them, and it is also why the feature list has no mention of handles, selection or drag behaviour.
For a reader choosing between libraries, this is the trade to weigh. A library that also shipped the interaction layer would save you the hundred lines, but it would also dictate a state model, and canvas-txt would no longer be a rendering library that fits inside an editor you already have. The per-line positions in layout.lines are what make the hundred lines feasible, since without them you would have to re-measure text to work out what was clicked.
Server side rendering and colour handling
The Node path is a first class part of the documentation rather than an afterthought, and it works with node-canvas and skia-canvas directly, with no type casts needed:
import { createCanvas } from 'canvas'
import { drawText } from 'canvas-txt'
import * as fs from 'node:fs'
const canvas = createCanvas(400, 400)
const ctx = canvas.getContext('2d')
drawText(ctx, 'Hello World!', {
x: 100, y: 200, width: 200, height: 200, fontSize: 24,
})
fs.writeFileSync('output.png', canvas.toBuffer('image/png'))That is a complete image generation pipeline, which is the scenario the library is most useful in: a service that renders images or PDF-like output on a server, with the same layout code as the browser client.
Colour handling follows the canvas convention. Without a style option the text uses the context's current fillStyle, so setting ctx.fillStyle before the call is enough, and a per-call style can set fill, stroke and strokeWidth for an outlined label. The example in the documentation shows both routes, the context level one and the per-call one.
For browser use there is also a CDN route, where a script tag from unpkg exposes the same functions on the global window.canvasTxt, for a page that does not want a build step. The package itself is installed with npm i canvas-txt.
Version 5 is a rewrite, and the maintenance story is uneven
The release history needs a note, because the gap is unusual. Version 5.0.0 was published on 2026-08-10, and before it the most recent releases were v4.1.1 and v4.0.7, both on 2023-09-21. So version 4 sat unchanged for roughly three years while the project was evidently rewritten, and the README points anyone upgrading to a migration guide at MIGRATION.md, with the claim that most code keeps working.
The repository was last pushed on 2026-09-11 and is not archived, so the rewrite is recent and the tree is active. The top level contains the monorepo configuration, the packages and apps directories, the LICENSE, the MIGRATION.md, a .prettierrc.cjs, a railway.json deployment file for the documentation site, and a .claude directory.
Two upgrade questions follow from this. The first is the API surface: the v5 line adds the layout and paint split, which is the reason the guide exists, and if your code is calling drawText in a loop you will want to know whether you can move to layoutText and drawTextLayout without changing how the output looks. The second is the platform requirement. The wrapping depends on Intl.Segmenter, so an environment without it, an old browser or a Node build without full ICU support, will not behave as documented, and the Node 22 floor in the package metadata and the Intl.Segmenter dependency are the two constraints to check against your deployment target before you upgrade.
There is no homepage in the repository metadata, but the project publishes a live playground at canvas-txt.geongeorge.com, which is the fastest way to see what a version actually does before you upgrade.
Editorial conclusion
canvas-txt fits a canvas application that needs real text layout rather than a single fillText call, especially when the content is not English, because the wrapping is driven by Intl.Segmenter and the box model includes justification, ellipsis and a max line count, and it fits server side image generation through node-canvas or skia-canvas with no type casts. It is the wrong tool if you want a ready made text editing experience, because the interaction layer is explicitly not part of the library, and it is the wrong tool if you are still on version 4, since v5.0.0 is a rewrite with a migration guide and the v4 line stopped in 2023. Before you upgrade, read MIGRATION.md, check that the platform you target has Intl.Segmenter, and decide whether the drawText convenience call is enough or whether you need the layout and paint split.
Frequently asked questions
What does canvas-txt do and how do I install it?
It is a TypeScript library for drawing text boxes on an HTML5 canvas, handling wrapping, alignment, clipping and justification in one call. Install it with npm i canvas-txt, or load it from a CDN where the same functions are exposed on the global window.canvasTxt, and it has zero dependencies at roughly 4 kB gzipped.
How does canvas-txt handle line breaking for Chinese or Thai text?
Line breaks come from Intl.Segmenter, so CJK and Thai break at real word boundaries rather than on spaces, and emoji and combining marks are never split mid-codepoint. The library reports that it is faster than canvas-hypertxt on every workload, with exact measuring and per-font width caches.
What is the difference between drawText and layoutText in canvas-txt?
drawText is a convenience wrapper that lays out and paints in one call. layoutText computes a layout once and drawTextLayout paints that cached layout as often as you like, so a render loop does not re-measure the text, and layout.lines gives per-line text, position, width and words for hit testing and decoration.
Does canvas-txt work outside the browser?
Yes. The README says it works in browsers, in workers through OffscreenCanvas, with node-canvas and with skia-canvas, and the API is typed against a minimal TextContext so no casts are needed for the Node canvas implementations.
Official sources
Was this useful?