Implementing Image Cropping with HTML Canvas

Canvas provides low-level rendering capabilities ideal for building custom image cropping interfaces. This implementation handles image loading, interactive region selection, visual feedback, and export of the cropped result.

Image Upload and Preprocessing

A file input triggers base64 conversion using FileReader. Once loaded, the image is scaled to fit within a fixed canvas area while preserving aspect ratio:

const uploadInput = document.getElementById('image-upload');
const canvas = document.getElementById('crop-canvas');
const ctx = canvas.getContext('2d');

uploadInput.addEventListener('change', (event) => {
  const selectedFile = event.target.files[0];
  if (!selectedFile || !selectedFile.type.match('image.*')) return;

  const reader = new FileReader();
  reader.onload = (loadEvent) => {
    const img = new Image();
    img.onload = () => {
      // Compute scale to fit inside 500×500 viewport
      const maxWidth = 500;
      const maxHeight = 500;
      const scaleX = maxWidth / img.width;
      const scaleY = maxHeight / img.height;
      const scale = Math.min(scaleX, scaleY);

      // Store original and scaled dimensions
      const scaledWidth = img.width * scale;
      const scaledHeight = img.height * scale;

      // Center image on canvas
      const offsetX = (canvas.width - scaledWidth) / 2;
      const offsetY = (canvas.height - scaledHeight) / 2;

      // Draw initial image
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.drawImage(img, 0, 0, img.width, img.height,
                    offsetX, offsetY, scaledWidth, scaledHeight);

      // Initialize crop region centered and sized to 60% of scaled image
      const defaultCropWidth = scaledWidth * 0.6;
      const defaultCropHeight = scaledHeight * 0.6;
      cropRegion = {
        x: offsetX + (scaledWidth - defaultCropWidth) / 2,
        y: offsetY + (scaledHeight - defaultCropHeight) / 2,
        width: defaultCropWidth,
        height: defaultCropHeight
      };

      renderOverlay();
    };
    img.src = loadEvent.target.result;
  };
  reader.readAsDataURL(selectedFile);
});

Renedring the Cropping Overlay

The overlay consists of four semi-transparent black rectangles surrounding a clear central region, plus a dashed border and draggable corner handles:

function renderOverlay() {
  const { x, y, width, height } = cropRegion;

  // Save state before applying alpha
  ctx.save();
  ctx.globalAlpha = 0.5;
  ctx.fillStyle = '#000';

  // Top mask
  ctx.fillRect(0, 0, canvas.width, y);
  // Bottom mask
  ctx.fillRect(0, y + height, canvas.width, canvas.height - y - height);
  // Left mask
  ctx.fillRect(0, y, x, height);
  // Right mask
  ctx.fillRect(x + width, y, canvas.width - x - width, height);
  ctx.restore();

  // Dashed border
  ctx.strokeStyle = '#3498db';
  ctx.lineWidth = 2;
  ctx.setLineDash([6, 6]);
  ctx.beginPath();
  ctx.rect(x, y, width, height);
  ctx.stroke();
  ctx.setLineDash([]);

  // Corner handles (10×10 squares)
  ctx.fillStyle = '#fff';
  ctx.fillRect(x - 6, y - 6, 12, 12); // top-left
  ctx.fillRect(x + width - 6, y - 6, 12, 12); // top-right
  ctx.fillRect(x + width - 6, y + height - 6, 12, 12); // bottom-right
  ctx.fillRect(x - 6, y + height - 6, 12, 12); // bottom-left
}

Interactive Region Adjustment

Mouse events enable resizing via corners and moving the entire region. A state machine tracks interaction mode (none, move, resize-tl, resize-tr, etc.). For brevity, here's the core movement logic:

let isDragging = false;
let dragOffsetX = 0;
let dragOffsetY = 0;
let activeHandle = null;

canvas.addEventListener('mousedown', (e) => {
  const rect = canvas.getBoundingClientRect();
  const mouseX = e.clientX - rect.left;
  const mouseY = e.clientY - rect.top;

  const { x, y, width, height } = cropRegion;

  // Detect corner handle click
  const tolerance = 10;
  if (Math.abs(mouseX - x) < tolerance && Math.abs(mouseY - y) < tolerance) {
    activeHandle = 'top-left';
  } else if (Math.abs(mouseX - (x + width)) < tolerance && Math.abs(mouseY - y) < tolerance) {
    activeHandle = 'top-right';
  } else if (Math.abs(mouseX - (x + width)) < tolerance && Math.abs(mouseY - (y + height)) < tolerance) {
    activeHandle = 'bottom-right';
  } else if (Math.abs(mouseX - x) < tolerance && Math.abs(mouseY - (y + height)) < tolerance) {
    activeHandle = 'bottom-left';
  } else if (mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height) {
    // Click inside — prepare to drag whole region
    isDragging = true;
    dragOffsetX = mouseX - x;
    dragOffsetY = mouseY - y;
  }
});

canvas.addEventListener('mousemove', (e) => {
  if (!activeHandle && !isDragging) return;

  const rect = canvas.getBoundingClientRect();
  const mouseX = e.clientX - rect.left;
  const mouseY = e.clientY - rect.top;

  if (isDragging) {
    cropRegion.x = Math.max(0, Math.min(mouseX - dragOffsetX, canvas.width - cropRegion.width));
    cropRegion.y = Math.max(0, Math.min(mouseY - dragOffsetY, canvas.height - cropRegion.height));
  } else if (activeHandle) {
    const minSize = 20;
    switch (activeHandle) {
      case 'top-left':
        cropRegion.width = Math.max(minSize, cropRegion.width + (cropRegion.x - mouseX));
        cropRegion.height = Math.max(minSize, cropRegion.height + (cropRegion.y - mouseY));
        cropRegion.x = mouseX;
        cropRegion.y = mouseY;
        break;
      case 'top-right':
        cropRegion.width = Math.max(minSize, mouseX - cropRegion.x);
        cropRegion.height = Math.max(minSize, cropRegion.height + (cropRegion.y - mouseY));
        cropRegion.y = mouseY;
        break;
      case 'bottom-right':
        cropRegion.width = Math.max(minSize, mouseX - cropRegion.x);
        cropRegion.height = Math.max(minSize, mouseY - cropRegion.y);
        break;
      case 'bottom-left':
        cropRegion.width = Math.max(minSize, cropRegion.width + (cropRegion.x - mouseX));
        cropRegion.height = Math.max(minSize, mouseY - cropRegion.y);
        cropRegion.x = mouseX;
        break;
    }
  }

  // Clamp region to canvas bounds
  cropRegion.x = Math.max(0, Math.min(cropRegion.x, canvas.width - cropRegion.width));
  cropRegion.y = Math.max(0, Math.min(cropRegion.y, canvas.height - cropRegion.height));
  cropRegion.width = Math.min(cropRegion.width, canvas.width - cropRegion.x);
  cropRegion.height = Math.min(cropRegion.height, canvas.height - cropRegion.y);

  renderOverlay();
});

canvas.addEventListener('mouseup', () => {
  isDragging = false;
  activeHandle = null;
});

Exporting the Cropped Result

When ready, extract the selected region from the original image using an offscreen canvas:

function exportCroppedImage() {
  const img = new Image();
  img.onload = () => {
    const offscreen = document.createElement('canvas');
    const offCtx = offscreen.getContext('2d');
    
    // Set output size to match crop region
    offscreen.width = cropRegion.width;
    offscreen.height = cropRegion.height;
    
    // Compute source coordinates relative to original image
    const scaleX = img.width / canvas.width;
    const scaleY = img.height / canvas.height;
    
    const srcX = (cropRegion.x - (canvas.width - img.width * scaleX) / 2) * scaleX;
    const srcY = (cropRegion.y - (canvas.height - img.height * scaleY) / 2) * scaleY;
    const srcWidth = cropRegion.width * scaleX;
    const srcHeight = cropRegion.height * scaleY;
    
    offCtx.drawImage(img, srcX, srcY, srcWidth, srcHeight, 0, 0, offscreen.width, offscreen.height);
    
    // Create download link
    const link = document.createElement('a');
    link.download = 'cropped.png';
    link.href = offscreen.toDataURL('image/png');
    link.click();
  };
  img.src = document.querySelector('img').src; // assumes original img is cached
}
``>

Tags: Canvas image-processing cropping HTML5 javascript

Posted on Thu, 07 May 2026 14:03:21 +0000 by suresh1