Image Cropper
Pan-and-zoom image cropper on react-easy-crop: rect or round mask, fixed aspect, zoom slider and canvas export via ref.
Example
Installation
npx shadcn@latest add https://hirael.com/r/image-cropper.jsonUsage
import { ImageCropper, ImageCropperZoom } from "@/components/image-cropper"Paths assume the default @/ alias from your components.json. The example above shows the parts composed.
API
<ImageCropper />
+ native element props| Prop | Type | Default |
|---|---|---|
ref | React.Ref<ImageCropperRef> | none |
src* | string | none |
alt | string | none |
aspectWidth over height of the crop area. | number | 1 |
shape | "rect" | "round" | 'rect' |
zoom | number | none |
defaultZoom | number | MIN_ZOOM |
onZoomChange | ((zoom: number) => void) | none |
maxZoom | number | 3 |
crop | Point | none |
defaultCrop | Point | ORIGIN |
onCropChange | ((crop: ImageCropperCrop) => void) | none |
gridRule-of-thirds guides over the crop area. | boolean | false |
disabled | boolean | none |
<ImageCropperZoom />
+ native element propsNo props of its own; forwards everything to the underlying element.
Component source
'use client';
import * as React from 'react';
import Cropper, { type Area, type Point } from 'react-easy-crop';
import { cn } from '@/lib/utils';
import { Slider } from '@/registry/hirael/bases/radix/ui/slider';
import { useControllableState } from '@/registry/hirael/hooks/use-controllable-state';
export type ImageCropperCrop = Point;
interface ImageCropperOutputOptions {
/** Width of the exported image in px; height follows the aspect. Defaults to the source resolution. */
size?: number;
type?: string;
quality?: number;
}
export interface ImageCropperRef {
getCroppedDataUrl: (opts?: ImageCropperOutputOptions) => string | null;
reset: () => void;
}
interface ImageCropperContextValue {
zoom: number;
setZoom: (next: number) => void;
minZoom: number;
maxZoom: number;
disabled?: boolean;
}
const ImageCropperContext = React.createContext<ImageCropperContextValue | null>(null);
const useImageCropper = () => {
const ctx = React.useContext(ImageCropperContext);
if (!ctx) throw new Error('ImageCropper parts must be used inside <ImageCropper>');
return ctx;
};
const MIN_ZOOM = 1;
const ORIGIN: ImageCropperCrop = { x: 0, y: 0 };
export interface ImageCropperProps extends Omit<React.ComponentProps<'div'>, 'ref'> {
ref?: React.Ref<ImageCropperRef>;
src: string;
alt?: string;
/** Width over height of the crop area. */
aspect?: number;
shape?: 'rect' | 'round';
zoom?: number;
defaultZoom?: number;
onZoomChange?: (zoom: number) => void;
maxZoom?: number;
crop?: ImageCropperCrop;
defaultCrop?: ImageCropperCrop;
onCropChange?: (crop: ImageCropperCrop) => void;
/** Rule-of-thirds guides over the crop area. */
grid?: boolean;
disabled?: boolean;
}
const ImageCropper = ({
ref,
src,
alt,
aspect = 1,
shape = 'rect',
zoom: zoomProp,
defaultZoom = MIN_ZOOM,
onZoomChange,
maxZoom = 3,
crop: cropProp,
defaultCrop = ORIGIN,
onCropChange,
grid = false,
disabled,
className,
children,
...props
}: ImageCropperProps) => {
const [zoom, setZoom] = useControllableState({
prop: zoomProp,
defaultProp: defaultZoom,
onChange: onZoomChange,
});
const [crop, setCrop] = useControllableState({
prop: cropProp,
defaultProp: defaultCrop,
onChange: onCropChange,
});
const areaRef = React.useRef<Area | null>(null);
const imageRef = React.useRef<HTMLImageElement | null>(null);
// A CORS-enabled copy to draw from; the cropper's own <img> would taint the export canvas for remote sources.
React.useEffect(() => {
const image = new Image();
image.crossOrigin = 'anonymous';
image.src = src;
imageRef.current = image;
}, [src]);
React.useImperativeHandle(
ref,
() => ({
getCroppedDataUrl: ({ size, type = 'image/png', quality } = {}) => {
const image = imageRef.current;
const area = areaRef.current;
if (!image?.complete || !area) return null;
const scale = size ? size / area.width : 1;
const canvas = document.createElement('canvas');
canvas.width = Math.round(area.width * scale);
canvas.height = Math.round(area.height * scale);
const context = canvas.getContext('2d');
if (!context) return null;
context.drawImage(image, area.x, area.y, area.width, area.height, 0, 0, canvas.width, canvas.height);
try {
return canvas.toDataURL(type, quality);
} catch {
return null;
}
},
reset: () => {
setZoom(defaultZoom);
setCrop(defaultCrop);
},
}),
[setZoom, setCrop, defaultZoom, defaultCrop],
);
const ctx = React.useMemo<ImageCropperContextValue>(
() => ({ zoom, setZoom, minZoom: MIN_ZOOM, maxZoom, disabled }),
[zoom, setZoom, maxZoom, disabled],
);
return (
<ImageCropperContext.Provider value={ctx}>
<div data-slot="image-cropper" className={cn('grid w-full gap-3', className)} {...props}>
<div
data-slot="image-cropper-area"
className={cn(
'relative w-full overflow-hidden rounded-md bg-muted',
disabled && 'pointer-events-none opacity-60',
)}
style={{ aspectRatio: aspect }}
>
<Cropper
image={src}
crop={crop}
zoom={zoom}
aspect={aspect}
cropShape={shape}
showGrid={grid}
minZoom={MIN_ZOOM}
maxZoom={maxZoom}
zoomWithScroll={!disabled}
mediaProps={{ alt }}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={(_, pixels) => {
areaRef.current = pixels;
}}
/>
</div>
{children}
</div>
</ImageCropperContext.Provider>
);
};
type ImageCropperZoomProps = Omit<
React.ComponentProps<typeof Slider>,
'value' | 'defaultValue' | 'min' | 'max' | 'onValueChange'
>;
const ImageCropperZoom = ({ className, ...props }: ImageCropperZoomProps) => {
const ctx = useImageCropper();
return (
<Slider
aria-label="Zoom"
min={ctx.minZoom}
max={ctx.maxZoom}
step={0.01}
value={[ctx.zoom]}
onValueChange={([v]) => {
if (v !== undefined) ctx.setZoom(v);
}}
disabled={ctx.disabled}
data-slot="image-cropper-zoom"
className={cn('w-full', className)}
{...props}
/>
);
};
export { ImageCropper, ImageCropperZoom };
Dependencies
shadcn registry
slideruse-controllable-state
npm
react-easy-crop