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

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);
  });

})();