Skip to content
JavaScript js canvas 5 min read

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:

PropertyTypeMeaning
widthnumberPixel width of the region
heightnumberPixel height of the region
dataUint8ClampedArrayFlat 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 getImageData then throws a SecurityError. Serve images from the same origin or set img.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 getImageData once, mutate the buffer in a single loop, then putImageData once — repeated round-trips are the main performance killer.
  • Cache image.data in 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.length or width * height * 4.
  • Lean on Uint8ClampedArray clamping instead of writing your own Math.max(0, Math.min(255, v)) for every channel.
  • Account for devicePixelRatio: on HiDPI displays the backing store is larger, so getImageData returns more pixels than the CSS size suggests.
  • For heavy, repeated filtering, consider offloading the loop to a Web Worker with the ImageData transferred via its underlying ArrayBuffer.
Last updated June 1, 2026
Was this helpful?