Image Cropper
Crop-before-upload control with a draggable, resizable crop rectangle, aspect-ratio locking, zoom, and canvas export at natural image resolution.
<arc-image-cropper> Overview
>
ImageCropper renders an image letterboxed inside a fixed-height stage and overlays a crop rectangle that users drag to reposition and resize via eight handles (four corners, four edges). Everything outside the rectangle is darkened, and rule-of-thirds guides inside the rectangle aid composition. A labeled zoom slider below the stage scales the image around its center (1x-4x) while the crop rectangle stays put, letting users crop into fine detail.
Setting the `aspect` prop (width/height, e.g. `1` for square avatars or `16/9`) locks the rectangle to that ratio through every drag, resize, and keyboard interaction. Leave it at `0` for free-form cropping. The rectangle is always clamped to the visible image and never shrinks below 32px.
The component exposes three methods: `getCrop()` returns `{ x, y, width, height }` in natural image pixel coordinates (letterbox scale and zoom are accounted for precisely); `getCroppedBlob(type, quality)` and `getCroppedDataUrl(type, quality)` draw the crop to an offscreen canvas at natural resolution. Canvas export requires `src` to be same-origin or served with CORS headers — a cross-origin image taints the canvas and the methods throw a descriptive error. The `arc-crop-change` event fires with natural-pixel coordinates, debounced to animation frames during drags.Guidelines
When to use
- Set `aspect="1"` for avatar flows so the exported crop is always square
- Use an object URL (`URL.createObjectURL(file)`) as `src` when cropping a just-picked file from `arc-file-upload`
- Listen for `arc-crop-change` to show a live preview or persist crop coordinates
- Call `getCroppedBlob()` at upload time to send the cropped region at natural resolution
- Serve remote images from the same origin or with CORS headers so canvas export works
When not to use
- Do not pass a cross-origin `src` without CORS headers if you need `getCroppedBlob()` / `getCroppedDataUrl()` — the canvas will be tainted and the methods throw
- Do not read crop coordinates from the rectangle position on screen — always use `getCrop()`, which converts to natural image pixels
- Do not set `height` smaller than ~160px — the crop rectangle and handles need room to operate
- Do not use ImageCropper for simple display-only image framing — use CSS `object-fit` or an aspect-ratio container instead
Features
- Draggable crop rectangle with 8 resize handles (corners + edges) and touch-friendly hit areas
- Aspect-ratio locking via the `aspect` prop, enforced through drag, resize, zoom, and keyboard
- Zoom slider (1x-4x) scales the image around its center under a stationary crop rectangle
- Rule-of-thirds guide lines inside the crop rectangle for composition
- Darkened overlay outside the crop area with the image letterboxed in a fixed-height stage
- `getCrop()` returns natural image pixel coordinates; `getCroppedBlob()` / `getCroppedDataUrl()` export at full resolution
- Full keyboard support: focus the rectangle, arrows move 2px, Shift+arrows resize from the bottom-right
- Position and size announced to screen readers on keyup via a polite live region
- Skeleton shimmer while the image loads; inline error state on load failure
- Crop rectangle clamped to image bounds at all times with a 32px minimum size
Preview
1.00×
Usage
This component requires JavaScript. No pure HTML/CSS version is available — use the Web Component directly or a framework wrapper.
<arc-image-cropper
src="/uploads/avatar-original.jpg"
aspect="1"
height="320"
></arc-image-cropper>
<script>
const cropper = document.querySelector('arc-image-cropper');
cropper.addEventListener('arc-crop-change', (e) => {
console.log('Crop (natural px):', e.detail);
});
// At upload time — natural-resolution export:
async function upload() {
const blob = await cropper.getCroppedBlob('image/jpeg', 0.9);
const body = new FormData();
body.append('avatar', blob, 'avatar.jpg');
await fetch('/api/avatar', { method: 'POST', body });
}
</script> import { useRef } from 'react';
import { ImageCropper } from '@arclux/arc-ui-react';
function AvatarEditor({ src }: { src: string }) {
const ref = useRef<HTMLElementTagNameMap['arc-image-cropper']>(null);
const save = async () => {
const blob = await ref.current!.getCroppedBlob('image/jpeg', 0.9);
// ...upload blob
};
return (
<>
<ImageCropper
ref={ref}
src={src}
aspect={1}
height={320}
onArcCropChange={(e) => console.log(e.detail)}
/>
<button onClick={save}>Save avatar</button>
</>
);
} <script setup>
import { ref } from 'vue';
import { ImageCropper } from '@arclux/arc-ui-vue';
const cropper = ref(null);
async function save() {
const blob = await cropper.value?.getCroppedBlob('image/jpeg', 0.9);
// ...upload blob
}
</script>
<template>
<ImageCropper
ref="cropper"
src="/uploads/avatar-original.jpg"
:aspect="1"
:height="320"
@arc-crop-change="(e) => console.log(e.detail)"
/>
<button @click="save">Save avatar</button>
</template> <script>
import { ImageCropper } from '@arclux/arc-ui-svelte';
let cropper;
async function save() {
const blob = await cropper.getCroppedBlob('image/jpeg', 0.9);
// ...upload blob
}
</script>
<ImageCropper
bind:this={cropper}
src="/uploads/avatar-original.jpg"
aspect={1}
height={320}
on:arc-crop-change={(e) => console.log(e.detail)}
/>
<button on:click={save}>Save avatar</button> import { Component, ElementRef, ViewChild } from '@angular/core';
import { ImageCropper } from '@arclux/arc-ui-angular';
@Component({
imports: [ImageCropper],
template: `
<arc-image-cropper
#cropper
src="/uploads/avatar-original.jpg"
[aspect]="1"
[height]="320"
(arcCropChange)="onCropChange($event)"
></arc-image-cropper>
<button (click)="save()">Save avatar</button>
`,
})
export class AvatarEditorComponent {
@ViewChild('cropper', { read: ElementRef }) cropper!: ElementRef;
onCropChange(e: CustomEvent) {
console.log(e.detail);
}
async save() {
const blob = await this.cropper.nativeElement.getCroppedBlob('image/jpeg', 0.9);
// ...upload blob
}
} API
-
srcstring'' - Image URL, object URL, or data URL to crop. Must be same-origin or CORS-enabled for canvas export.
-
heightnumber320 - Fixed stage height in pixels. The image is letterboxed to fit.
-
aspectnumber0 - Crop aspect ratio as width/height (e.g. `1`, `16/9`). `0` allows free-form cropping.
-
zoomnumber1 - Image zoom factor, clamped to 1-4. Scales the image around its center; also settable via the built-in slider.
Events
-
arc-crop-change - Fired when the crop changes (drag, resize, keyboard, zoom, stage resize). `event.detail` is `{ x, y, width, height }` in natural image pixels, debounced to animation frames.