Pixel Manipulation
Most canvas drawing happens through high-level shape and image APIs, but sometimes you need to reach into the raw bytes behind the pixels — to build a grayscale filter, invert colors, detect edges, or generate procedural textures. The ImageData interface gives you exactly that: direct read/write access to every red, green, blue, and alpha value on the canvas. Because you operate on a flat numeric array, pixel work is also a natural fit for typed arrays and tight numeric loops.
The ImageData object
When you read pixels off a canvas you get back an ImageData object with three properties:
| Property | Type | Meaning |
|---|---|---|
width | number | Pixel width of the region |
height | number | Pixel height of the region |
data | Uint8ClampedArray | Flat RGBA byte array, length width * height * 4 |
The data array stores pixels left-to-right, top-to-bottom. Each pixel occupies four consecutive bytes — red, green, blue, alpha — and each value ranges from 0 to 255. The Uint8ClampedArray type clamps assignments: writing 300 stores 255, writing -5 stores 0, and fractional values round to the nearest integer. That clamping is what makes it safe for color math.
To find the offset of the pixel at column x, row y:
const index = (y * width + x) * 4;
const r = data[index];
const g = data[index + 1];
const b = data[index + 2];
const a = data[index + 3];
Reading pixels with getImageData
getImageData(sx, sy, sw, sh) copies a rectangular region of the canvas into a fresh ImageData. The coordinates are in device pixels, and the call returns a snapshot — later drawing on the canvas does not change an ImageData you already grabbed.
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
ctx.fillStyle = "tomato";
ctx.fillRect(0, 0, 50, 50);
const image = ctx.getImageData(0, 0, 50, 50);
console.log(image.width, image.height, image.data.length);
console.log(image.data[0], image.data[1], image.data[2], image.data[3]);
Output:
50 50 10000
255 99 71 255
Reading from a canvas that has loaded a cross-origin image without proper CORS headers “taints” it, and
getImageDatathen throws aSecurityError. Serve images from the same origin or setimg.crossOrigin = "anonymous"plus the right CORS headers.
Writing pixels with putImageData
putImageData(imageData, dx, dy) writes a block of pixels back onto the canvas at the destination corner (dx, dy). Unlike drawImage, it ignores the current transform, global alpha, and compositing settings — it is a raw byte blit.
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
// ...mutate image.data here...
ctx.putImageData(image, 0, 0);
An optional “dirty rectangle” lets you write back only a sub-region, which is faster when you changed just part of the buffer:
ctx.putImageData(image, 0, 0, 10, 10, 32, 32); // only the 32x32 area at (10,10)
Creating blank ImageData
createImageData(width, height) allocates a new, transparent-black ImageData (every byte 0) without reading anything from the canvas — perfect for generating images from scratch. You can also pass an existing ImageData to copy its dimensions.
const blank = ctx.createImageData(256, 256);
// blank.data is all zeros: fully transparent, ready to fill
A per-pixel filter: grayscale and invert
A filter is just a loop over data in steps of four. For grayscale we compute a luminance-weighted average that matches how the eye perceives brightness; for invert we subtract each channel from 255. The alpha byte at i + 3 is usually left untouched.
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: sans-serif; display: flex; gap: 12px; flex-wrap: wrap; }
button { padding: 6px 12px; cursor: pointer; }
</style>
</head>
<body>
<canvas id="c" width="200" height="200"></canvas>
<div>
<button id="gray">Grayscale</button>
<button id="invert">Invert</button>
<button id="reset">Reset</button>
</div>
<script>
const canvas = document.getElementById("c");
const ctx = canvas.getContext("2d");
function draw() {
const grad = ctx.createLinearGradient(0, 0, 200, 200);
grad.addColorStop(0, "#ff6b6b");
grad.addColorStop(0.5, "#4ecdc4");
grad.addColorStop(1, "#5567ff");
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 200, 200);
ctx.fillStyle = "white";
ctx.beginPath();
ctx.arc(100, 100, 50, 0, Math.PI * 2);
ctx.fill();
}
function grayscale() {
const image = ctx.getImageData(0, 0, 200, 200);
const d = image.data;
for (let i = 0; i < d.length; i += 4) {
const lum = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
d[i] = d[i + 1] = d[i + 2] = lum; // clamped + rounded automatically
}
ctx.putImageData(image, 0, 0);
}
function invert() {
const image = ctx.getImageData(0, 0, 200, 200);
const d = image.data;
for (let i = 0; i < d.length; i += 4) {
d[i] = 255 - d[i];
d[i + 1] = 255 - d[i + 1];
d[i + 2] = 255 - d[i + 2];
}
ctx.putImageData(image, 0, 0);
}
document.getElementById("gray").onclick = grayscale;
document.getElementById("invert").onclick = invert;
document.getElementById("reset").onclick = draw;
draw();
</script>
</body>
</html>
Generating an image from scratch
You don’t need any prior drawing to use pixel buffers. The next demo fills a createImageData buffer with a procedural pattern, computing each pixel’s color directly from its coordinates, then blits it once with putImageData.
<!DOCTYPE html>
<html>
<head><style>body { margin: 0; }</style></head>
<body>
<canvas id="c" width="256" height="256"></canvas>
<script>
const ctx = document.getElementById("c").getContext("2d");
const W = 256, H = 256;
const image = ctx.createImageData(W, H);
const d = image.data;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const i = (y * W + x) * 4;
d[i] = x; // red ramps across
d[i + 1] = y; // green ramps down
d[i + 2] = (x ^ y) & 0xff; // blue: XOR texture
d[i + 3] = 255; // fully opaque
}
}
ctx.putImageData(image, 0, 0);
</script>
</body>
</html>
Best Practices
- Call
getImageDataonce, mutate the buffer in a single loop, thenputImageDataonce — repeated round-trips are the main performance killer. - Cache
image.datain a local variable (const d = image.data) so the hot loop avoids property lookups. - Iterate in steps of four and never assume the array length is what you expect — derive bounds from
data.lengthorwidth * height * 4. - Lean on
Uint8ClampedArrayclamping instead of writing your ownMath.max(0, Math.min(255, v))for every channel. - Account for
devicePixelRatio: on HiDPI displays the backing store is larger, sogetImageDatareturns more pixels than the CSS size suggests. - For heavy, repeated filtering, consider offloading the loop to a Web Worker with the
ImageDatatransferred via its underlyingArrayBuffer.