Course progress Course outline 18 of 18 lessons available
Part I: Choose the Surface Before You Draw—Product, Pixels, and Coordinates
Part II: Give the Pixel World a Brain—Model, Scheduling, Input, and Tools
- 05 Chapter 5: Give the Pixel World a Registry available now
- 06 Chapter 6: Redraw Only When the Light Turns On—Render Scheduling and the React Boundary available now
- 07 Chapter 7: Mouse, Touch, and Pen Speak One Language available now
- 08 Chapter 8: Find the Big Box Before Inspecting the Edge available now
- 09 Chapter 9: Tools Are Traffic Lights, Not a Bag of Booleans available now
Part III: From “It Drags” to “It Is Trustworthy”—Interaction, Text, Assets, and Recovery
- 10 Chapter 10: Make the Editor Feel Right available now
- 11 Chapter 11: Drawn Text Is Not Editable Text available now
- 12 Chapter 12: Borrowed Images Cannot Be Packed Without Rules available now
- 13 Chapter 13: Time Machines and Old Boxes available now
- 14 Chapter 14: Looking Correct Is Not Being Correct available now
Part IV: Master-Level Decisions—Performance, Workers, GPU, SDKs, Collaboration, and AI
Start with a game a five-year-old can understand
Prepare a sheet of white paper, a transparent plastic sheet, a box of building blocks, and a small erasable chalkboard. Write “Order A” on the paper, draw an arrow on the plastic, arrange the blocks into two squares, and finally color a patch of the chalkboard blue.
Before continuing, predict four things: Which material makes text easiest to edit? Which lets you see text underneath it? Which can you move directly by hand? After erasing it, which makes it hardest to know what used to be there? Actually try it once, then change the A in “Order A” to B. You will discover that “they can all represent the same picture” does not mean “the same operation costs the same on each one.”
- Write on paperReadable and selectable
- Draw on filmStill sharp when scaled
- Arrange blocksEach block has an identity
- Color the boardOnly pixels remain
That is the single truth of this chapter: an Infinite Canvas is a product interaction model in which users can pan, zoom, and place objects; it is not a rendering technology. DOM, SVG, Canvas 2D, a GPU, or several of them together can implement it.
Translate the toys into Canvas engineering
| Toy material | Engineering equivalent | The question you actually need to ask |
|---|---|---|
| Text on white paper | DOM | Must users type, select, hear, or automatically lay out the content? |
| A line on transparent film | SVG | Do you need vector geometry and addressable nodes? |
| Color on an erasable board | Canvas 2D Bitmap | Do you need a large amount of custom 2D pixel drawing? |
| Many chefs plating at once | WebGL / WebGPU | Do you have evidence that batching or parallel computation will remove the bottleneck? |
| Paper attached to the board | Hybrid DOM/Canvas | Keep text editing in the DOM and give graphical drawing to Canvas |
| Paper attached to a GPU display | Hybrid DOM/GPU | Keep the semantic layer in the DOM and give high-throughput rendering to the GPU |
| “A desktop that can move forever” | Infinite Canvas | A product behavior that does not prescribe the underlying material |
The analogy breaks down here: a physical material can occupy only one place at a time, while the browser can layer DOM over Canvas. SVG is not literal film either; the browser rasterizes it too. Nor is a GPU a magical chef that is always faster: data uploads, shaders, device loss, and accessibility all impose costs. The analogy helps us ask the right question first; it cannot replace measurement, specifications, and compatibility testing.
Kill the misleading intuitions first
- “An infinite canvas should use
<canvas>.” Wrong. An infinite canvas promises only camera and object interactions. When a product contains a lot of text, a moderate number of objects, and important semantics, DOM/SVG is often the better fit. - “Many objects always mean Canvas.” Wrong. One hundred thousand total objects with ten visible objects have a different cost from two thousand visible objects containing shadows and images. Measure the visible count, change rate, and pixel area first.
- “Three.js is an upgrade from Canvas 2D.” Wrong. Three.js addresses 3D concerns such as Scene, Camera, Mesh, Material, and Depth. If an ordinary 2D whiteboard does not need those things, Three.js introduces a different, more complex set of constraints.
- “tldraw draws every Shape with Canvas 2D.” Wrong. The official tldraw Shape contract requires
component()to return a React component, while its export path generates SVG. A library name and the look of its product do not prove its Renderer. This describes the primary Shape rendering architecture; it does not deny that an individual component may use<canvas>internally.
Production backpack
Prerequisite contract
This chapter does not require knowledge of matrices or hit testing. You only need to be able to run a browser, TypeScript, and Vitest. First, freeze the product requirements: Canvas Lab is a business-process diagram editor. Cards require native title editing, connectors must remain sharp while zooming, the grid moves frequently, exports must include PNG and structured JSON, and neither keyboard nor screen-reader access may be absent.
Formal knowledge: choose by dimensions, not beliefs
| Renderer | Strengths | Main costs | Its place in Canvas Lab |
|---|---|---|---|
| DOM | Native text, CSS Layout, semantics, focus, and automated testing | Many nodes changing frequently increase style/layout costs | Toolbar, forms, text editing, Inspector |
| SVG | Vectors, addressable geometry nodes, CSS, and sharp exports | Many continuously changing nodes require measurement of DOM costs | Candidate for small-scale connectors or exports |
| Canvas 2D | Direct control of 2D drawing, mature APIs, and frequent redraws | No retained objects, native semantics, or text editing | Grid, Shapes, and Overlay in the teaching kernel |
| WebGL | Mature GPU pipeline, batching, and Shaders | You must own Buffers, textures, picking, and context loss | Replacement candidate after profiling provides evidence |
| WebGPU | More modern GPU compute/rendering interface | Support surface, device/pipeline management, and fallback costs | Progressive-enhancement experiment, not the baseline |
| DOM/Canvas | Semantic UI and a high-frequency pixel layer each do their own job | Coordinates, focus, and stacking must stay synchronized across two layers | Current choice |
| DOM/GPU | Native UI plus high-throughput rendering | Two lifecycles and a higher engineering threshold | When scale is high and GPU benefit is proven |
A review must record every dimension: native text editing, DOM semantics, Accessibility, visible object count, total object count, changes per second, pixel effects, visual and semantic export requirements, Renderer replacement cost, multiplayer collaboration model, target browsers, and whether the team can maintain geometry/GPU code. Collaboration alone does not determine a Renderer; it synchronizes Document operations, not pixels.
Evidence and compatibility
Browsers define <canvas> as an element that can bind contexts such as 2d, webgl, webgl2, and webgpu. That fact itself demonstrates that the element is not a specific product. Before release, check compatibility tables for target browsers and benchmark real devices; do not treat “Baseline” as a performance guarantee.
- MDN: Canvas API
- MDN: SVG
- MDN: WebGL API
- MDN: WebGPU API
- WHATWG HTML: Canvas
- tldraw: Shapes and the React component rendering contract
Sources above were checked on 2026-08-29. WebGPU availability and capabilities must be detected at runtime; this tutorial does not freeze a browser version number as a permanent fact.
This chapter’s engineering increment
Starting point: an empty page and a single set of business data. Finish line: DOM, SVG, and Canvas 2D implementations display the same “Order → Shipping” business diagram, and the project produces its first ADR. Planned files:
canvas-lab/
src/lab/ch01/render-three-ways.ts
src/lab/ch01/index.html
src/lab/ch01/renderer-choice.ts
src/lab/ch01/renderer-choice.test.ts
docs/adr/0001-canvas-2d-teaching-kernel.md
Complete HTML container:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Canvas Lab / Renderer</title>
</head>
<body>
<main>
<section>
<h1>DOM</h1>
<div id="dom-demo"></div>
</section>
<section>
<h1>SVG</h1>
<svg id="svg-demo" viewBox="0 0 360 140" aria-label="Order process diagram"></svg>
</section>
<section>
<h1>Canvas</h1>
<canvas id="canvas-demo" width="360" height="140">Order to shipping</canvas>
</section>
</main>
<script type="module" src="./render-three-ways.ts"></script>
</body>
</html>
Complete TypeScript: all three paths consume the same data, so no business object is locked to a Renderer.
type Card = { id: string; label: string; x: number; y: number };
type Diagram = { cards: readonly Card[]; edge: readonly [string, string] };
export const diagram: Diagram = {
cards: [
{ id: 'order', label: 'Order', x: 20, y: 40 },
{ id: 'ship', label: 'Shipping', x: 240, y: 40 },
],
edge: ['order', 'ship'],
};
function boxStyle(card: Card): string {
return `position:absolute;left:${card.x}px;top:${card.y}px;width:100px;height:56px;border:2px solid #2563eb;border-radius:10px;display:grid;place-items:center`;
}
export function renderDOM(root: HTMLElement, model: Diagram): void {
root.replaceChildren();
root.style.cssText = 'position:relative;width:360px;height:140px';
const line = document.createElement('div');
line.style.cssText =
'position:absolute;left:120px;top:68px;width:120px;border-top:2px solid #64748b';
root.append(line);
for (const card of model.cards) {
const article = document.createElement('article');
article.dataset.shapeId = card.id;
article.style.cssText = boxStyle(card);
article.textContent = card.label;
root.append(article);
}
}
export function renderSVG(root: SVGSVGElement, model: Diagram): void {
const ns = 'http://www.w3.org/2000/svg';
root.replaceChildren();
const line = document.createElementNS(ns, 'line');
Object.entries({
x1: '120',
y1: '68',
x2: '240',
y2: '68',
stroke: '#64748b',
'stroke-width': '2',
}).forEach(([key, value]) => line.setAttribute(key, value));
root.append(line);
for (const card of model.cards) {
const group = document.createElementNS(ns, 'g');
group.dataset.shapeId = card.id;
const rect = document.createElementNS(ns, 'rect');
Object.entries({
x: String(card.x),
y: String(card.y),
width: '100',
height: '56',
rx: '10',
fill: 'white',
stroke: '#2563eb',
'stroke-width': '2',
}).forEach(([key, value]) => rect.setAttribute(key, value));
const text = document.createElementNS(ns, 'text');
text.setAttribute('x', String(card.x + 50));
text.setAttribute('y', String(card.y + 34));
text.setAttribute('text-anchor', 'middle');
text.textContent = card.label;
group.append(rect, text);
root.append(group);
}
}
export function renderCanvas(canvas: HTMLCanvasElement, model: Diagram): void {
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2D unavailable');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = '#64748b';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(120, 68);
ctx.lineTo(240, 68);
ctx.stroke();
for (const card of model.cards) {
ctx.fillStyle = 'white';
ctx.strokeStyle = '#2563eb';
ctx.beginPath();
ctx.roundRect(card.x, card.y, 100, 56, 10);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#0f172a';
ctx.textAlign = 'center';
ctx.font = '16px system-ui';
ctx.fillText(card.label, card.x + 50, card.y + 34);
}
}
renderDOM(document.querySelector('#dom-demo')!, diagram);
renderSVG(document.querySelector('#svg-demo')!, diagram);
renderCanvas(document.querySelector('#canvas-demo')!, diagram);
The decision interface and tests give the ADR verifiable inputs:
export type ProductNeeds = Readonly<{
nativeSceneText: boolean;
semanticScene: boolean;
visibleDynamicShapes: number;
changesPerSecond: number;
benchmark?: Readonly<{ domP95Ms: number; canvasP95Ms: number; frameBudgetMs: number }>;
}>;
type SceneChoice = 'dom' | 'canvas2d' | 'prototype-required';
export function chooseLayers(n: ProductNeeds): { ui: 'dom'; scene: SceneChoice } {
if (n.nativeSceneText || n.semanticScene) return { ui: 'dom', scene: 'dom' };
if (!n.benchmark) return { ui: 'dom', scene: 'prototype-required' };
const canvasWins =
n.benchmark.canvasP95Ms <= n.benchmark.frameBudgetMs &&
n.benchmark.canvasP95Ms < n.benchmark.domP95Ms;
return { ui: 'dom', scene: canvasWins ? 'canvas2d' : 'dom' };
}
import { describe, expect, it } from 'vitest';
import { chooseLayers } from './renderer-choice';
describe('renderer ADR rule', () => {
it('never moves semantic product UI into the bitmap', () => {
expect(
chooseLayers({
nativeSceneText: true,
semanticScene: true,
visibleDynamicShapes: 2000,
changesPerSecond: 120,
}),
).toEqual({ ui: 'dom', scene: 'dom' });
});
it('refuses to turn an object count into an unmeasured renderer verdict', () => {
expect(
chooseLayers({
nativeSceneText: false,
semanticScene: false,
visibleDynamicShapes: 2000,
changesPerSecond: 120,
}).scene,
).toBe('prototype-required');
});
it('permits Canvas only when the measured scene meets its frame budget', () => {
expect(
chooseLayers({
nativeSceneText: false,
semanticScene: false,
visibleDynamicShapes: 2000,
changesPerSecond: 120,
benchmark: { domP95Ms: 24, canvasP95Ms: 8, frameBudgetMs: 12 },
}).scene,
).toBe('canvas2d');
});
});
The ADR must state that Canvas 2D was chosen for the teaching kernel so you can implement the Bitmap lifecycle, Renderer, geometry, and scheduling yourself. The Toolbar, forms, and text editing remain in the DOM. Replacement triggers include “return to DOM/SVG when native text and semantics dominate” and “move to a GPU when benchmarks prove Canvas 2D misses the p95 frame budget and a GPU prototype improves it.” Record compatibility, team capability, exports, collaboration, and exit costs as well.
visibleDynamicShapes and changesPerSecond enter the experimental scenario and ADR, but they are not universal thresholds across products. Returning “prototype required” without a Benchmark prevents sample numbers from masquerading as laws. Run npm run dev; expect the browser to show three diagrams with identical content, with selectable DOM text. Run npx vitest run src/lab/ch01/renderer-choice.test.ts; expect 3 passed.
Return to the game: we did not argue whether paper or chalkboards are “more advanced.” We put editable text on paper and placed the scene that must be redrawn repeatedly on the board.
Break it on purpose
Draw the heading and an ordinary form inside Canvas too, then remove the real <h1>, <label>, and <input> elements from the page.
| Injection | Symptom | Evidence | Fix | Regression test | Recovery |
|---|---|---|---|---|---|
| Pixel heading | The heading cannot be selected and is absent from the document outline | No heading in the DevTools Accessibility Tree | Use a real <h1>; Canvas draws only the scene | getByRole('heading') succeeds | Restore the DOM heading |
| Pixel input | Tab cannot enter it; a screen reader does not know its label/value | Keyboard recording and Accessibility Tree | Use a <label><input> DOM Overlay | Enter and submit with keyboard only | Delete the fake input |
| Canvas-laid-out form | CSS Grid, wrapping, and validation all require hand-written replacements | Compare implementation size and browser-test count | Keep product UI in the DOM | No overlap at 200% Zoom | Restore the original layout |
| Coordinate-assertion tests | A font or DPR change makes them brittle | Test fails on another operating system | Query DOM roles; test the Canvas model and screenshots | Node unit tests plus browser visual tests | Remove hard-coded coordinates |
Screen Reader, Keyboard, Text Selection, CSS, Layout, and Testing are not decorations to add later; they are Renderer-selection costs. After completing the failure experiment, restore the real DOM controls and rerun keyboard and screen-reader checks.
Pass with evidence
| Claim to prove | Acceptable evidence | Unacceptable answer |
|---|---|---|
| Which layer needs Canvas | Dynamic visible-object and frame Traces, an ADR, and a prototype | “Canvas is faster” |
| Which layer remains DOM | Keyboard, semantic-tree, and text-editing requirements | “Everyone does it this way” |
| Renderer is replaceable | All three implementations consume the same Diagram type | Storing business objects as DOM nodes |
| Compatibility is controlled | Target-browser testing and feature detection | Looking only at a global support table |
- I can explain the difference between an Infinite Canvas and a
<canvas>element. - I compared DOM, SVG, Canvas 2D, WebGL, WebGPU, and both Hybrid options.
- I filled in all twelve selection dimensions instead of only the object count.
- The three implementations show the same business diagram and consume the same data.
- The fake-form Canvas failure has recordings, semantic-tree evidence, and tests, and has been recovered.
- The ADR documents the current choice, replacement conditions, and collaboration/export/licensing/team costs.
Explain it to a five-year-old
Without using the words “Renderer,” “DOM,” or “GPU,” answer this: Why should a storybook that can read itself aloud not be drawn entirely as one photograph? Then answer this: Why might a fast-moving, colorful background not be made from ten thousand editable paper notes?
Expand a good jargon-free answer
A photograph is very good at showing you many colors at once, but it does not know which part is a title or where someone can type. A machine that reads books does not know what to read first either. That is why words and buttons should remain things that can be found, selected, and operated. In the other direction, if a large patch of constantly moving color is divided into too many little paper notes, arranging every note whenever it moves becomes a lot of work. The best approach is to preserve identities for the things people must understand and operate, and give the parts that must be redrawn quickly to a drawing board—just like attaching the paper to the chalkboard in our game.