Knowledge/HTML5 Canvas/Introduction
28 Chapters
2D API Baseline
Living Document
Free License
Engineering Publication · Syed Omar Ibn Feroj← Back to portfolio
Engineering Notes · Open Knowledge Repository

HTML5 Canvas — Engineering
Notes for Pixels

A working reference for the Canvas 2D API — the immediate-mode model, paths and transforms, pixel manipulation, a correct animation loop, hit-testing, and the high-DPI and performance details that decide whether it's smooth.

28
Chapters
2D API
Baseline
Living
Document
Free
License
Ch. 01
Setup & Context
A canvas is a bitmap; the context is the brush.
1.1 — Get the 2D context
JavaScript
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
ctx.fillRect(10, 10, 100, 50);
Canvas is immediate-mode: once you draw, there are no objects — only pixels. There is nothing to "move" or "select" later. Every frame you clear and redraw the whole scene from your own data model.
Ch. 02
Coordinate System
Origin top-left, y grows down, and the CSS-size trap.
2.1 — Drawing buffer ≠ display size
HTML
<canvas width="800" height="600"></canvas>
<!-- CSS width:400px → bitmap is 800px, SCALED to 400 -->
The width/height attributes set the bitmap resolution; CSS width/height set the display size. Setting only CSS stretches an 300×150 default bitmap → everything blurry. Set the attributes (in JS) to match the displayed size × devicePixelRatio (Ch. 03).
Ch. 03
High-DPI
Crisp on retina — the boilerplate every canvas needs.
3.1 — Scale the buffer by devicePixelRatio
JavaScript
const dpr = window.devicePixelRatio || 1;
const { width: w, height: h } = canvas.getBoundingClientRect();
canvas.width  = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);   // now draw in CSS pixels
Assigning to canvas.width/height resets the entire context (transforms, styles, the bitmap). Do the DPR setup once on init/resize, then re-apply context state — never per frame.
Ch. 04
Rectangles
The only shape with dedicated methods.
4.1 — fill, stroke, clear
JavaScript
ctx.fillRect(x, y, w, h);
ctx.strokeRect(x, y, w, h);
ctx.clearRect(0, 0, canvas.width, canvas.height);  // erase
clearRect over the full buffer is the standard "wipe the frame" call. fillRect with a translucent colour instead creates a motion-trail effect — useful for visualisers.
Ch. 05
Paths
Everything that isn't a rectangle is a path.
5.1 — begin, build, fill/stroke
JavaScript
ctx.beginPath();
ctx.moveTo(20, 20);
ctx.lineTo(120, 20);
ctx.lineTo(70, 90);
ctx.closePath();
ctx.fill(); ctx.stroke();
Forgetting beginPath() is the #1 canvas bug: every new path is appended to the previous one, so the next stroke() re-strokes everything ever drawn — progressively slower and visually wrong. Call beginPath() before every shape.
Ch. 06
Curves & Arcs
Circles, rounded corners, smooth lines.
6.1 — arc, quadratic, bezier
JavaScript
ctx.arc(cx, cy, r, 0, Math.PI * 2);          // full circle
ctx.quadraticCurveTo(cpx, cpy, x, y);
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
ctx.roundRect(x, y, w, h, 8);              // modern: rounded rect
arc angles are in radians, measured clockwise from the positive x-axis (3 o'clock). Passing degrees draws nothing recognisable — convert with deg * Math.PI / 180.
Ch. 07
Fill & Stroke
State is sticky — set before you draw.
7.1 — Styles persist until changed
JavaScript
ctx.fillStyle = "#22d3ee";
ctx.strokeStyle = "rgba(255,255,255,.5)";
ctx.lineWidth = 2;
ctx.fill();   // uses whatever fillStyle is right now
Context properties are global state, not per-shape arguments. They keep their value across draws and frames. Group by style to minimise state changes — switching fillStyle is comparatively expensive.
Ch. 08
Gradients
Linear, radial, conic — created once, reused.
8.1 — Build, add stops, assign
JavaScript
const g = ctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, "#0ea5e9");
g.addColorStop(1, "#1e293b");
ctx.fillStyle = g; ctx.fillRect(0, 0, w, h);
Gradient coordinates are in canvas space, not relative to the shape — a gradient defined 0→100 won't span a rectangle drawn at x=500. Recreate it relative to where you draw, or translate first.
Ch. 09
Patterns
Tile an image or another canvas as a fill.
9.1 — createPattern
JavaScript
const p = ctx.createPattern(img, "repeat");
ctx.fillStyle = p;
ctx.fillRect(0, 0, w, h);
The source image must be fully loaded before createPattern (and drawImage) — call them from the image's onload, or you silently paint nothing.
Ch. 10
Shadows
Soft drop shadows — and their performance cost.
10.1 — Four shadow properties
JavaScript
ctx.shadowColor = "rgba(0,0,0,.4)";
ctx.shadowBlur = 12;
ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 4;
ctx.fillRect(20, 20, 100, 60);
ctx.shadowColor = "transparent";  // reset or it bleeds onto everything
Shadow blur is expensive and applies to every subsequent draw until reset. In an animation loop, blurred shadows tank the frame rate — pre-render the shadowed element to an offscreen canvas once and blit it.
Ch. 11
Line Styles
Caps, joins, dashes — the details that read as polish.
11.1 — cap, join, dash
JavaScript
ctx.lineCap = "round";     // butt | round | square
ctx.lineJoin = "round";    // miter | round | bevel
ctx.setLineDash([6, 4]);
ctx.lineDashOffset = 0;   // animate this for "marching ants"
Ch. 12
Text
Drawing strings — and the baseline that catches everyone.
12.1 — font, align, baseline
JavaScript
ctx.font = "600 16px Inter, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("Hello", x, y);
const { width } = ctx.measureText("Hello");  // for layout
Default textBaseline is "alphabetic", so fillText("x", 0, 0) draws mostly above y=0 and is clipped. For predictable positioning set textBaseline = "top" or "middle".
Ch. 13
Images
drawImage and its three signatures.
13.1 — Place, scale, crop
JavaScript
ctx.drawImage(img, x, y);                       // natural size
ctx.drawImage(img, x, y, w, h);                 // scaled
ctx.drawImage(img, sx, sy, sw, sh, x, y, w, h);  // crop+scale
A canvas tainted by a cross-origin image (drawn without proper CORS) throws on getImageData/toDataURL. Serve images with Access-Control-Allow-Origin and set img.crossOrigin = "anonymous" before src.
Ch. 14
Sprites
One image, many frames — the basis of 2D animation.
14.1 — Sub-rectangle blitting
JavaScript
const frame = (tick / 6 | 0) % FRAMES;
ctx.drawImage(sheet, frame * FW, 0, FW, FH, x, y, FW, FH);
A single sprite sheet drawn with the 9-arg drawImage beats N separate images: one decode, one GPU upload, no per-frame network. Keep frames on a power-of-two grid for clean math.
Ch. 15
Transformations
Move the coordinate system, not the shape.
15.1 — translate / rotate / scale
JavaScript
ctx.save();
ctx.translate(cx, cy);   // move origin to pivot
ctx.rotate(angle);
ctx.fillRect(-w/2, -h/2, w, h);  // draw centred on origin
ctx.restore();
Transforms are cumulative and mutate global state. To rotate a shape about its centre you must translate to the pivot, rotate, draw offset by half-size, then restore — rotating without translating spins everything around (0,0).
Ch. 16
save / restore
The state stack — your only undo.
16.1 — Balance every save with a restore
JavaScript
ctx.save();      // push: transform + all style state
// … mutate freely …
ctx.restore();   // pop: exact prior state back
An unbalanced save without restore grows the state stack every frame — a slow leak that degrades over minutes. Treat them like brackets: one restore for every save, same scope.
Ch. 17
Compositing
globalAlpha and globalCompositeOperation.
17.1 — Blend modes
JavaScript
ctx.globalAlpha = 0.5;
ctx.globalCompositeOperation = "lighter";  // additive glow
// … draw …
ctx.globalCompositeOperation = "source-over";  // reset to default
"lighter" (additive) is the cheap way to make particles and light glow without shaders. Always reset to "source-over" and globalAlpha = 1 or every later draw inherits it.
Ch. 18
Clipping
Constrain drawing to a region.
18.1 — Path → clip
JavaScript
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI*2);
ctx.clip();          // subsequent draws limited to the circle
ctx.drawImage(img, 0, 0);
ctx.restore();       // remove the clip
A clip can only be removed by restore() (to a state saved before the clip). There is no "unclip" — forget the save/restore pair and the rest of your scene vanishes inside the clip region.
Ch. 19
Pixel Manipulation
Read and write the raw RGBA buffer.
19.1 — getImageData / putImageData
JavaScript
const img = ctx.getImageData(0, 0, w, h);
const d = img.data;   // Uint8ClampedArray, RGBA per pixel
for (let i = 0; i < d.length; i += 4) {
  d[i] = 255 - d[i];   // invert red
}
ctx.putImageData(img, 0, 0);
getImageData is slow (CPU↔GPU readback) and the array is huge (w·h·4 bytes). Never call it per frame on the whole canvas — operate on small regions, or use WebGL/filters for full-frame effects.
Ch. 20
The Animation Loop
requestAnimationFrame, delta time, and never setInterval.
20.1 — Time-based, not frame-based
JavaScript
let last = performance.now();
function frame(now) {
  const dt = (now - last) / 1000; last = now;
  update(dt);          // move by velocity * dt
  render();
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
Move objects by velocity * dt, never by a fixed amount per frame. Frame-based motion runs at double speed on a 120 Hz display and crawls under load. setInterval for animation is always wrong — it drifts and runs in background tabs.
Ch. 21
Redraw Strategy
Clear-and-repaint vs dirty rectangles vs layers.
21.1 — Pick the cheapest correct one

Default: clear the whole canvas and redraw everything from the model each frame — simple and correct. If only a small region changes, dirty rectangles (clear+redraw just that area) cut fill cost. If a background is static, put it on its own canvas layer and only repaint the dynamic layer.

Don't optimise until you measure. Full clear-and-repaint is fine for most scenes at 60 fps; reach for layering/dirty-rects only when the profiler shows fill rate is the bottleneck.
Ch. 22
Hit Testing
There are no objects — you compute hits yourself.
22.1 — Map pointer → model
JavaScript
canvas.addEventListener("pointerdown", (e) => {
  const r = canvas.getBoundingClientRect();
  const x = e.clientX - r.left, y = e.clientY - r.top;
  const hit = shapes.find(s => contains(s, x, y));
});
Use getBoundingClientRect() to convert page coordinates to canvas coordinates, and divide by the CSS-vs-buffer scale if you used DPR. ctx.isPointInPath() can hit-test the current path but is slow for many shapes — keep a model and test geometry.
Ch. 23
Particle Systems
Hundreds of cheap objects, one buffer.
23.1 — Update, cull, draw
JavaScript
for (const p of particles) {
  p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt;
}
particles = particles.filter(p => p.life > 0);   // cull dead
// draw with composite "lighter" for glow (Ch. 17)
Allocating a new particle object every frame thrashes the GC and stutters. Use a fixed pool: reuse dead slots instead of push/filter when counts get large.
Ch. 24
Performance
The levers, in order of impact.
24.1 — What actually moves the needle
  • Batch by style — minimise fillStyle/font changes
  • Pre-render static/expensive elements to an offscreen canvas, then blit
  • Integer coordinates avoid sub-pixel anti-aliasing cost
  • Avoid shadows and per-frame getImageData
  • One requestAnimationFrame loop, not many
  • Layer static backgrounds onto a separate canvas
Ch. 25
Exporting
Get pixels back out as an image.
25.1 — toBlob over toDataURL
JavaScript
canvas.toBlob((blob) => {
  const url = URL.createObjectURL(blob);
  // download or upload the blob
}, "image/png");
Prefer toBlob (async, memory-efficient) to toDataURL (a giant base64 string that blocks the main thread for large canvases). Both throw if the canvas is cross-origin tainted (Ch. 13).
Ch. 26
Canvas vs SVG vs WebGL
Choose the right rasteriser.
26.1 — The decision
UseWhen
SVGfew shapes, need DOM/CSS/events per element, must scale crisply
Canvas 2Dthousands of pixels/shapes, charts, image editing, simple games
WebGL/WebGPUtens of thousands of objects, 3D, shaders, GPU effects
Rule of thumb: if you'd attach a click handler to each element, use SVG. If you're pushing pixels by the thousand every frame, use Canvas. If the CPU can't keep up, move to WebGL.
Ch. 27
Common Pitfalls
The recurring canvas bugs.
27.1 — The list
  • Sizing via CSS only → blurry (set the buffer + DPR)
  • Missing beginPath() → cumulative re-stroking
  • arc angles in degrees instead of radians
  • Unbalanced save/restore → state-stack leak
  • Forgetting to reset shadowColor/globalAlpha/composite
  • Drawing an image before it loaded
  • getImageData per frame → jank
  • Frame-based motion instead of * dt
Ch. 28
Best Practices
The defaults for a smooth canvas app.
28.1 — The short list
  • Set buffer size = CSS size × devicePixelRatio on init/resize
  • One rAF loop, time-based updates
  • beginPath() before every shape; balanced save/restore
  • Keep a model; the canvas is a view you fully repaint
  • Pre-render expensive/static content offscreen
  • Reset transient state (alpha, shadow, composite) each frame
  • Profile before optimising the redraw strategy
REF
Canvas Cheatsheet
The high-DPI + loop boilerplate, memorised.
Init + loop
JavaScript
function fit() {
  const dpr = devicePixelRatio || 1;
  const { width, height } = canvas.getBoundingClientRect();
  canvas.width = width * dpr; canvas.height = height * dpr;
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
addEventListener("resize", fit); fit();

let t = performance.now();
(function loop(n) {
  const dt = (n - t) / 1000; t = n;
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  update(dt); render();
  requestAnimationFrame(loop);
})(t);