Image Tools
Image Cropper
Crop images to any size or aspect ratio — right in your browser. No upload, no server. Supports JPEG, PNG, WebP output.
Drop image here or click to upload
PNG, JPG, WebP, GIF, BMP — processed locally in your browser
0 × 0
Aspect Ratio
Crop Region (px)
Transform
Output Format
Preview
— × — px
Developer Reference
Core Algorithm & Standalone Script
Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.
(function () {
'use strict';
/* ── STATE ── */
const state = {
img: null,
naturalW: 0, naturalH: 0,
displayW: 0, displayH: 0,
offsetX: 0, offsetY: 0, // img offset within container
scale: 1,
cropX: 0, cropY: 0, cropW: 0, cropH: 0,
ratio: null, // null = free, {w, h}
flipH: false, flipV: false, rotation: 0,
format: 'image/jpeg', quality: 92
};
/* ── ELEMENTS ── */
const uploadArea = document.getElementById('upload-area');
const uploadZone = document.getElementById('upload-zone');
const fileInput = document.getElementById('file-input');
const cropperUI = document.getElementById('cropper-ui');
const imgContainer = document.getElementById('img-container');
const displayImg = document.getElementById('display-img');
const cropBox = document.getElementById('crop-box');
const cropDims = document.getElementById('crop-dims');
const changeBtn = document.getElementById('change-btn');
const inX = document.getElementById('in-x');
const inY = document.getElementById('in-y');
const inW = document.getElementById('in-w');
const inH = document.getElementById('in-h');
const qualitySlider = document.getElementById('quality-slider');
const qualityDisplay = document.getElementById('quality-display');
const previewCanvas = document.getElementById('preview-canvas');
const previewDims = document.getElementById('preview-dims');
const downloadBtn = document.getElementById('download-btn');
const downloadLabel = document.getElementById('download-label');
const downloadSpinner = document.getElementById('download-spinner');
const gridLines = document.querySelectorAll('.grid-line');
/* ── UPLOAD LOGIC ── */
uploadZone.addEventListener('dragover', e => {
e.preventDefault();
uploadZone.classList.add('drag-over');
});
uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('drag-over'));
uploadZone.addEventListener('drop', e => {
e.preventDefault();
uploadZone.classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) loadFile(file);
});
fileInput.addEventListener('change', () => {
if (fileInput.files[0]) loadFile(fileInput.files[0]);
});
changeBtn.addEventListener('click', () => {
uploadArea.classList.remove('hidden');
cropperUI.classList.add('hidden');
fileInput.value = '';
state.img = null;
});
function loadFile(file) {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
state.img = img;
state.naturalW = img.naturalWidth;
state.naturalH = img.naturalHeight;
displayImg.src = url;
uploadArea.classList.add('hidden');
cropperUI.classList.remove('hidden');
// reset transforms
state.flipH = false; state.flipV = false; state.rotation = 0;
updateTransformBtns();
// wait for layout then init
requestAnimationFrame(() => {
requestAnimationFrame(initCropper);
});
};
img.src = url;
}
/* ── INIT CROPPER ── */
function getImgRect() {
const r = displayImg.getBoundingClientRect();
const c = imgContainer.getBoundingClientRect();
return {
left: r.left - c.left,
top: r.top - c.top,
width: r.width,
height: r.height
};
}
function initCropper() {
const rect = getImgRect();
state.displayW = rect.width;
state.displayH = rect.height;
state.offsetX = rect.left;
state.offsetY = rect.top;
state.scale = state.displayW / state.naturalW;
// Initial crop: 80% centered
let cw, ch;
if (state.ratio) {
// fit ratio into 80% of image
const maxW = state.naturalW * 0.8;
const maxH = state.naturalH * 0.8;
const rw = state.ratio.w, rh = state.ratio.h;
if (maxW / rw < maxH / rh) {
cw = maxW;
ch = cw * rh / rw;
} else {
ch = maxH;
cw = ch * rw / rh;
}
} else {
cw = state.naturalW * 0.8;
ch = state.naturalH * 0.8;
}
state.cropW = Math.round(cw);
state.cropH = Math.round(ch);
state.cropX = Math.round((state.naturalW - state.cropW) / 2);
state.cropY = Math.round((state.naturalH - state.cropH) / 2);
applyCropBox();
updateInputs();
schedulePreview();
}
/* ── APPLY CROP BOX POSITION ── */
function applyCropBox() {
const s = state.scale;
const dx = Math.round(state.cropX * s);
const dy = Math.round(state.cropY * s);
const dw = Math.max(10, Math.round(state.cropW * s));
const dh = Math.max(10, Math.round(state.cropH * s));
cropBox.style.left = (state.offsetX + dx) + 'px';
cropBox.style.top = (state.offsetY + dy) + 'px';
cropBox.style.width = dw + 'px';
cropBox.style.height = dh + 'px';
cropDims.textContent = state.cropW + ' × ' + state.cropH;
}
/* ── UPDATE INPUTS ── */
function updateInputs() {
inX.value = Math.round(state.cropX);
inY.value = Math.round(state.cropY);
inW.value = Math.round(state.cropW);
inH.value = Math.round(state.cropH);
}
/* ── INPUT CHANGES ── */
function onInputChange() {
if (!state.img) return;
let x = parseInt(inX.value) || 0;
let y = parseInt(inY.value) || 0;
let w = parseInt(inW.value) || 10;
let h = parseInt(inH.value) || 10;
x = Math.max(0, Math.min(x, state.naturalW - 1));
y = Math.max(0, Math.min(y, state.naturalH - 1));
w = Math.max(10, Math.min(w, state.naturalW - x));
h = Math.max(10, Math.min(h, state.naturalH - y));
if (state.ratio) {
// snap h to ratio
h = Math.round(w * state.ratio.h / state.ratio.w);
h = Math.max(10, Math.min(h, state.naturalH - y));
}
state.cropX = x; state.cropY = y;
state.cropW = w; state.cropH = h;
applyCropBox();
updateInputs();
schedulePreview();
}
[inX, inY, inW, inH].forEach(el => el.addEventListener('input', onInputChange));
/* ── DRAG / RESIZE ── */
let drag = null; // {type:'move'|handle, startX, startY, startCrop}
function clampCrop(cx, cy, cw, ch) {
cw = Math.max(10, cw);
ch = Math.max(10, ch);
cx = Math.max(0, Math.min(cx, state.naturalW - cw));
cy = Math.max(0, Math.min(cy, state.naturalH - ch));
return {cx, cy, cw, ch};
}
function constrainToRatio(cw, ch, handle) {
if (!state.ratio) return {cw, ch};
const rw = state.ratio.w, rh = state.ratio.h;
// For vertical handles use height to derive width, for horizontal use width
if (handle === 'tm' || handle === 'bm') {
cw = Math.round(ch * rw / rh);
} else if (handle === 'ml' || handle === 'mr') {
ch = Math.round(cw * rh / rw);
} else {
// corner: use the larger of the two to determine
const byCw = Math.round(cw * rh / rw);
if (Math.abs(byCw - ch) <= Math.abs(Math.round(ch * rw / rh) - cw)) {
ch = byCw;
} else {
cw = Math.round(ch * rw / rh);
}
}
return {cw, ch};
}
function getClientPos(e) {
if (e.touches && e.touches.length > 0) {
return {x: e.touches[0].clientX, y: e.touches[0].clientY};
}
return {x: e.clientX, y: e.clientY};
}
function onPointerDown(e) {
if (!state.img) return;
const target = e.target;
const handle = target.dataset.handle;
const {x, y} = getClientPos(e);
const sc = {...state}; // snapshot
if (handle) {
e.stopPropagation();
drag = {type: 'handle', handle, startX: x, startY: y, sc};
} else {
drag = {type: 'move', startX: x, startY: y, sc};
}
showGrid(true);
e.preventDefault();
}
function onPointerMove(e) {
if (!drag) return;
const {x, y} = getClientPos(e);
const dx = (x - drag.startX) / state.scale;
const dy = (y - drag.startY) / state.scale;
const sc = drag.sc;
let cx = sc.cropX, cy = sc.cropY, cw = sc.cropW, ch = sc.cropH;
if (drag.type === 'move') {
cx = sc.cropX + dx;
cy = sc.cropY + dy;
} else {
const h = drag.handle;
if (h === 'tl') {
cx = sc.cropX + dx; cy = sc.cropY + dy;
cw = sc.cropW - dx; ch = sc.cropH - dy;
const r = constrainToRatio(Math.max(10, cw), Math.max(10, ch), h);
const dcw = r.cw - cw; const dch = r.ch - ch;
cw = r.cw; ch = r.ch;
cx -= dcw; cy -= dch;
} else if (h === 'tr') {
cy = sc.cropY + dy;
cw = sc.cropW + dx; ch = sc.cropH - dy;
const r = constrainToRatio(Math.max(10, cw), Math.max(10, ch), h);
cy += (ch - r.ch);
cw = r.cw; ch = r.ch;
} else if (h === 'bl') {
cx = sc.cropX + dx;
cw = sc.cropW - dx; ch = sc.cropH + dy;
const r = constrainToRatio(Math.max(10, cw), Math.max(10, ch), h);
cx -= (cw - r.cw);
cw = r.cw; ch = r.ch;
} else if (h === 'br') {
cw = sc.cropW + dx; ch = sc.cropH + dy;
const r = constrainToRatio(Math.max(10, cw), Math.max(10, ch), h);
cw = r.cw; ch = r.ch;
} else if (h === 'tm') {
cy = sc.cropY + dy; ch = sc.cropH - dy;
const r = constrainToRatio(Math.max(10, cw), Math.max(10, ch), h);
cy += (ch - r.ch);
cw = r.cw; ch = r.ch;
cx = sc.cropX + (sc.cropW - cw) / 2;
} else if (h === 'bm') {
ch = sc.cropH + dy;
const r = constrainToRatio(Math.max(10, cw), Math.max(10, ch), h);
cw = r.cw; ch = r.ch;
cx = sc.cropX + (sc.cropW - cw) / 2;
} else if (h === 'ml') {
cx = sc.cropX + dx; cw = sc.cropW - dx;
const r = constrainToRatio(Math.max(10, cw), Math.max(10, ch), h);
cx += (cw - r.cw);
cw = r.cw; ch = r.ch;
cy = sc.cropY + (sc.cropH - ch) / 2;
} else if (h === 'mr') {
cw = sc.cropW + dx;
const r = constrainToRatio(Math.max(10, cw), Math.max(10, ch), h);
cw = r.cw; ch = r.ch;
cy = sc.cropY + (sc.cropH - ch) / 2;
}
cw = Math.max(10, cw);
ch = Math.max(10, ch);
}
const c = clampCrop(Math.round(cx), Math.round(cy), Math.round(cw), Math.round(ch));
state.cropX = c.cx; state.cropY = c.cy;
state.cropW = c.cw; state.cropH = c.ch;
applyCropBox();
updateInputs();
schedulePreview();
e.preventDefault();
}
function onPointerUp() {
if (drag) {
drag = null;
showGrid(false);
}
}
function showGrid(on) {
gridLines.forEach(l => l.classList.toggle('show', on));
}
cropBox.addEventListener('mousedown', onPointerDown);
cropBox.addEventListener('touchstart', onPointerDown, {passive: false});
document.addEventListener('mousemove', onPointerMove);
document.addEventListener('touchmove', onPointerMove, {passive: false});
document.addEventListener('mouseup', onPointerUp);
document.addEventListener('touchend', onPointerUp);
/* ── ASPECT RATIO BUTTONS ── */
document.querySelectorAll('.ratio-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.ratio-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const val = btn.dataset.ratio;
if (val === 'free') {
state.ratio = null;
} else {
const parts = val.split(':');
state.ratio = {w: parseFloat(parts[0]), h: parseFloat(parts[1])};
}
if (state.img) applyCropRatio();
});
});
function applyCropRatio() {
if (!state.ratio) return;
const rw = state.ratio.w, rh = state.ratio.h;
const maxW = state.naturalW;
const maxH = state.naturalH;
let cw, ch;
if (maxW / rw < maxH / rh) {
cw = maxW; ch = Math.round(cw * rh / rw);
} else {
ch = maxH; cw = Math.round(ch * rw / rh);
}
cw = Math.min(cw, maxW);
ch = Math.min(ch, maxH);
state.cropW = cw; state.cropH = ch;
state.cropX = Math.round((maxW - cw) / 2);
state.cropY = Math.round((maxH - ch) / 2);
applyCropBox();
updateInputs();
schedulePreview();
}
/* ── FLIP & ROTATE ── */
document.getElementById('btn-flip-h').addEventListener('click', () => {
state.flipH = !state.flipH;
updateTransformBtns();
schedulePreview();
});
document.getElementById('btn-flip-v').addEventListener('click', () => {
state.flipV = !state.flipV;
updateTransformBtns();
schedulePreview();
});
document.getElementById('btn-rot-cw').addEventListener('click', () => {
state.rotation = (state.rotation + 90) % 360;
updateTransformBtns();
schedulePreview();
});
document.getElementById('btn-rot-ccw').addEventListener('click', () => {
state.rotation = (state.rotation - 90 + 360) % 360;
updateTransformBtns();
schedulePreview();
});
function updateTransformBtns() {
document.getElementById('btn-flip-h').classList.toggle('active', state.flipH);
document.getElementById('btn-flip-v').classList.toggle('active', state.flipV);
}
/* ── FORMAT BUTTONS ── */
document.querySelectorAll('.format-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.format-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state.format = btn.dataset.fmt;
// PNG doesn't have quality
qualitySlider.disabled = state.format === 'image/png';
schedulePreview();
});
});
/* ── QUALITY SLIDER ── */
qualitySlider.addEventListener('input', () => {
state.quality = parseInt(qualitySlider.value);
qualityDisplay.textContent = state.quality;
schedulePreview();
});
/* ── PREVIEW ── */
let previewTimer = null;
function schedulePreview() {
clearTimeout(previewTimer);
previewTimer = setTimeout(renderPreview, 200);
}
function getOutputDimensions() {
let w = state.cropW, h = state.cropH;
if (state.rotation === 90 || state.rotation === 270) {
return {w: h, h: w};
}
return {w, h};
}
function renderPreview() {
if (!state.img) return;
const out = getOutputDimensions();
const maxPrev = 200;
const scale = Math.min(maxPrev / out.w, maxPrev / out.h, 1);
const pw = Math.round(out.w * scale);
const ph = Math.round(out.h * scale);
previewCanvas.width = pw;
previewCanvas.height = ph;
const ctx = previewCanvas.getContext('2d');
ctx.clearRect(0, 0, pw, ph);
ctx.save();
ctx.translate(pw / 2, ph / 2);
if (state.flipH) ctx.scale(-1, 1);
if (state.flipV) ctx.scale(1, -1);
ctx.rotate(state.rotation * Math.PI / 180);
const sw = state.cropW, sh = state.cropH;
// After rotation, the draw origin flips for 90/270
let drawW, drawH;
if (state.rotation === 90 || state.rotation === 270) {
drawW = ph; drawH = pw;
} else {
drawW = pw; drawH = ph;
}
ctx.drawImage(
state.img,
state.cropX, state.cropY, sw, sh,
-drawW / 2, -drawH / 2, drawW, drawH
);
ctx.restore();
previewDims.textContent = out.w + ' × ' + out.h + ' px';
}
/* ── DOWNLOAD ── */
downloadBtn.addEventListener('click', cropAndDownload);
function cropAndDownload() {
if (!state.img) return;
downloadLabel.textContent = 'Processing…';
downloadSpinner.classList.remove('hidden');
downloadBtn.disabled = true;
// Small delay for spinner to appear
setTimeout(() => {
try {
const out = getOutputDimensions();
const canvas = document.createElement('canvas');
canvas.width = out.w;
canvas.height = out.h;
const ctx = canvas.getContext('2d');
ctx.save();
ctx.translate(canvas.width / 2, canvas.height / 2);
if (state.flipH) ctx.scale(-1, 1);
if (state.flipV) ctx.scale(1, -1);
ctx.rotate(state.rotation * Math.PI / 180);
const sw = state.cropW, sh = state.cropH;
let drawW, drawH;
if (state.rotation === 90 || state.rotation === 270) {
drawW = canvas.height; drawH = canvas.width;
} else {
drawW = canvas.width; drawH = canvas.height;
}
ctx.drawImage(
state.img,
state.cropX, state.cropY, sw, sh,
-drawW / 2, -drawH / 2, drawW, drawH
);
ctx.restore();
const q = state.format === 'image/png' ? undefined : state.quality / 100;
canvas.toBlob(blob => {
if (!blob) {
alert('Export failed. Try a different format.');
return;
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const ext = state.format === 'image/jpeg' ? 'jpg'
: state.format === 'image/webp' ? 'webp' : 'png';
a.href = url;
a.download = 'toolpad-crop.' + ext;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, state.format, q);
} finally {
downloadLabel.textContent = 'Crop & Download';
downloadSpinner.classList.add('hidden');
downloadBtn.disabled = false;
}
}, 30);
}
/* ── WINDOW RESIZE ── */
let resizeTimer = null;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (!state.img) return;
const oldDisplayW = state.displayW;
const rect = getImgRect();
state.displayW = rect.width;
state.displayH = rect.height;
state.offsetX = rect.left;
state.offsetY = rect.top;
state.scale = state.displayW / state.naturalW;
// Crop coords in natural pixels stay the same, just re-render
applyCropBox();
}, 100);
});
})();