DEV Community

Haruki Tanaka
Haruki Tanaka

Posted on

Circular Image Cropping with Canvas API: The Complete Guide

I needed to generate circular avatar thumbnails for a side project last week. What seemed like a 10-minute task turned into a deep dive into the Canvas API, alpha channels, and browser quirks. Here's everything I learned.

Why border-radius Isn't Enough

The most common approach to circular images on the web is CSS:

.avatar {
  border-radius: 50%;
  width: 100px;
  height: 100px;
  object-fit: cover;
}
Enter fullscreen mode Exit fullscreen mode

This works for display. But the underlying file is still rectangular. Right-click, save — it's a square. Drop it into Figma, an email signature, or a platform without CSS masking, and you get the full rectangle back.

If you need the actual file to be circular with transparent pixels outside the crop area, you need to do the cropping at the pixel level. That means Canvas.

The Core Technique: Clipping Paths

The Canvas API has a clip() method that restricts all subsequent drawing operations to a defined path. Combined with arc(), this gives us circular cropping:

function circularCrop(img, size) {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');

  canvas.width = size;
  canvas.height = size;

  // Define a circular clipping region
  ctx.beginPath();
  ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
  ctx.closePath();
  ctx.clip();

  // Draw the image centered in the circle
  const scale = Math.max(size / img.width, size / img.height);
  const x = (size - img.width * scale) / 2;
  const y = (size - img.height * scale) / 2;
  ctx.drawImage(img, x, y, img.width * scale, img.height * scale);

  return canvas;
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • arc() creates the circular path
  • clip() makes it the active clipping region — anything drawn outside is invisible
  • The canvas defaults to transparent, so pixels outside the circle have alpha = 0
  • We scale the image to cover the circle (like object-fit: cover in CSS)

Exporting: toDataURL vs toBlob

Once you have the cropped canvas, there are two ways to get the image out:

toDataURL (synchronous, simple)

const dataUrl = canvas.toDataURL('image/png');
// Returns: "data:image/png;base64,iVBORw0KGgo..."
Enter fullscreen mode Exit fullscreen mode

Good for small images or quick previews. The downside: it's synchronous, blocks the main thread, and the base64 encoding adds ~33% to the size.

toBlob (async, better for downloads)

canvas.toBlob((blob) => {
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = 'avatar-circle.png';
  a.click();
  URL.revokeObjectURL(url);
}, 'image/png');
Enter fullscreen mode Exit fullscreen mode

This is what you want for a download button. It's async, produces a proper Blob, and avoids the base64 overhead.

Important: always use image/png for circular crops. JPEG doesn't support transparency — your carefully clipped circle will get a white (or black) background.

Handling Retina Displays

If you render a 200px circle on a 2x display, it'll look blurry. The fix:

function circularCropRetina(img, displaySize) {
  const dpr = window.devicePixelRatio || 1;
  const size = displaySize * dpr;

  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');

  canvas.width = size;
  canvas.height = size;

  // Scale all drawing operations
  ctx.scale(dpr, dpr);

  ctx.beginPath();
  ctx.arc(displaySize / 2, displaySize / 2, displaySize / 2, 0, Math.PI * 2);
  ctx.closePath();
  ctx.clip();

  const scale = Math.max(displaySize / img.width, displaySize / img.height);
  const x = (displaySize - img.width * scale) / 2;
  const y = (displaySize - img.height * scale) / 2;
  ctx.drawImage(img, x, y, img.width * scale, img.height * scale);

  return canvas;
}
Enter fullscreen mode Exit fullscreen mode

The canvas is physically size * dpr pixels, but all coordinates use the logical displaySize. Result: crisp circles on any display.

Edge Cases That Will Bite You

1. iOS Safari Canvas Size Limits

Safari on iOS has a hard limit on canvas dimensions. Depending on the device, it's somewhere between 4096x4096 and 16384x16384 pixels. Exceed it and the canvas silently renders as blank.

function getMaxCanvasSize() {
  // Conservative limit that works across iOS devices
  const MAX_AREA = 16777216; // 4096 * 4096
  return Math.floor(Math.sqrt(MAX_AREA));
}

function safeSize(requestedSize) {
  const max = getMaxCanvasSize();
  return Math.min(requestedSize, max);
}
Enter fullscreen mode Exit fullscreen mode

2. CORS and Tainted Canvases

If your image comes from a different origin, the canvas becomes "tainted" and you can't export it:

const img = new Image();
img.crossOrigin = 'anonymous'; // Must set BEFORE src
img.src = 'https://other-domain.com/photo.jpg';
Enter fullscreen mode Exit fullscreen mode

The server must also send Access-Control-Allow-Origin headers. Without both pieces, toDataURL() and toBlob() will throw a SecurityError.

3. Large Images and Memory

A 4000x3000 photo uses ~48MB of memory as a canvas (4000 * 3000 * 4 bytes per pixel). If you're cropping user-uploaded photos, resize first:

function resizeBeforeCrop(img, maxDimension) {
  if (img.width <= maxDimension && img.height <= maxDimension) {
    return img; // No resize needed
  }

  const scale = maxDimension / Math.max(img.width, img.height);
  const canvas = document.createElement('canvas');
  canvas.width = img.width * scale;
  canvas.height = img.height * scale;

  const ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0, canvas.width, canvas.height);

  return canvas; // Use this as input for circularCrop()
}
Enter fullscreen mode Exit fullscreen mode

4. Image Loading Race Condition

A classic gotcha — trying to draw an image that hasn't loaded yet:

// Wrong: image may not be loaded
const img = new Image();
img.src = file;
circularCrop(img, 200); // Draws nothing

// Right: wait for load
const img = new Image();
img.onload = () => circularCrop(img, 200);
img.src = file;

// Or with promises
function loadImage(src) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => resolve(img);
    img.onerror = reject;
    img.src = src;
  });
}
Enter fullscreen mode Exit fullscreen mode

Putting It All Together

Here's a complete, production-ready function that handles all the edge cases above:

async function createCircularAvatar(file, outputSize = 400) {
  // Load image from File object
  const src = URL.createObjectURL(file);
  const img = await loadImage(src);
  URL.revokeObjectURL(src);

  // Resize if too large (prevent memory issues)
  const maxDim = Math.min(outputSize * 3, 2048);
  const source = resizeBeforeCrop(img, maxDim);

  // Safe output size (iOS limits)
  const size = safeSize(outputSize);

  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  canvas.width = size;
  canvas.height = size;

  // Circular clip
  ctx.beginPath();
  ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
  ctx.closePath();
  ctx.clip();

  // Cover-fit the image
  const w = source.width || source.naturalWidth;
  const h = source.height || source.naturalHeight;
  const scale = Math.max(size / w, size / h);
  const x = (size - w * scale) / 2;
  const y = (size - h * scale) / 2;
  ctx.drawImage(source, x, y, w * scale, h * scale);

  // Return as Blob
  return new Promise((resolve) => {
    canvas.toBlob(resolve, 'image/png');
  });
}

// Usage
const fileInput = document.querySelector('input[type="file"]');
fileInput.addEventListener('change', async (e) => {
  const blob = await createCircularAvatar(e.target.files[0], 400);
  // Download or upload the blob
});
Enter fullscreen mode Exit fullscreen mode

When to Skip the Code

All of the above is worth understanding. But honestly, for one-off tasks — preparing a default avatar, cropping a screenshot for docs, testing how a photo looks as a circle — I just use Circle Crop Image. It handles these edge cases already, runs client-side (nothing uploaded), and takes about 5 seconds.

Writing the code makes sense when you need it in your app's pipeline. For everything else, a good tool saves time.

Top comments (0)