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;
}
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;
}
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: coverin 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..."
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');
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;
}
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);
}
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';
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()
}
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;
});
}
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
});
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)