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
Take a sheet of graph paper and color the fourth square in the third row blue. Put a sticky note beside it that says, “This is shape-7.” Let a friend take only the graph paper while you keep the note. Ask, “What is the blue square called? Who drew it? Can you move it two squares to the right?” Your friend can see only the color and cannot answer its name. Now replace the paper with one that has a denser grid. The old blue square disappears too, so you must redraw it from the record on the sticky note.
First predict: if you stretch a 10×10 drawing sheet to twice its size, do you gain more squares, or merely make the old squares fatter? If you replace it directly with a new 20×20 sheet, does the old drawing move over automatically? Try it yourself.
- Remember objectsWhere shape-7 is
- Prepare graph paperHow dense the screen is
- Color from the recordBecome a Bitmap
- Change paper and redrawDo not rely on old colors
The single truth of this chapter is: Canvas stores a Bitmap; the application stores objects. Canvas 2D is immediate-mode: a call changes pixels, and the browser does not retain “rectangle shape-7” for you.
Translate the toys into Canvas engineering
| Object or action in the toy world | Browser concept | Result |
|---|---|---|
| Size of the outer frame | CSS Size | How many CSS Pixels it occupies in page layout |
| Number of squares on the paper | canvas.width/height | Pixel dimensions of the Backing Bitmap |
| Grid density | devicePixelRatio | Several Physical Pixels may surround one CSS Pixel |
| Replacing the graph paper | Set width / height | Clears the Bitmap and resets Context State |
| Shape records on the sticky note | Document Data | Rebuilds the scene deterministically |
| Assistant measuring the frame | ResizeObserver | Notifies the Host when the container changes |
| Box of drawing tools | CanvasRenderingContext2D | Stateful tools such as Transform, alpha, and styles |
The analogy has limits. A CSS Pixel is not a physical square of fixed size, and DPR is not determined only by hardware; browser zoom and moving between displays can change the effective ratio. A ResizeObserver is not a synchronous ruler: the browser schedules callback batches and layout cycles. Replacing real paper does not “reset the pen color,” but setting a Canvas size attribute resets Context State as well, so state must be rebuilt explicitly.
Kill the misleading intuitions first
- “CSS
width: 800pxgives me 800 sharp pixels.” CSS changes only the displayed size. Enlarging the default 300×150 Bitmap still makes it blurry and stretched. - “Read DPR only during initial load.” DPR can change after browser zoom or a cross-display move, so the Host must recalculate it on every effective measurement.
- “Assigning the same
canvas.widthhas no effect.” The specification requires size assignment to reset the canvas. Evencanvas.width = canvas.widthclears it and restores default Context State. - “Restoring the camera after Resize is enough.” Transform, clip, alpha, lineDash, fonts, and other state can all disappear. The reliable approach is to run unified initialization and a complete redraw after a size change.
- “More Observers make it more responsive.” Duplicate Observers/listeners cause duplicate measurements, loops, and leaks. One Host owns one resource set.
Production backpack
Prerequisite contract
The prerequisite is the boundary from Chapter 1: product UI stays in the DOM, while the dynamic scene uses Canvas 2D. At this point Canvas Lab has only a redraw(viewport) callback that does not preserve pixels. The camera belongs to application data and must never be hidden inside the Context’s current Transform. The container may be hidden and its width or height may be zero. After destruction, no callback may draw again.
Formal knowledge
<canvas> is a DOM element; getContext('2d', settings) binds the Rendering Context. Canvas 2D calls such as fillRect() immediately modify the output Bitmap; they do not produce a queryable rectangle node. CSS Size determines layout dimensions, while Backing Bitmap Size determines the number of available pixels. A common calculation is round(cssSize × effectiveDpr), followed by ctx.setTransform(dpr, 0, 0, dpr, 0, 0) so subsequent drawing can continue in CSS Pixels.
A Physical Pixel is a display unit on a device; a CSS Pixel is a browser logical unit. devicePixelRatio is a ratio, not a permanent device label. Pixel dimensions need an upper bound, or a high-DPR large display can allocate astonishing amounts of memory. If width or height is 0, do not create a 0-sized Bitmap and pretend the operation completed. Enter a suspended state and wait for the next nonzero measurement.
Under the specification, assigning a dimension initializes a new Bitmap and resets Context State. Even when the value matches the old value, it cannot be treated as side-effect-free. Assign only when the calculated pixel dimensions actually change. Once they do, follow a fixed order: set CSS Size → set Backing Size → initialize Context → redraw completely from the model.
Context creation options are requests, not guarantees. alpha: false says that a transparent background is unnecessary. desynchronized is a hint for lower latency. willReadFrequently suits frequent getImageData() use and may change the acceleration strategy. colorSpace can request srgb or a supported wide gamut. Support for a colorType such as unorm8/float16 requires even more careful detection. Report the actual attributes through getContextAttributes?.(); the input settings are not proof of a capability. getContext() may also return null.
Use ResizeObserver to watch the container instead of polling every frame. Some environments provide devicePixelContentBoxSize, but retain a contentRect × DPR fallback. If a callback changes the observed container merely because it changed Canvas’s own dimensions, it can create a Resize loop. Host CSS must make Canvas follow its container; Bitmap attributes must not participate in layout.
Evidence and compatibility
- WHATWG HTML: Canvas element and the algorithm for setting Bitmap dimensions
- MDN: Canvas basic usage
- MDN: Window.devicePixelRatio
- MDN: ResizeObserver
- MDN: getContext()
- MDN: getContextAttributes()
Sources above were checked on 2026-08-29. Feature-detect colorType, device-pixel-content-box, and context-attribute reporting. When unsupported, fall back to sRGB, CSS Size × DPR, and “unknown” rather than blocking basic drawing.
This chapter’s engineering increment
Starting point: Chapter 1 calls renderCanvas directly with fixed dimensions. Finish line: a CanvasHost that creates only one Observer, supports DPR/zero size/destruction, redraws deterministically after every reset, and emits a capability report.
canvas-lab/
src/lab/ch02/canvas-host.ts
src/lab/ch02/canvas-host.test.ts
src/lab/ch02/demo.html
Interface contract: redraw receives the CSS viewport and actual DPR. The Host does not store the Document; it only notifies the Renderer. Here is the complete key implementation:
export type Viewport = { cssWidth: number; cssHeight: number; dpr: number };
export type CapabilityReport = {
requested: CanvasRenderingContext2DSettings;
actual: Record<string, unknown> | null;
};
export type HostPlatform = {
dpr(): number;
observe(element: Element, callback: () => void): () => void;
watchDpr(callback: () => void): () => void;
};
const browserPlatform: HostPlatform = {
dpr: () => window.devicePixelRatio || 1,
observe(element, callback) {
const observer = new ResizeObserver(callback);
observer.observe(element);
return () => observer.disconnect();
},
watchDpr(callback) {
let media: MediaQueryList | null = null;
const onChange = (): void => {
arm();
callback();
};
const arm = (): void => {
media?.removeEventListener('change', onChange);
media = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
media.addEventListener('change', onChange);
};
arm();
return () => media?.removeEventListener('change', onChange);
},
};
export class CanvasHost {
readonly context: CanvasRenderingContext2D;
readonly capabilities: CapabilityReport;
private stopObserving: (() => void) | null = null;
private stopWatchingDpr: (() => void) | null = null;
private destroyed = false;
private last: Viewport | null = null;
constructor(
private readonly container: HTMLElement,
private readonly canvas: HTMLCanvasElement,
private readonly redraw: (ctx: CanvasRenderingContext2D, viewport: Viewport) => void,
private readonly platform: HostPlatform = browserPlatform,
) {
const requested: CanvasRenderingContext2DSettings = {
alpha: false,
desynchronized: false,
willReadFrequently: false,
colorSpace: 'srgb',
colorType: 'unorm8',
};
const context = canvas.getContext('2d', requested);
if (!context) throw new Error('Canvas 2D context is unavailable');
this.context = context;
const readable = context as CanvasRenderingContext2D & {
getContextAttributes?: () => Record<string, unknown>;
};
this.capabilities = {
requested,
actual: readable.getContextAttributes?.() ?? null,
};
this.canvas.style.display = 'block';
this.canvas.style.width = '100%';
this.canvas.style.height = '100%';
this.stopObserving = this.platform.observe(this.container, () => this.measure());
this.stopWatchingDpr = this.platform.watchDpr(() => this.measure());
this.measure();
}
measure(): void {
if (this.destroyed) return;
const rect = this.container.getBoundingClientRect();
const cssWidth = Math.max(0, rect.width);
const cssHeight = Math.max(0, rect.height);
if (cssWidth === 0 || cssHeight === 0) {
this.last = null;
return;
}
const dpr = Math.min(3, Math.max(1, this.platform.dpr()));
const pixelWidth = Math.max(1, Math.round(cssWidth * dpr));
const pixelHeight = Math.max(1, Math.round(cssHeight * dpr));
const bitmapChanged = this.canvas.width !== pixelWidth || this.canvas.height !== pixelHeight;
const viewport: Viewport = { cssWidth, cssHeight, dpr };
const viewportChanged =
!this.last ||
this.last.cssWidth !== cssWidth ||
this.last.cssHeight !== cssHeight ||
this.last.dpr !== dpr;
if (!bitmapChanged && !viewportChanged) return;
if (bitmapChanged) {
this.canvas.width = pixelWidth;
this.canvas.height = pixelHeight;
}
this.initializeContext(viewport);
this.last = viewport;
this.redraw(this.context, viewport);
}
private initializeContext(viewport: Viewport): void {
this.context.setTransform(viewport.dpr, 0, 0, viewport.dpr, 0, 0);
this.context.globalAlpha = 1;
this.context.globalCompositeOperation = 'source-over';
this.context.lineCap = 'butt';
this.context.lineJoin = 'miter';
this.context.setLineDash([]);
this.context.fillStyle = '#ffffff';
this.context.fillRect(0, 0, viewport.cssWidth, viewport.cssHeight);
}
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
this.stopObserving?.();
this.stopWatchingDpr?.();
this.stopObserving = null;
this.stopWatchingDpr = null;
this.last = null;
}
}
The complete usage page retains a real, accessible heading:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<style>
#stage {
width: 80vw;
height: 50vh;
}
canvas {
display: block;
}
</style>
</head>
<body>
<h1>Canvas Lab</h1>
<div id="stage"><canvas id="scene">Two business cards</canvas></div>
<script type="module">
import { CanvasHost } from './canvas-host.ts';
const host = new CanvasHost(stage, scene, (ctx, view) => {
ctx.fillStyle = '#2563eb';
ctx.fillRect(24, 24, 120, 64);
ctx.fillStyle = '#fff';
ctx.fillText(`${view.dpr}x`, 34, 60);
});
window.addEventListener('pagehide', () => host.destroy(), { once: true });
</script>
</body>
</html>
The test does not guess browser timing. It injects the platform and asserts resource ownership and redraw behavior:
import { describe, expect, it, vi } from 'vitest';
import { CanvasHost, type HostPlatform } from './canvas-host';
describe('CanvasHost', () => {
it('resizes for both element and DPR changes, then releases both subscriptions once', () => {
const container = document.createElement('div');
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
width: 200,
height: 100,
} as DOMRect);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
const setTransform = vi.spyOn(ctx, 'setTransform');
const redraw = vi.fn();
const disconnect = vi.fn();
const unwatchDpr = vi.fn();
let notifyResize = () => undefined;
let notifyDpr = () => undefined;
let dpr = 2;
const platform: HostPlatform = {
dpr: () => dpr,
observe: (_element, callback) => {
notifyResize = callback;
return disconnect;
},
watchDpr: (callback) => {
notifyDpr = callback;
return unwatchDpr;
},
};
const host = new CanvasHost(container, canvas, redraw, platform);
expect([canvas.width, canvas.height]).toEqual([400, 200]);
expect(setTransform).toHaveBeenLastCalledWith(2, 0, 0, 2, 0, 0);
expect(redraw).toHaveBeenCalledTimes(1);
notifyResize();
expect(redraw).toHaveBeenCalledTimes(1);
dpr = 1.5;
notifyDpr();
expect([canvas.width, canvas.height]).toEqual([300, 150]);
expect(setTransform).toHaveBeenLastCalledWith(1.5, 0, 0, 1.5, 0, 0);
expect(redraw).toHaveBeenCalledTimes(2);
host.destroy();
host.destroy();
expect(disconnect).toHaveBeenCalledTimes(1);
expect(unwatchDpr).toHaveBeenCalledTimes(1);
});
it('waits through a zero-sized hidden container', () => {
const container = document.createElement('div');
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
width: 0,
height: 0,
} as DOMRect);
const redraw = vi.fn();
const platform: HostPlatform = {
dpr: () => 2,
observe: () => () => undefined,
watchDpr: () => () => undefined,
};
new CanvasHost(container, document.createElement('canvas'), redraw, platform);
expect(redraw).not.toHaveBeenCalled();
});
});
ResizeObserver tells you only whether the element box changed. DPR can change independently while the box dimensions remain the same, so re-arm a matchMedia listener for the current resolution as in the MDN example. Run npx vitest run src/lab/ch02/canvas-host.test.ts --environment jsdom; expect 2 passed. Run npm run dev, then change the window and browser Zoom. The capability report in the console should remain stable, and shape edges must not stretch. Return to the graph-paper game: the Host replaces the sheet according to frame size and density and prepares the pens again; the actual picture is still redrawn from the sticky-note record.
Break it on purpose
| Injection | Symptom | Evidence | Fix | Regression test | Recovery |
|---|---|---|---|---|---|
| Browser Zoom from 100% to 200% | Old implementation is blurry or wrongly sized | Record CSS/Bitmap/DPR tuple and screenshots | Recalculate DPR on every measurement | Pixel dimensions match after Playwright changes DPR | Restore Zoom |
| Drag from standard display to high-DPI display | Still uses old density | devicePixelRatio changed but Bitmap did not | Resize/media change triggers measure | Simulate DPR 1→2 | Drag back and verify again |
display:none, then show | First measurement is 0 and remains permanently blank | redraw call count is 0 | Suspend at zero size and resume on the next callback | 0→300×200 calls once | Restore display |
| Skip initialization after a size change | Camera/alpha/lineDash disappear | getTransform() is identity and screenshot is wrong | Initialize state, then redraw from model | Spy order: resize→setTransform→render | Remove failure branch |
| Callback always writes the same dimensions | Clears, flickers, and renders repeatedly | Identical measurement still increases redraw count | Compare pixel/viewport before assignment | Repeated notify does not redraw | Restore guard |
| Create a Host on every React render | Multiple Observers and loops | Construction/disconnection counts are asymmetric | Own only one Host in the lifecycle | Two mount/unmount cycles are both 1:1 | Destroy extra instances |
Also test a tiny width, a large high-DPR display, and missing capability reporting manually. Recovery does not mean “it looked fine after refresh.” It means the injection is removed, Observer counts return to zero, tests pass again, and the next Resize still draws.
Pass with evidence
| Property | Automated/manual | Evidence |
|---|---|---|
| Not blurry | Manual | CSS Size × DPR and Bitmap Size logs, cross-display screenshots |
| Not stretched | Automated | Assertion comparing CSS and Bitmap aspect ratios |
| Does not lose Transform/Camera | Automated | getTransform/camera rendering test after Resize |
| Does not create multiple Observers | Automated | Construction and disconnect counts plus repeated-mount test |
| Redraw is repeatable | Automated | Identical pixel snapshot from the same Document after clear/Resize |
| Can degrade gracefully | Automated/manual | Tests for no attribute report, DPR=1, and zero-size paths |
- I can distinguish CSS Size, Backing Size, CSS Pixel, and Physical Pixel.
- I know changing
width/heightclears the Bitmap and resets Context State, including assignment of the same value. - The Host handles Resize, DPR, initialization, capability reporting, and deterministic redraw together.
-
alpha/desynchronized/willReadFrequently/colorSpace/colorTypeare recorded requests with measured capabilities. - Hidden→visible, Zoom, cross-display movement, zero size, and Resize loops all have evidence.
-
destroy()is idempotent and no Observer leaks.
Explain it to a five-year-old
Without using the words “pixel,” “DPR,” “Context,” or “Observer,” answer: Why is stretching graph paper not the same as replacing it with paper that has a denser grid? Why must you redraw from a record after replacing the paper?
Expand a good jargon-free answer
Stretching old paper only makes each old square look fatter, so the edges become fuzzy. Paper with more squares lets you draw finer edges. But new paper starts empty: the blue color on the old sheet does not jump over by itself, and even the last pen color cannot be trusted. So we keep a list of what exists and where it belongs. Whenever the paper's size or density changes, we prepare the pen and draw everything again from that list. The sticky note and the graph paper each have their own job.