Image Crop and Edit Before Upload Implementation
Uploading images without client-side processing is a pain for the backend: incorrect aspect ratios for avatars, multi-megabyte originals in the database, mismatched layout requirements. Client-side cropping solves this before sending the file.
Stack and Library Choice
Two main players: Cropper.js (vanilla, ~34 KB gzip) and react-image-crop (React-specific, ~6 KB). For Vue there's vue-cropper. For complex scenarios with filters and text — Fabric.js or Konva.js, but that's overkill for most tasks.
Choice is simple:
- Need React —
react-image-croporreact-cropper(wrapper over Cropper.js) - Need vanilla/jQuery — Cropper.js directly
- Need rotations, filters, text overlay — Cropper.js with extensions
Basic Implementation with react-image-crop
import { useState, useRef, useCallback } from 'react'
import ReactCrop, { Crop, PixelCrop, centerCrop, makeAspectCrop } from 'react-image-crop'
import 'react-image-crop/dist/ReactCrop.css'
function centerAspectCrop(width: number, height: number, aspect: number): Crop {
return centerCrop(
makeAspectCrop({ unit: '%', width: 90 }, aspect, width, height),
width,
height
)
}
export function ImageCropUploader({ aspect = 1, onUpload }: {
aspect?: number
onUpload: (blob: Blob) => Promise<void>
}) {
const [imgSrc, setImgSrc] = useState('')
const [crop, setCrop] = useState<Crop>()
const [completedCrop, setCompletedCrop] = useState<PixelCrop>()
const imgRef = useRef<HTMLImageElement>(null)
function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
if (e.target.files?.[0]) {
const reader = new FileReader()
reader.addEventListener('load', () => setImgSrc(reader.result?.toString() ?? ''))
reader.readAsDataURL(e.target.files[0])
}
}
function onImageLoad(e: React.SyntheticEvent<HTMLImageElement>) {
const { width, height } = e.currentTarget
setCrop(centerAspectCrop(width, height, aspect))
}
const getCroppedBlob = useCallback((): Promise<Blob> => {
const image = imgRef.current
if (!image || !completedCrop) throw new Error('No crop data')
const canvas = document.createElement('canvas')
const scaleX = image.naturalWidth / image.width
const scaleY = image.naturalHeight / image.height
canvas.width = completedCrop.width * scaleX
canvas.height = completedCrop.height * scaleY
const ctx = canvas.getContext('2d')!
ctx.drawImage(
image,
completedCrop.x * scaleX,
completedCrop.y * scaleY,
completedCrop.width * scaleX,
completedCrop.height * scaleY,
0, 0,
canvas.width,
canvas.height
)
return new Promise((resolve, reject) => {
canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('Canvas empty')), 'image/webp', 0.9)
})
}, [completedCrop])
async function handleSubmit() {
const blob = await getCroppedBlob()
await onUpload(blob)
}
return (
<div>
<input type="file" accept="image/*" onChange={onSelectFile} />
{imgSrc && (
<ReactCrop
crop={crop}
onChange={setCrop}
onComplete={setCompletedCrop}
aspect={aspect}
minWidth={100}
>
<img ref={imgRef} src={imgSrc} onLoad={onImageLoad} />
</ReactCrop>
)}
{completedCrop && (
<button onClick={handleSubmit}>Upload</button>
)}
</div>
)
}
Key point — scale through naturalWidth / width. Browser displays the image in reduced view, but you need to crop from the original.
Sending Through FormData to Server
async function uploadCroppedImage(blob: Blob): Promise<void> {
const formData = new FormData()
// Filename with extension — important for mime-type on server
formData.append('avatar', blob, `avatar-${Date.now()}.webp`)
const response = await fetch('/api/users/avatar', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')!.getAttribute('content')! },
body: formData,
})
if (!response.ok) throw new Error(`Upload failed: ${response.status}`)
}
On the Laravel side, the file arrives as a regular $request->file('avatar'). Additional server-side cropping is not needed — dimensions are already correct.
Advanced Scenario: Cropper.js with Rotation and Zoom
<div>
<img id="image" src="/original.jpg">
<button onclick="cropper.rotate(90)">Rotate</button>
<button onclick="cropper.zoom(0.1)">+</button>
<button onclick="cropper.zoom(-0.1)">-</button>
<button onclick="uploadCropped()">Upload</button>
</div>
<script>
const cropper = new Cropper(document.getElementById('image'), {
aspectRatio: 16 / 9,
viewMode: 2, // don't go beyond image bounds
dragMode: 'move',
autoCropArea: 0.8,
restore: false,
guides: true,
center: true,
highlight: false,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: false,
})
async function uploadCropped() {
cropper.getCroppedCanvas({
maxWidth: 1920,
maxHeight: 1080,
imageSmoothingEnabled: true,
imageSmoothingQuality: 'high',
}).toBlob(async (blob) => {
const fd = new FormData()
fd.append('image', blob, 'cover.webp')
await fetch('/api/upload', { method: 'POST', body: fd })
}, 'image/webp', 0.85)
}
</script>
EXIF Orientation Handling
Mobile photos often arrive rotated due to EXIF metadata. For reliability — use exifr library:
import { parse } from 'exifr'
async function fixOrientation(file: File): Promise<string> {
const exif = await parse(file, { orientation: true })
return new Promise(resolve => {
const reader = new FileReader()
reader.onload = (e) => {
const img = new Image()
img.onload = () => {
const canvas = document.createElement('canvas')
const rotation = exif?.Orientation ?? 1
// Apply transformation according to EXIF table
applyExifRotation(canvas, img, rotation)
resolve(canvas.toDataURL())
}
img.src = e.target!.result as string
}
reader.readAsDataURL(file)
})
}
Size Limits Before Crop
Uploading a 20 MB original to crop 200×200 is wasteful. Check before showing crop:
const MAX_SIZE_MB = 10
const MAX_DIMENSION = 4096
function validateImage(file: File): string | null {
if (file.size > MAX_SIZE_MB * 1024 * 1024) {
return `File too large. Maximum ${MAX_SIZE_MB} MB`
}
return null
}
// To check dimensions — only after loading in img
function checkDimensions(img: HTMLImageElement): string | null {
if (img.naturalWidth > MAX_DIMENSION || img.naturalHeight > MAX_DIMENSION) {
return `Image too large. Maximum ${MAX_DIMENSION}px`
}
return null
}
If you need to accept large files — resize to reasonable dimensions before crop via canvas.drawImage with reduced canvas.width/height.
Before/After Preview
function CropPreview({ crop, imgRef }: { crop: PixelCrop; imgRef: React.RefObject<HTMLImageElement> }) {
const previewRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
if (!crop || !imgRef.current || !previewRef.current) return
const img = imgRef.current
const canvas = previewRef.current
const ctx = canvas.getContext('2d')!
const scaleX = img.naturalWidth / img.width
const scaleY = img.naturalHeight / img.height
const pixelRatio = window.devicePixelRatio
canvas.width = 150 * pixelRatio
canvas.height = 150 * pixelRatio
ctx.scale(pixelRatio, pixelRatio)
ctx.drawImage(img,
crop.x * scaleX, crop.y * scaleY,
crop.width * scaleX, crop.height * scaleY,
0, 0, 150, 150
)
}, [crop, imgRef])
return <canvas ref={previewRef} style={{ width: 150, height: 150, borderRadius: '50%' }} />
}
Implementation Timeline
Basic crop integration with upload — 4–6 hours. With rotation, EXIF correction, preview, validation, and responsive modal — 1–2 days. If you need multi-upload with individual crop for each file — a separate task for 3–5 days.







