Pretext Lab
30The accessibility argument
Arc 10 · Ecosystem & Future

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.

Pretext → DOM (per-line spans)
Cmd+F works ✓
Pretext → Canvas (painted pixels)
Cmd+F finds nothing ✗
Try it: press Cmd+F (or Ctrl+F) and type attention. The left pane highlights the match. The right pane — same word, same pixels — is invisible to Find.
340

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:

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

"looks beautiful and stays accessible" Pretext measurement + DOM render; per-line spans, not 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
Next: the closing lesson — where this whole library lands once browsers catch up.
one handle, two renderers — DOM keeps the text real
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);
  }
}