DEV Community

Joe Lin for BeGoodTool.com

Posted on

The hard part of an image cropper is keeping two coordinate systems from drifting apart

The first time I built an image cropper, I assumed the interesting part would be canvas.drawImage(). It wasn't. The thing that actually kept biting me was much less glamorous: the crop rectangle the user drags lives in display pixels, but the image you export has to be cut from the original source pixels.

If those two coordinate systems drift even a little, the preview looks right but the downloaded file comes out slightly off. That's the part this tool's Vue component is really solving.

The crop box is in CSS pixels, but the output has to use natural pixels

On screen, the crop box is just an absolutely positioned overlay on top of a responsive <img>. The exported image, though, needs source coordinates from the original file. So the component keeps the draggable box in stage-space, then converts it back into source-space with a scale factor:

const scale = computed(() =>
  naturalWidth.value && stageWidth.value ? stageWidth.value / naturalWidth.value : 1
);

const cropNatural = computed(() => {
  const s = scale.value || 1;
  return {
    x: box.x / s,
    y: box.y / s,
    w: box.w / s,
    h: box.h / s,
  };
});
Enter fullscreen mode Exit fullscreen mode

That box object is what the user is dragging around visually. cropNatural is what eventually gets fed into drawImage(). I like this split because it keeps pointer math simple while still exporting the real pixels, not whatever scaled-down version happened to fit the page.

A subtle detail here: the code only uses a single width-based scale factor. That works because the image is rendered with width: 100% and height: auto, so X and Y scale uniformly.

Responsive layouts can quietly break cropping unless you rescale the box too

A cropper inside a flexible layout has another annoying problem: the displayed image can resize after load. Maybe the viewport changes, maybe the right-hand controls wrap, maybe the user rotates a phone. If the crop box stays at its old pixel coordinates, it's now pointing at the wrong part of the image.

This component fixes that with a ResizeObserver plus proportional remapping:

const handleStageResize = (newW, newH) => {
  if (!boxInitialized) {
    stageWidth.value = newW;
    stageHeight.value = newH;
    box.x = 0;
    box.y = 0;
    box.w = newW;
    box.h = newH;
    boxInitialized = true;
  } else {
    const oldW = stageWidth.value;
    const oldH = stageHeight.value;
    if (oldW > 0 && oldH > 0 && (newW !== oldW || newH !== oldH)) {
      const fx = newW / oldW;
      const fy = newH / oldH;
      box.x *= fx;
      box.w *= fx;
      box.y *= fy;
      box.h *= fy;
    }
    stageWidth.value = newW;
    stageHeight.value = newH;
  }
  scheduleRender();
};
Enter fullscreen mode Exit fullscreen mode

This is the kind of code you don't notice when it's present, and immediately notice when it's missing. Instead of resetting the selection on resize, it preserves the same relative crop region. That's a much better feel on mobile and on responsive pages.

Aspect-ratio-locked resize handles are much branchier than they look

Freeform dragging is easy. Ratio-locked dragging is where the cropper stops being a rectangle and starts becoming geometry homework.

The code handles edges and corners differently:

if (ratio) {
  let newW = right - left;
  let newH = bottom - top;
  if (handle === "n" || handle === "s") {
    newW = newH * ratio;
    const cx = x + w / 2;
    left = cx - newW / 2;
    right = cx + newW / 2;
  } else if (handle === "e" || handle === "w") {
    newH = newW / ratio;
    const cy = y + h / 2;
    top = cy - newH / 2;
    bottom = cy + newH / 2;
  } else {
    newH = newW / ratio;
    if (handle.includes("n")) top = bottom - newH;
    else bottom = top + newH;
  }
}
Enter fullscreen mode Exit fullscreen mode

That split is doing real UI work:

  • dragging top/bottom keeps the horizontal center stable
  • dragging left/right keeps the vertical center stable
  • dragging a corner treats one corner as the anchor and derives the other dimension from the ratio

Without those branches, ratio presets like 1:1, 16:9, or the 35×45 ID-photo preset feel weird immediately, because the box "slides" while you resize it.

Preview and export share the same pipeline, which keeps the tool honest

The live preview canvas isn't using some approximation. It's rendering the same crop coordinates and target dimensions that the download step uses:

function scheduleRender() {
  if (rafId) return;
  rafId = requestAnimationFrame(() => {
    rafId = null;
    renderPreviewNow();
  });
}

function renderPreviewNow() {
  const w = Math.max(1, Math.round(targetWidth.value));
  const h = Math.max(1, Math.round(targetHeight.value));
  canvas.width = w;
  canvas.height = h;
  const ctx = canvas.getContext("2d");
  if (format.value === "jpeg") {
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, w, h);
  }
  const cn = cropNatural.value;
  ctx.drawImage(imgEl.value, cn.x, cn.y, cn.w, cn.h, 0, 0, w, h);
}
Enter fullscreen mode Exit fullscreen mode

Two nice implementation details here:

First, preview rendering is throttled with requestAnimationFrame(), which matters because pointermove can fire a lot while you're dragging handles around.

Second, JPEG output gets a white background fill before drawing. That's important because JPEG has no alpha channel; without the fill, transparent areas would be flattened unpredictably.

Where this still has sharp edges

The big technical assumption is that one scale factor is enough. Right now that's valid because the displayed image uses width: 100% and height: auto. If the UI ever changed to letterbox the image, apply transforms, or otherwise scale X and Y differently, box.x / scale would stop mapping cleanly back to source pixels.

There's also no explicit EXIF-orientation normalization in this component. In practice that's usually fine because browsers decode most phone photos the way users expect, but the cropper is trusting the browser's interpretation rather than normalizing image data itself.

I turned that implementation into a small free tool if you want to try it without wiring up the pointer math yourself: Image Cropper & Resizer.


Available in other languages

Top comments (0)