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,
};
});
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();
};
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;
}
}
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);
}
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
- 圖片裁切/縮圖尺寸調整工具 — 繁體中文
- 图片裁切/缩图尺寸调整工具 — 简体中文
- Image Cropper & Resizer — English
- 画像トリミング&リサイズツール — 日本語
- 이미지 자르기 및 크기 조절 도구 — 한국어
- Recadrage & Redimensionnement d'Image — Français
- Обрезка и изменение размера изображений — Русский
- Bild zuschneiden & Größe ändern — Deutsch
- Alat Crop & Resize Gambar — Bahasa Indonesia
- Recortar y Redimensionar Imágenes — Español
- Công Cụ Cắt & Đổi Kích Thước Ảnh — Tiếng Việt
- เครื่องมือครอปและปรับขนาดรูปภาพ — ไทย
- Przycinanie i Zmiana Rozmiaru Obrazu — Polski
- Görsel Kırpma ve Boyutlandırma Aracı — Türkçe
- Ritaglia e Ridimensiona Immagini — Italiano
- Recortar e Redimensionar Imagens — Português
- Afbeelding Bijsnijden & Formaat Wijzigen — Nederlands
- Обрізка та зміна розміру зображень — Українська
Top comments (0)