Skip to content
JavaScript js canvas 4 min read

Drawing Images & Sprites

Photographs, icons, textures, and game sprites all reach the canvas through a single, versatile method: ctx.drawImage(). It can paint an image at a point, stretch it to any size, or slice a rectangle out of a larger sheet — which is exactly how 2D games animate characters from a single file. Because images load asynchronously, the trick to getting reliable results is drawing only after the pixels have arrived.

Loading an image before you draw

An <img> element (or an Image object created in script) does not have its pixel data the instant you set src. The browser fetches it over the network, so you must wait for the load event before calling drawImage. Drawing too early silently paints nothing.

const ctx = canvas.getContext("2d");
const img = new Image();

img.addEventListener("load", () => {
  ctx.drawImage(img, 0, 0);
});

img.addEventListener("error", () => {
  console.error("Image failed to load");
});

img.src = "player.png"; // set src LAST, after handlers are attached

If you prefer async/await, wrap the load in a promise. This is handy when you need several images ready before the first frame.

function loadImage(src) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => resolve(img);
    img.onerror = () => reject(new Error(`Cannot load ${src}`));
    img.src = src;
  });
}

const [hero, tiles] = await Promise.all([
  loadImage("hero.png"),
  loadImage("tiles.png"),
]);

Tip: Setting img.src after attaching the load listener avoids a rare race where a cached image fires load before your handler is registered.

The three drawImage signatures

drawImage is overloaded with three argument counts. Each adds a layer of control: where, how big, and which slice of the source to use.

SignatureArgumentsDoes
PositiondrawImage(img, dx, dy)Draws at natural size at (dx, dy)
ScaledrawImage(img, dx, dy, dW, dH)Draws scaled into a dW × dH box
Crop + scaledrawImage(img, sx, sy, sW, sH, dx, dy, dW, dH)Copies the source rectangle (sx, sy, sW, sH) into the destination rectangle

The first source (s*) values index into the original image pixels; the destination (d*) values index into the canvas. The nine-argument form is the workhorse for sprites because it lets you pull one frame out of a packed sheet.

// Position: top-left corner at (40, 30)
ctx.drawImage(img, 40, 30);

// Scale: squeeze the whole image into a 120×80 box
ctx.drawImage(img, 40, 30, 120, 80);

// Crop + scale: take a 32×32 tile from (64, 0), draw it at (10, 10) doubled
ctx.drawImage(img, 64, 0, 32, 32, 10, 10, 64, 64);

Sprite sheets

A sprite sheet packs many frames into one image, so the browser makes a single request and the GPU keeps one texture warm. To draw frame n from a grid, compute its source (sx, sy) from the frame size and columns, then blit that cell with the nine-argument form.

const FRAME_W = 32;
const FRAME_H = 32;
const COLUMNS = 4;

function drawFrame(ctx, sheet, index, dx, dy) {
  const sx = (index % COLUMNS) * FRAME_W;
  const sy = Math.floor(index / COLUMNS) * FRAME_H;
  ctx.drawImage(sheet, sx, sy, FRAME_W, FRAME_H, dx, dy, FRAME_W, FRAME_H);
}

Animating is just advancing index over time and redrawing. The pen below cycles through a procedurally generated four-frame sheet so you can see the walk-cycle effect with no external assets.

<canvas id="c" width="320" height="160" style="background:#0f172a;border-radius:8px"></canvas>
<script>
  const ctx = document.getElementById("c").getContext("2d");
  const FRAME = 64, COLS = 4;

  // Build a 4-frame sprite sheet on an offscreen canvas
  const sheet = document.createElement("canvas");
  sheet.width = FRAME * COLS;
  sheet.height = FRAME;
  const sctx = sheet.getContext("2d");
  const colors = ["#f87171", "#fbbf24", "#34d399", "#60a5fa"];
  colors.forEach((color, i) => {
    sctx.fillStyle = color;
    sctx.beginPath();
    sctx.arc(i * FRAME + FRAME / 2, FRAME / 2, 24 - i * 3, 0, Math.PI * 2);
    sctx.fill();
  });

  let frame = 0;
  setInterval(() => {
    ctx.clearRect(0, 0, 320, 160);
    const sx = (frame % COLS) * FRAME;
    ctx.drawImage(sheet, sx, 0, FRAME, FRAME, 128, 48, FRAME, FRAME);
    frame++;
  }, 180);
</script>

Scaling, tiling, and pixel-art crispness

For backgrounds you can tile an image across the canvas with nested loops, or reach for createPattern when the fill is rectangular. When scaling pixel art, disable smoothing so edges stay sharp instead of blurring.

<canvas id="art" width="320" height="160" style="border-radius:8px"></canvas>
<script>
  const ctx = document.getElementById("art").getContext("2d");

  // Tiny 4×4 checker drawn on an offscreen canvas to act as our "image"
  const tile = document.createElement("canvas");
  tile.width = tile.height = 4;
  const t = tile.getContext("2d");
  t.fillStyle = "#1e293b"; t.fillRect(0, 0, 4, 4);
  t.fillStyle = "#38bdf8"; t.fillRect(0, 0, 2, 2); t.fillRect(2, 2, 2, 2);

  ctx.imageSmoothingEnabled = false; // keep pixels crisp when scaled up
  ctx.drawImage(tile, 0, 0, 4, 4, 20, 30, 120, 120);  // blocky, scaled 30×

  ctx.imageSmoothingEnabled = true;
  ctx.drawImage(tile, 0, 0, 4, 4, 180, 30, 120, 120); // smooth, blurred
</script>

Output:

Left square: hard, blocky checker (smoothing off)
Right square: soft, blurred checker (smoothing on)

Best Practices

  • Always attach load (and error) handlers before setting src, and draw only inside the load callback.
  • Preload every asset with Promise.all so animation starts with all textures ready.
  • Prefer one sprite sheet over many small files to cut HTTP requests and texture switches.
  • Cache computed (sx, sy) offsets or use integer frame math instead of recalculating heavy values each frame.
  • Set ctx.imageSmoothingEnabled = false for pixel art; leave it on for photos.
  • Draw at integer destination coordinates to avoid sub-pixel blur, and round positions in animation loops.
  • Use an offscreen <canvas> to pre-compose static layers, then drawImage that canvas in one call.
Last updated June 1, 2026
Was this helpful?