The accessibility argument
Most viral Pretext demos render to canvas — text woven through particles, text that morphs with a scroll, text that behaves like a physical object. The visual flex is real. But every one of those demos has quietly traded something away: the text stops being text. You can't select it, the browser's Find command can't see it, and a screen reader reads nothing at all.
Two panes below. Identical line breaks, identical look. Left pane renders Pretext's lines into DOM spans. Right pane paints the same lines onto a canvas. Drag the shared width slider to prove the output matches. Then press Cmd/Ctrl+F and search for the word attention. Only one pane has it.
Mechanism
Both panes start from the same prepareWithSegments(text, font) handle and the same layoutWithLines(prepared, width, lineHeightPx) call. The lines array they receive is identical, character for character, pixel for pixel. The divergence is only in the render step.
The DOM pane iterates lines and stamps one <span style="display:block"> per line with the line's .text as its textContent. The browser now owns these strings as real Text nodes — indexed by the selection engine, exposed to the accessibility tree, walked by search crawlers, read by screen readers in source order.
The canvas pane takes the same lines array and calls ctx.fillText(line.text, paddingX, baselineY) per line. The glyphs become pixels. There is no text in the DOM under those pixels — just a single <canvas> element that, to the browser's accessibility tree, is an opaque image.
Visually the two panes are indistinguishable when the font and line-height are mirrored correctly. Semantically they are not the same document at all.
Application
Default to DOM target for any paragraph a human is meant to read. That means:
- Article bodies, documentation, blog posts. Selection, copy-paste, Find, screen readers, translation tools, print — all of these work for free, because the DOM still holds real text.
- Chat messages, comments, any user-generated prose. Users expect to copy quotes. Search crawlers expect to find substrings. A canvas breaks both.
- Form labels, error messages, nav text. Accessibility isn't optional here; canvas text is effectively invisible to assistive tech unless you duplicate every word into an offscreen DOM element.
Canvas earns its place when motion is the point — games, data-viz, kinetic typography, text-that-acts-like-a-particle. For those, keep the hidden DOM fallback or accept the accessibility cost openly. But don't reach for canvas just because the Pretext demo on Twitter did.
The argument of this lesson was sharpened by Den Odell's essay You're Looking at the Wrong Pretext Demo (dev.to, April 2026). Also see Micaela Avigliano's accessible Pretext demo.
"I went to the woods because I wished to live deliberately. What lies before us and what lies behind us are small matters compared to what lies within us — and what you see is always less than what holds you up."
After Henry David Thoreau & Ralph Waldo Emerson (public-domain paraphrase)Direct Claude
fillText
"canvas only when the visual demands it"
→
default to DOM target; reach for canvas when motion is the point
"same Pretext handle, two renderers"
→
one prepareWithSegments, split into DOM spans or ctx.fillText
"don't break Cmd+F"
→
if prose lives in a canvas, mirror it into an offscreen DOM fallback
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext';
const FONT = "500 17px 'EB Garamond', serif";
const LINE_HEIGHT_PX = 17 * 1.55;
const prepared = prepareWithSegments(SOURCE_TEXT, FONT);
// Shared layout — identical line breaks drive both panes.
function relayout(width) {
const { lines, height } = layoutWithLines(prepared, width, LINE_HEIGHT_PX);
renderDom(lines, height);
renderCanvas(lines);
}
// DOM pane: per-line spans. The browser treats these as real text.
function renderDom(lines, height) {
domRegion.style.minHeight = height + 'px';
lineStack.textContent = '';
for (const line of lines) {
const span = document.createElement('span');
span.className = 'pt-line';
span.textContent = line.text; // Find works. Selection works. a11y works.
lineStack.appendChild(span);
}
}
// Canvas pane: same lines, painted pixels. Beautiful — but opaque to a11y tools.
function renderCanvas(lines) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < lines.length; i++) {
const baselineY = i * LINE_HEIGHT_PX + LINE_HEIGHT_PX * 0.78;
ctx.fillText(lines[i].text, PADDING_X, PADDING_Y + baselineY);
}
}