Source Code
<div class="container py-5 d-flex justify-content-center">
<div class="card bscrop-card">
<div class="card-body p-4">
<label class="form-label small fw-semibold">Choose an image</label>
<input type="file" class="form-control mb-3" id="bscropInput" accept="image/*">
<div class="d-none" id="bscropEditor">
<div class="bscrop-frame" id="bscropFrame">
<img id="bscropImg" draggable="false" alt="">
</div>
<label class="form-label small mt-2 mb-1">Zoom</label>
<input type="range" class="form-range" id="bscropZoom" min="1" max="2.5" step="0.01" value="1">
<button type="button" class="btn btn-dark btn-sm fw-bold mt-2" id="bscropSave">Save crop</button>
</div>
<div class="d-none mt-3" id="bscropResultWrap">
<p class="small fw-semibold mb-1">Cropped result:</p>
<canvas id="bscropCanvas" width="160" height="160" class="bscrop-result"></canvas>
</div>
</div>
</div>
</div>.bscrop-card { width: 340px; max-width: 100%; border: 1px solid #eceef1; border-radius: 14px; }
.bscrop-frame {
position: relative; width: 220px; height: 220px; margin: 0 auto;
overflow: hidden; border-radius: 10px; background: #14151a; cursor: grab; touch-action: none;
}
.bscrop-frame.bscrop-dragging { cursor: grabbing; }
.bscrop-frame img { position: absolute; left: 0; top: 0; user-select: none; }
.bscrop-result { border-radius: 50%; border: 2px solid #e5e7eb; }const FRAME = 220;
const OUTPUT = 160;
const input = document.getElementById('bscropInput');
const editor = document.getElementById('bscropEditor');
const frame = document.getElementById('bscropFrame');
const img = document.getElementById('bscropImg');
const zoomSlider = document.getElementById('bscropZoom');
const resultWrap = document.getElementById('bscropResultWrap');
const canvas = document.getElementById('bscropCanvas');
let baseScale = 1;
let offsetX = 0;
let offsetY = 0;
let dragging = false;
let startX = 0, startY = 0, startOffsetX = 0, startOffsetY = 0;
function currentScale() {
return baseScale * Number(zoomSlider.value);
}
function clampOffsets() {
const scale = currentScale();
const w = img.naturalWidth * scale;
const h = img.naturalHeight * scale;
offsetX = Math.min(0, Math.max(FRAME - w, offsetX));
offsetY = Math.min(0, Math.max(FRAME - h, offsetY));
}
function applyTransform() {
const scale = currentScale();
img.style.width = (img.naturalWidth * scale) + 'px';
img.style.height = (img.naturalHeight * scale) + 'px';
img.style.left = offsetX + 'px';
img.style.top = offsetY + 'px';
}
function centerImage() {
const scale = currentScale();
offsetX = (FRAME - img.naturalWidth * scale) / 2;
offsetY = (FRAME - img.naturalHeight * scale) / 2;
}
input.addEventListener('change', () => {
const file = input.files && input.files[0];
if (!file) return;
const url = URL.createObjectURL(file);
img.onload = () => {
baseScale = Math.max(FRAME / img.naturalWidth, FRAME / img.naturalHeight);
zoomSlider.value = '1';
centerImage();
applyTransform();
editor.classList.remove('d-none');
resultWrap.classList.add('d-none');
URL.revokeObjectURL(url);
};
img.src = url;
});
zoomSlider.addEventListener('input', () => {
clampOffsets();
applyTransform();
});
frame.addEventListener('pointerdown', e => {
dragging = true;
frame.classList.add('bscrop-dragging');
startX = e.clientX; startY = e.clientY;
startOffsetX = offsetX; startOffsetY = offsetY;
frame.setPointerCapture(e.pointerId);
});
frame.addEventListener('pointermove', e => {
if (!dragging) return;
offsetX = startOffsetX + (e.clientX - startX);
offsetY = startOffsetY + (e.clientY - startY);
clampOffsets();
applyTransform();
});
['pointerup', 'pointercancel'].forEach(evt =>
frame.addEventListener(evt, () => { dragging = false; frame.classList.remove('bscrop-dragging'); })
);
document.getElementById('bscropSave').addEventListener('click', () => {
const scale = currentScale();
const srcX = -offsetX / scale;
const srcY = -offsetY / scale;
const srcSize = FRAME / scale;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, OUTPUT, OUTPUT);
ctx.drawImage(img, srcX, srcY, srcSize, srcSize, 0, 0, OUTPUT, OUTPUT);
resultWrap.classList.remove('d-none');
});Bootstrap Image Crop Before Upload — Free HTML CSS JS Snippet
Bootstrap Image Crop Before Upload · Forms · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Bootstrap Image Crop Before Upload — HTML, CSS & JavaScript

The core problem this snippet solves is mapping what's *visually* inside a fixed-size crop frame back to real pixel coordinates in the *original, full-resolution* image — those are two different coordinate systems the moment the image is scaled or panned at all. baseScale starts at whichever ratio makes the image fully cover the 220px frame (Math.max(FRAME / naturalWidth, FRAME / naturalHeight), the same "cover" math CSS's own object-fit: cover uses), and the zoom slider multiplies on top of that base rather than replacing it, so 1.0 on the slider always means "just covering the frame," not "original size."
Dragging updates offsetX/offsetY directly as real pixel positions of the image's top-left corner relative to the frame — not a CSS transform, which would make the reverse-mapping math significantly messier. clampOffsets() runs after every drag and zoom change to guarantee the frame can never show empty space around the image, by constraining each offset between FRAME - scaledSize and 0.
The actual crop happens in Save: since the frame shows a FRAME-pixel window starting at -offsetX, -offsetY in the image's *displayed* pixels, dividing those by the current scale converts them into the image's *natural* pixel coordinates — srcX = -offsetX / scale, srcY = -offsetY / scale, srcSize = FRAME / scale — which is exactly the source rectangle ctx.drawImage() needs to paint precisely what was visible on screen onto the output canvas, at full resolution rather than a blurry re-scale of an already-shrunk preview.
Build with AI
Build, Understand, Optimize, and Extend It With AI
Hand this snippet to an AI coding assistant like Claude and ask it to add a rule-of-thirds grid overlay while dragging for compositional guidance, or to add pinch-to-zoom support on touch devices using two simultaneous pointer events alongside the existing zoom slider.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a Bootstrap 5.3 pan-and-zoom image cropper, using the real Bootstrap CDN framework (bootstrap.min.css and bootstrap.bundle.min.js) for the surrounding UI, with a plain HTML5 canvas for the actual crop output.
Requirements:
- A file input that loads a chosen image into a fixed-size square crop frame (e.g. 220x220px), automatically scaled with a "cover" fit so the image always fully fills the frame with no gaps.
- Dragging inside the frame (using Pointer Events, so it works for both mouse and touch) pans the image, clamped so it can never reveal empty space beyond any edge of the frame.
- A zoom range slider scales the image further while staying properly clamped and centered.
- A "Save crop" button must compute the exact visible region of the frame back into the original image's natural pixel coordinates (accounting for both the current pan offset and zoom scale), and draw that precise region onto an output canvas using drawImage's 9-argument form, so the cropped result is at full resolution rather than a scaled-down copy of the preview.Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.
Step by step
How to Use
- 1Choose an image fileIt loads into a circular-adjacent square frame, automatically scaled to fully cover it.
- 2Drag inside the frameThe image pans under your cursor, clamped so it can never reveal empty space at any edge.
- 3Move the zoom sliderThe image scales up (and re-clamps its position) while staying centered on wherever you last panned to.
- 4Click "Save crop"A circular cropped result appears below, computed from the exact visible region at full image resolution.
- 5Choose a different imageThe editor resets cleanly with fresh scale and centering for the new file.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
The display coordinates are natural-pixel coordinates multiplied by scale; reversing that transform to recover natural coordinates from display coordinates requires dividing by the same scale, not multiplying by it.
Yes — drawImage() always reads from the original, full-resolution <img> element regardless of how small it currently appears on screen, so the crop is never limited by the preview's displayed size the way cropping a screenshot of the frame would be.
Yes. Keep offsetX/offsetY/zoom in component state or refs (refs avoid unnecessary re-renders during a drag), and run the same clamp-and-transform math inside your pointer event handlers.
Give the frame a rectangular size instead of a square one, compute baseScale using that rectangle's width/height against the image's natural dimensions, and adjust srcSize into separate srcWidth/srcHeight values using the frame's actual aspect ratio.