"use client";
import * as React from "react";
import {
Cropper,
CropperArea,
CropperImage,
type CropperPoint,
} from "@/components/ui/cropper";
export function CropperDemo() {
const [crop, setCrop] = React.useState<CropperPoint>({ x: 0, y: 0 });
const [zoom, setZoom] = React.useState(1);
return (
<Cropper
aspectRatio={1}
crop={crop}
zoom={zoom}
onCropChange={setCrop}
onZoomChange={setZoom}
className="min-h-72"
>
<CropperImage
src="https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=1920&h=1080&fit=crop&auto=format&fm=webp&q=80"
alt="Profile picture"
crossOrigin="anonymous"
/>
<CropperArea />
</Cropper>
);
}
Installation
CLI
npx shadcn@latest add "https://diceui.com/r/cropper"
Manual
Install the following dependencies:
npm install @radix-ui/react-slot class-variance-authority
Copy the refs composition utilities into your lib/compose-refs.ts
file.
/**
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
*/
import * as React from "react";
type PossibleRef<T> = React.Ref<T> | undefined;
/**
* Set a given ref to a given value
* This utility takes care of different types of refs: callback refs and RefObject(s)
*/
function setRef<T>(ref: PossibleRef<T>, value: T) {
if (typeof ref === "function") {
return ref(value);
}
if (ref !== null && ref !== undefined) {
ref.current = value;
}
}
/**
* A utility to compose multiple refs together
* Accepts callback refs and RefObject(s)
*/
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
return (node) => {
let hasCleanup = false;
const cleanups = refs.map((ref) => {
const cleanup = setRef(ref, node);
if (!hasCleanup && typeof cleanup === "function") {
hasCleanup = true;
}
return cleanup;
});
// React <19 will log an error to the console if a callback ref returns a
// value. We don't use ref cleanups internally so this will only happen if a
// user's ref callback returns a value, which we only expect if they are
// using the cleanup functionality added in React 19.
if (hasCleanup) {
return () => {
for (let i = 0; i < cleanups.length; i++) {
const cleanup = cleanups[i];
if (typeof cleanup === "function") {
cleanup();
} else {
setRef(refs[i], null);
}
}
};
}
};
}
/**
* A custom hook that composes multiple refs
* Accepts callback refs and RefObject(s)
*/
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
return React.useCallback(composeRefs(...refs), refs);
}
export { composeRefs, useComposedRefs };
Copy and paste the following code into your project.
"use client";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
const ROOT_NAME = "Cropper";
const CONTENT_NAME = "CropperContent";
const IMAGE_NAME = "CropperImage";
const VIDEO_NAME = "CropperVideo";
const AREA_NAME = "CropperArea";
interface Point {
x: number;
y: number;
}
interface GestureEvent extends UIEvent {
rotation: number;
scale: number;
clientX: number;
clientY: number;
}
interface Size {
width: number;
height: number;
}
interface Area {
width: number;
height: number;
x: number;
y: number;
}
interface MediaSize {
width: number;
height: number;
naturalWidth: number;
naturalHeight: number;
}
type Shape = "rectangular" | "circular";
type ObjectFit = "contain" | "cover" | "horizontal-cover" | "vertical-cover";
interface DivProps extends React.ComponentProps<"div"> {
asChild?: boolean;
}
const MAX_CACHE_SIZE = 200;
const DPR = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
const rotationSizeCache = new Map<string, Size>();
const cropSizeCache = new Map<string, Size>();
const croppedAreaCache = new Map<
string,
{ croppedAreaPercentages: Area; croppedAreaPixels: Area }
>();
const onPositionClampCache = new Map<string, Point>();
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function quantize(n: number, step = 2 / DPR): number {
return Math.round(n / step) * step;
}
function quantizePosition(n: number, step = 4 / DPR): number {
return Math.round(n / step) * step;
}
function quantizeZoom(n: number, step = 0.01): number {
return Math.round(n / step) * step;
}
function quantizeRotation(n: number, step = 1.0): number {
return Math.round(n / step) * step;
}
function snapToDevicePixel(n: number): number {
return Math.round(n * DPR) / DPR;
}
function lruGet<K, V>(map: Map<K, V>, key: K): V | undefined {
const v = map.get(key);
if (v !== undefined) {
map.delete(key);
map.set(key, v);
}
return v;
}
function lruSet<K, V>(
map: Map<K, V>,
key: K,
val: V,
max = MAX_CACHE_SIZE,
): void {
if (map.has(key)) {
map.delete(key);
}
map.set(key, val);
if (map.size > max) {
const firstKey = map.keys().next().value;
if (firstKey !== undefined) {
map.delete(firstKey);
}
}
}
function getDistanceBetweenPoints(pointA: Point, pointB: Point): number {
return Math.sqrt((pointA.y - pointB.y) ** 2 + (pointA.x - pointB.x) ** 2);
}
function getCenter(a: Point, b: Point): Point {
return {
x: (b.x + a.x) * 0.5,
y: (b.y + a.y) * 0.5,
};
}
function getRotationBetweenPoints(pointA: Point, pointB: Point): number {
return (Math.atan2(pointB.y - pointA.y, pointB.x - pointA.x) * 180) / Math.PI;
}
function getRadianAngle(degreeValue: number): number {
return (degreeValue * Math.PI) / 180;
}
function rotateSize(width: number, height: number, rotation: number): Size {
const cacheKey = `${quantize(width)}-${quantize(height)}-${quantizeRotation(rotation)}`;
const cached = lruGet(rotationSizeCache, cacheKey);
if (cached) {
return cached;
}
const rotRad = getRadianAngle(rotation);
const cosRot = Math.cos(rotRad);
const sinRot = Math.sin(rotRad);
const result: Size = {
width: Math.abs(cosRot * width) + Math.abs(sinRot * height),
height: Math.abs(sinRot * width) + Math.abs(cosRot * height),
};
lruSet(rotationSizeCache, cacheKey, result, MAX_CACHE_SIZE);
return result;
}
function getCropSize(
mediaWidth: number,
mediaHeight: number,
contentWidth: number,
contentHeight: number,
aspect: number,
rotation = 0,
): Size {
const cacheKey = `${quantize(mediaWidth, 8)}-${quantize(mediaHeight, 8)}-${quantize(contentWidth, 8)}-${quantize(contentHeight, 8)}-${quantize(aspect, 0.01)}-${quantizeRotation(rotation)}`;
const cached = lruGet(cropSizeCache, cacheKey);
if (cached) {
return cached;
}
const { width, height } = rotateSize(mediaWidth, mediaHeight, rotation);
const fittingWidth = Math.min(width, contentWidth);
const fittingHeight = Math.min(height, contentHeight);
const result: Size =
fittingWidth > fittingHeight * aspect
? {
width: fittingHeight * aspect,
height: fittingHeight,
}
: {
width: fittingWidth,
height: fittingWidth / aspect,
};
lruSet(cropSizeCache, cacheKey, result, MAX_CACHE_SIZE);
return result;
}
function onPositionClamp(
position: Point,
mediaSize: Size,
cropSize: Size,
zoom: number,
rotation = 0,
): Point {
const quantizedX = quantizePosition(position.x);
const quantizedY = quantizePosition(position.y);
const cacheKey = `${quantizedX}-${quantizedY}-${quantize(mediaSize.width)}-${quantize(mediaSize.height)}-${quantize(cropSize.width)}-${quantize(cropSize.height)}-${quantizeZoom(zoom)}-${quantizeRotation(rotation)}`;
const cached = lruGet(onPositionClampCache, cacheKey);
if (cached) {
return cached;
}
const { width, height } = rotateSize(
mediaSize.width,
mediaSize.height,
rotation,
);
const maxPositionX = width * zoom * 0.5 - cropSize.width * 0.5;
const maxPositionY = height * zoom * 0.5 - cropSize.height * 0.5;
const result: Point = {
x: clamp(position.x, -maxPositionX, maxPositionX),
y: clamp(position.y, -maxPositionY, maxPositionY),
};
lruSet(onPositionClampCache, cacheKey, result, MAX_CACHE_SIZE);
return result;
}
function getCroppedArea(
crop: Point,
mediaSize: MediaSize,
cropSize: Size,
aspect: number,
zoom: number,
rotation = 0,
allowOverflow = false,
): { croppedAreaPercentages: Area; croppedAreaPixels: Area } {
const cacheKey = `${quantizePosition(crop.x)}-${quantizePosition(crop.y)}-${quantize(mediaSize.width)}-${quantize(mediaSize.height)}-${quantize(mediaSize.naturalWidth)}-${quantize(mediaSize.naturalHeight)}-${quantize(cropSize.width)}-${quantize(cropSize.height)}-${quantize(aspect, 0.01)}-${quantizeZoom(zoom)}-${quantizeRotation(rotation)}-${allowOverflow}`;
const cached = lruGet(croppedAreaCache, cacheKey);
if (cached) return cached;
const onAreaLimit = !allowOverflow
? (max: number, value: number) => Math.min(max, Math.max(0, value))
: (_max: number, value: number) => value;
const mediaBBoxSize = rotateSize(mediaSize.width, mediaSize.height, rotation);
const mediaNaturalBBoxSize = rotateSize(
mediaSize.naturalWidth,
mediaSize.naturalHeight,
rotation,
);
const croppedAreaPercentages: Area = {
x: onAreaLimit(
100,
(((mediaBBoxSize.width - cropSize.width / zoom) / 2 - crop.x / zoom) /
mediaBBoxSize.width) *
100,
),
y: onAreaLimit(
100,
(((mediaBBoxSize.height - cropSize.height / zoom) / 2 - crop.y / zoom) /
mediaBBoxSize.height) *
100,
),
width: onAreaLimit(
100,
((cropSize.width / mediaBBoxSize.width) * 100) / zoom,
),
height: onAreaLimit(
100,
((cropSize.height / mediaBBoxSize.height) * 100) / zoom,
),
};
const widthInPixels = Math.round(
onAreaLimit(
mediaNaturalBBoxSize.width,
(croppedAreaPercentages.width * mediaNaturalBBoxSize.width) / 100,
),
);
const heightInPixels = Math.round(
onAreaLimit(
mediaNaturalBBoxSize.height,
(croppedAreaPercentages.height * mediaNaturalBBoxSize.height) / 100,
),
);
const isImageWiderThanHigh =
mediaNaturalBBoxSize.width >= mediaNaturalBBoxSize.height * aspect;
const sizePixels: Size = isImageWiderThanHigh
? {
width: Math.round(heightInPixels * aspect),
height: heightInPixels,
}
: {
width: widthInPixels,
height: Math.round(widthInPixels / aspect),
};
const croppedAreaPixels: Area = {
...sizePixels,
x: Math.round(
onAreaLimit(
mediaNaturalBBoxSize.width - sizePixels.width,
(croppedAreaPercentages.x * mediaNaturalBBoxSize.width) / 100,
),
),
y: Math.round(
onAreaLimit(
mediaNaturalBBoxSize.height - sizePixels.height,
(croppedAreaPercentages.y * mediaNaturalBBoxSize.height) / 100,
),
),
};
const result = { croppedAreaPercentages, croppedAreaPixels };
lruSet(croppedAreaCache, cacheKey, result, MAX_CACHE_SIZE);
return result;
}
function useLazyRef<T>(fn: () => T) {
const ref = React.useRef<T | null>(null);
if (ref.current === null) {
ref.current = fn();
}
return ref as React.RefObject<T>;
}
interface StoreState {
crop: Point;
zoom: number;
rotation: number;
mediaSize: MediaSize | null;
cropSize: Size | null;
isDragging: boolean;
isWheelZooming: boolean;
}
interface Store {
subscribe: (callback: () => void) => () => void;
getState: () => StoreState;
setState: <K extends keyof StoreState>(key: K, value: StoreState[K]) => void;
notify: () => void;
batch: (fn: () => void) => void;
}
function createStore(
listenersRef: React.RefObject<Set<() => void>>,
stateRef: React.RefObject<StoreState>,
aspectRatio: number,
onCropChange?: (crop: Point) => void,
onCropSizeChange?: (cropSize: Size) => void,
onCropAreaChange?: (croppedArea: Area, croppedAreaPixels: Area) => void,
onCropComplete?: (croppedArea: Area, croppedAreaPixels: Area) => void,
onZoomChange?: (zoom: number) => void,
onRotationChange?: (rotation: number) => void,
onMediaLoaded?: (mediaSize: MediaSize) => void,
onInteractionStart?: () => void,
onInteractionEnd?: () => void,
): Store {
let isBatching = false;
let raf: number | null = null;
function notifyCropAreaChange() {
if (raf != null) return;
raf = requestAnimationFrame(() => {
raf = null;
const s = stateRef.current;
if (s?.mediaSize && s.cropSize && onCropAreaChange) {
const { croppedAreaPercentages, croppedAreaPixels } = getCroppedArea(
s.crop,
s.mediaSize,
s.cropSize,
aspectRatio,
s.zoom,
s.rotation,
);
onCropAreaChange(croppedAreaPercentages, croppedAreaPixels);
}
});
}
const store: Store = {
subscribe: (cb) => {
if (listenersRef.current) {
listenersRef.current.add(cb);
return () => listenersRef.current?.delete(cb);
}
return () => {};
},
getState: () =>
stateRef.current ?? {
crop: { x: 0, y: 0 },
zoom: 1,
rotation: 0,
mediaSize: null,
cropSize: null,
isDragging: false,
isWheelZooming: false,
},
setState: (key, value) => {
const state = stateRef.current;
if (!state || Object.is(state[key], value)) return;
state[key] = value;
if (
key === "crop" &&
typeof value === "object" &&
value &&
"x" in value
) {
onCropChange?.(value);
} else if (key === "zoom" && typeof value === "number") {
onZoomChange?.(value);
} else if (key === "rotation" && typeof value === "number") {
onRotationChange?.(value);
} else if (
key === "cropSize" &&
typeof value === "object" &&
value &&
"width" in value
) {
onCropSizeChange?.(value);
} else if (
key === "mediaSize" &&
typeof value === "object" &&
value &&
"naturalWidth" in value
) {
onMediaLoaded?.(value);
} else if (key === "isDragging") {
if (value) {
onInteractionStart?.();
} else {
onInteractionEnd?.();
const currentState = stateRef.current;
if (
currentState?.mediaSize &&
currentState.cropSize &&
onCropComplete
) {
const { croppedAreaPercentages, croppedAreaPixels } =
getCroppedArea(
currentState.crop,
currentState.mediaSize,
currentState.cropSize,
aspectRatio,
currentState.zoom,
currentState.rotation,
);
onCropComplete(croppedAreaPercentages, croppedAreaPixels);
}
}
}
if (
(key === "crop" ||
key === "zoom" ||
key === "rotation" ||
key === "mediaSize" ||
key === "cropSize") &&
onCropAreaChange
) {
notifyCropAreaChange();
}
if (!isBatching) {
store.notify();
}
},
notify: () => {
if (listenersRef.current) {
for (const cb of listenersRef.current) {
cb();
}
}
},
batch: (fn: () => void) => {
if (isBatching) {
fn();
return;
}
isBatching = true;
try {
fn();
} finally {
isBatching = false;
store.notify();
}
},
};
return store;
}
const StoreContext = React.createContext<Store | null>(null);
function useStoreContext(consumerName: string) {
const context = React.useContext(StoreContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
function useStore<T>(selector: (state: StoreState) => T): T {
const store = useStoreContext("useStore");
const getSnapshot = React.useCallback(
() => selector(store.getState()),
[store, selector],
);
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
type RootElement = React.ComponentRef<typeof CropperRootImpl>;
interface CropperContextValue {
id: string;
aspectRatio: number;
minZoom: number;
maxZoom: number;
zoomSpeed: number;
keyboardStep: number;
shape: Shape;
objectFit: ObjectFit;
rootRef: React.RefObject<RootElement | null>;
allowOverflow: boolean;
preventScrollZoom: boolean;
withGrid: boolean;
}
const CropperContext = React.createContext<CropperContextValue | null>(null);
function useCropperContext(consumerName: string) {
const context = React.useContext(CropperContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
interface CropperRootProps extends DivProps {
crop?: Point;
zoom?: number;
minZoom?: number;
maxZoom?: number;
zoomSpeed?: number;
rotation?: number;
keyboardStep?: number;
aspectRatio?: number;
shape?: Shape;
objectFit?: ObjectFit;
allowOverflow?: boolean;
preventScrollZoom?: boolean;
withGrid?: boolean;
onCropChange?: (crop: Point) => void;
onCropSizeChange?: (cropSize: Size) => void;
onCropAreaChange?: (croppedArea: Area, croppedAreaPixels: Area) => void;
onCropComplete?: (croppedArea: Area, croppedAreaPixels: Area) => void;
onZoomChange?: (zoom: number) => void;
onRotationChange?: (rotation: number) => void;
onMediaLoaded?: (mediaSize: MediaSize) => void;
onInteractionStart?: () => void;
onInteractionEnd?: () => void;
onWheelZoom?: (event: WheelEvent) => void;
}
function CropperRoot(props: CropperRootProps) {
const {
crop = { x: 0, y: 0 },
zoom = 1,
minZoom = 1,
maxZoom = 3,
zoomSpeed = 1,
rotation = 0,
keyboardStep = 1,
aspectRatio = 4 / 3,
shape = "rectangular",
objectFit = "contain",
allowOverflow = false,
preventScrollZoom = false,
withGrid = false,
onCropChange,
onCropSizeChange,
onCropAreaChange,
onCropComplete,
onZoomChange,
onRotationChange,
onMediaLoaded,
onInteractionStart,
onInteractionEnd,
id: idProp,
className,
...rootProps
} = props;
const listenersRef = useLazyRef(() => new Set<() => void>());
const stateRef = useLazyRef<StoreState>(() => ({
crop,
zoom,
rotation,
mediaSize: null,
cropSize: null,
isDragging: false,
isWheelZooming: false,
}));
const rootRef = React.useRef<RootElement>(null);
const store = React.useMemo(
() =>
createStore(
listenersRef,
stateRef,
aspectRatio,
onCropChange,
onCropSizeChange,
onCropAreaChange,
onCropComplete,
onZoomChange,
onRotationChange,
onMediaLoaded,
onInteractionStart,
onInteractionEnd,
),
[
listenersRef,
stateRef,
aspectRatio,
onCropChange,
onCropSizeChange,
onCropAreaChange,
onCropComplete,
onZoomChange,
onRotationChange,
onMediaLoaded,
onInteractionStart,
onInteractionEnd,
],
);
React.useEffect(() => {
const updates: Partial<StoreState> = {};
let hasUpdates = false;
let shouldRecompute = false;
if (crop !== undefined) {
const currentState = store.getState();
if (!Object.is(currentState.crop, crop)) {
updates.crop = crop;
hasUpdates = true;
}
}
if (zoom !== undefined) {
const currentState = store.getState();
if (currentState.zoom !== zoom) {
updates.zoom = zoom;
hasUpdates = true;
shouldRecompute = true;
}
}
if (rotation !== undefined) {
const currentState = store.getState();
if (currentState.rotation !== rotation) {
updates.rotation = rotation;
hasUpdates = true;
shouldRecompute = true;
}
}
if (hasUpdates) {
store.batch(() => {
Object.entries(updates).forEach(([key, value]) => {
store.setState(key as keyof StoreState, value);
});
});
if (shouldRecompute && rootRef.current) {
requestAnimationFrame(() => {
const currentState = store.getState();
if (currentState.cropSize && currentState.mediaSize) {
const newPosition = !allowOverflow
? onPositionClamp(
currentState.crop,
currentState.mediaSize,
currentState.cropSize,
currentState.zoom,
currentState.rotation,
)
: currentState.crop;
if (
Math.abs(newPosition.x - currentState.crop.x) > 0.001 ||
Math.abs(newPosition.y - currentState.crop.y) > 0.001
) {
store.setState("crop", newPosition);
}
}
});
}
}
}, [crop, zoom, rotation, store, allowOverflow]);
const id = React.useId();
const rootId = idProp ?? id;
const contextValue = React.useMemo<CropperContextValue>(
() => ({
id: rootId,
minZoom,
maxZoom,
zoomSpeed,
keyboardStep,
aspectRatio,
shape,
objectFit,
preventScrollZoom,
allowOverflow,
withGrid,
rootRef,
}),
[
rootId,
minZoom,
maxZoom,
zoomSpeed,
keyboardStep,
aspectRatio,
shape,
objectFit,
preventScrollZoom,
allowOverflow,
withGrid,
],
);
return (
<StoreContext.Provider value={store}>
<CropperContext.Provider value={contextValue}>
<div
data-slot="cropper-wrapper"
className={cn("relative size-full overflow-hidden", className)}
>
<CropperRootImpl id={rootId} {...rootProps} />
</div>
</CropperContext.Provider>
</StoreContext.Provider>
);
}
interface CropperRootImplProps extends CropperRootProps {
onWheelZoom?: (event: WheelEvent) => void;
}
function CropperRootImpl(props: CropperRootImplProps) {
const { className, asChild, ref, ...contentProps } = props;
const context = useCropperContext(CONTENT_NAME);
const store = useStoreContext(CONTENT_NAME);
const crop = useStore((state) => state.crop);
const zoom = useStore((state) => state.zoom);
const rotation = useStore((state) => state.rotation);
const mediaSize = useStore((state) => state.mediaSize);
const cropSize = useStore((state) => state.cropSize);
const composedRef = useComposedRefs(ref, context.rootRef);
const dragStartPositionRef = React.useRef<Point>({ x: 0, y: 0 });
const dragStartCropRef = React.useRef<Point>({ x: 0, y: 0 });
const contentPositionRef = React.useRef<Point>({ x: 0, y: 0 });
const lastPinchDistanceRef = React.useRef(0);
const lastPinchRotationRef = React.useRef(0);
const rafDragTimeoutRef = React.useRef<number | null>(null);
const rafPinchTimeoutRef = React.useRef<number | null>(null);
const wheelTimerRef = React.useRef<number | null>(null);
const isTouchingRef = React.useRef(false);
const gestureZoomStartRef = React.useRef(0);
const gestureRotationStartRef = React.useRef(0);
const onRefsCleanup = React.useCallback(() => {
if (rafDragTimeoutRef.current) {
cancelAnimationFrame(rafDragTimeoutRef.current);
rafDragTimeoutRef.current = null;
}
if (rafPinchTimeoutRef.current) {
cancelAnimationFrame(rafPinchTimeoutRef.current);
rafPinchTimeoutRef.current = null;
}
if (wheelTimerRef.current) {
clearTimeout(wheelTimerRef.current);
wheelTimerRef.current = null;
}
isTouchingRef.current = false;
}, []);
const onCachesCleanup = React.useCallback(() => {
if (onPositionClampCache.size > MAX_CACHE_SIZE * 1.5) {
onPositionClampCache.clear();
}
if (croppedAreaCache.size > MAX_CACHE_SIZE * 1.5) {
croppedAreaCache.clear();
}
}, []);
const getMousePoint = React.useCallback(
(event: MouseEvent | React.MouseEvent) => ({
x: Number(event.clientX),
y: Number(event.clientY),
}),
[],
);
const getTouchPoint = React.useCallback(
(touch: Touch | React.Touch) => ({
x: Number(touch.clientX),
y: Number(touch.clientY),
}),
[],
);
const onContentPositionChange = React.useCallback(() => {
if (context.rootRef?.current) {
const bounds = context.rootRef.current.getBoundingClientRect();
contentPositionRef.current = { x: bounds.left, y: bounds.top };
}
}, [context.rootRef]);
const getPointOnContent = React.useCallback(
({ x, y }: Point, contentTopLeft: Point): Point => {
if (!context.rootRef?.current) {
return { x: 0, y: 0 };
}
const contentRect = context.rootRef.current.getBoundingClientRect();
return {
x: contentRect.width / 2 - (x - contentTopLeft.x),
y: contentRect.height / 2 - (y - contentTopLeft.y),
};
},
[context.rootRef],
);
const getPointOnMedia = React.useCallback(
({ x, y }: Point) => {
return {
x: (x + crop.x) / zoom,
y: (y + crop.y) / zoom,
};
},
[crop, zoom],
);
const recomputeCropPosition = React.useCallback(() => {
if (!cropSize || !mediaSize) return;
const newPosition = !context.allowOverflow
? onPositionClamp(crop, mediaSize, cropSize, zoom, rotation)
: crop;
if (
Math.abs(newPosition.x - crop.x) > 0.001 ||
Math.abs(newPosition.y - crop.y) > 0.001
) {
store.setState("crop", newPosition);
}
}, [cropSize, mediaSize, context.allowOverflow, crop, zoom, rotation, store]);
const onZoomChange = React.useCallback(
(newZoom: number, point: Point, shouldUpdatePosition = true) => {
if (!cropSize || !mediaSize) return;
const clampedZoom = clamp(newZoom, context.minZoom, context.maxZoom);
store.batch(() => {
if (shouldUpdatePosition) {
const zoomPoint = getPointOnContent(
point,
contentPositionRef.current,
);
const zoomTarget = getPointOnMedia(zoomPoint);
const requestedPosition = {
x: zoomTarget.x * clampedZoom - zoomPoint.x,
y: zoomTarget.y * clampedZoom - zoomPoint.y,
};
const newPosition = !context.allowOverflow
? onPositionClamp(
requestedPosition,
mediaSize,
cropSize,
clampedZoom,
rotation,
)
: requestedPosition;
store.setState("crop", newPosition);
}
store.setState("zoom", clampedZoom);
});
requestAnimationFrame(() => {
recomputeCropPosition();
});
},
[
cropSize,
mediaSize,
context.minZoom,
context.maxZoom,
context.allowOverflow,
getPointOnContent,
getPointOnMedia,
rotation,
store,
recomputeCropPosition,
],
);
const onDragStart = React.useCallback(
({ x, y }: Point) => {
dragStartPositionRef.current = { x, y };
dragStartCropRef.current = { ...crop };
store.setState("isDragging", true);
},
[crop, store],
);
const onDrag = React.useCallback(
({ x, y }: Point) => {
if (rafDragTimeoutRef.current) {
cancelAnimationFrame(rafDragTimeoutRef.current);
}
rafDragTimeoutRef.current = requestAnimationFrame(() => {
if (!cropSize || !mediaSize) return;
if (x === undefined || y === undefined) return;
const offsetX = x - dragStartPositionRef.current.x;
const offsetY = y - dragStartPositionRef.current.y;
if (Math.abs(offsetX) < 2 && Math.abs(offsetY) < 2) {
return;
}
const requestedPosition = {
x: dragStartCropRef.current.x + offsetX,
y: dragStartCropRef.current.y + offsetY,
};
const newPosition = !context.allowOverflow
? onPositionClamp(
requestedPosition,
mediaSize,
cropSize,
zoom,
rotation,
)
: requestedPosition;
const currentCrop = store.getState().crop;
if (
Math.abs(newPosition.x - currentCrop.x) > 1 ||
Math.abs(newPosition.y - currentCrop.y) > 1
) {
store.setState("crop", newPosition);
}
});
},
[cropSize, mediaSize, context.allowOverflow, zoom, rotation, store],
);
const onMouseMove = React.useCallback(
(event: MouseEvent) => onDrag(getMousePoint(event)),
[getMousePoint, onDrag],
);
const onTouchMove = React.useCallback(
(event: TouchEvent) => {
event.preventDefault();
if (event.touches.length === 2) {
const [firstTouch, secondTouch] = event.touches ?? [];
if (firstTouch && secondTouch) {
const pointA = getTouchPoint(firstTouch);
const pointB = getTouchPoint(secondTouch);
const center = getCenter(pointA, pointB);
onDrag(center);
if (rafPinchTimeoutRef.current) {
cancelAnimationFrame(rafPinchTimeoutRef.current);
}
rafPinchTimeoutRef.current = requestAnimationFrame(() => {
const distance = getDistanceBetweenPoints(pointA, pointB);
const distanceRatio = distance / lastPinchDistanceRef.current;
if (Math.abs(distanceRatio - 1) > 0.01) {
const newZoom = zoom * distanceRatio;
onZoomChange(newZoom, center, false);
lastPinchDistanceRef.current = distance;
}
const rotationAngle = getRotationBetweenPoints(pointA, pointB);
const rotationDiff = rotationAngle - lastPinchRotationRef.current;
if (Math.abs(rotationDiff) > 0.5) {
const newRotation = rotation + rotationDiff;
store.setState("rotation", newRotation);
lastPinchRotationRef.current = rotationAngle;
}
});
}
} else if (event.touches.length === 1) {
const firstTouch = event.touches[0];
if (firstTouch) {
onDrag(getTouchPoint(firstTouch));
}
}
},
[getTouchPoint, onDrag, zoom, onZoomChange, rotation, store],
);
const onGestureChange = React.useCallback(
(event: GestureEvent) => {
event.preventDefault();
if (isTouchingRef.current) {
return;
}
const point = { x: Number(event.clientX), y: Number(event.clientY) };
const newZoom = gestureZoomStartRef.current - 1 + event.scale;
onZoomChange(newZoom, point, true);
const newRotation = gestureRotationStartRef.current + event.rotation;
store.setState("rotation", newRotation);
},
[onZoomChange, store],
);
const onGestureEnd = React.useCallback(() => {
document.removeEventListener(
"gesturechange",
onGestureChange as EventListener,
);
document.removeEventListener("gestureend", onGestureEnd as EventListener);
}, [onGestureChange]);
const onGestureStart = React.useCallback(
(event: GestureEvent) => {
event.preventDefault();
document.addEventListener(
"gesturechange",
onGestureChange as EventListener,
);
document.addEventListener("gestureend", onGestureEnd as EventListener);
gestureZoomStartRef.current = zoom;
gestureRotationStartRef.current = rotation;
},
[zoom, rotation, onGestureChange, onGestureEnd],
);
const cleanEvents = React.useCallback(() => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("touchmove", onTouchMove);
document.removeEventListener(
"gesturechange",
onGestureChange as EventListener,
);
document.removeEventListener("gestureend", onGestureEnd as EventListener);
}, [onMouseMove, onTouchMove, onGestureChange, onGestureEnd]);
const onDragStopped = React.useCallback(() => {
isTouchingRef.current = false;
store.setState("isDragging", false);
onRefsCleanup();
document.removeEventListener("mouseup", onDragStopped);
document.removeEventListener("touchend", onDragStopped);
cleanEvents();
}, [store, cleanEvents, onRefsCleanup]);
const normalizeWheel = React.useCallback((event: WheelEvent) => {
let deltaX = event.deltaX;
let deltaY = event.deltaY;
let deltaZ = event.deltaZ;
if (event.deltaMode === 1) {
deltaX *= 16;
deltaY *= 16;
deltaZ *= 16;
} else if (event.deltaMode === 2) {
deltaX *= 400;
deltaY *= 400;
deltaZ *= 400;
}
return { deltaX, deltaY, deltaZ };
}, []);
const onWheelZoom = React.useCallback(
(event: WheelEvent) => {
contentProps.onWheelZoom?.(event);
if (event.defaultPrevented) return;
event.preventDefault();
const point = getMousePoint(event);
const { deltaY } = normalizeWheel(event);
const newZoom = zoom - (deltaY * context.zoomSpeed) / 200;
onZoomChange(newZoom, point, true);
store.batch(() => {
const currentState = store.getState();
if (!currentState.isWheelZooming) {
store.setState("isWheelZooming", true);
}
if (!currentState.isDragging) {
store.setState("isDragging", true);
}
});
if (wheelTimerRef.current) {
clearTimeout(wheelTimerRef.current);
}
wheelTimerRef.current = window.setTimeout(() => {
store.batch(() => {
store.setState("isWheelZooming", false);
store.setState("isDragging", false);
});
}, 250);
},
[
getMousePoint,
zoom,
context.zoomSpeed,
onZoomChange,
store,
contentProps.onWheelZoom,
normalizeWheel,
],
);
const onKeyUp = React.useCallback(
(event: React.KeyboardEvent<RootElement>) => {
contentProps.onKeyUp?.(event);
if (event.defaultPrevented) return;
const arrowKeys = new Set([
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
]);
if (arrowKeys.has(event.key)) {
event.preventDefault();
store.setState("isDragging", false);
}
},
[store, contentProps.onKeyUp],
);
const onKeyDown = React.useCallback(
(event: React.KeyboardEvent<RootElement>) => {
if (!cropSize || !mediaSize) return;
contentProps.onKeyDown?.(event);
if (event.defaultPrevented) return;
let step = context.keyboardStep;
if (event.shiftKey) {
step *= 0.2;
}
const keyCallbacks: Record<string, () => Point> = {
ArrowUp: () => ({ ...crop, y: crop.y - step }),
ArrowDown: () => ({ ...crop, y: crop.y + step }),
ArrowLeft: () => ({ ...crop, x: crop.x - step }),
ArrowRight: () => ({ ...crop, x: crop.x + step }),
} as const;
const callback = keyCallbacks[event.key];
if (!callback) return;
event.preventDefault();
let newCrop = callback();
if (!context.allowOverflow) {
newCrop = onPositionClamp(newCrop, mediaSize, cropSize, zoom, rotation);
}
if (!event.repeat) {
store.setState("isDragging", true);
}
store.setState("crop", newCrop);
},
[
cropSize,
mediaSize,
context.keyboardStep,
context.allowOverflow,
crop,
zoom,
rotation,
store,
contentProps.onKeyDown,
],
);
const onMouseDown = React.useCallback(
(event: React.MouseEvent<RootElement>) => {
contentProps.onMouseDown?.(event);
if (event.defaultPrevented) return;
event.preventDefault();
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onDragStopped);
onContentPositionChange();
onDragStart(getMousePoint(event));
},
[
getMousePoint,
onDragStart,
onDragStopped,
onMouseMove,
onContentPositionChange,
contentProps.onMouseDown,
],
);
const onTouchStart = React.useCallback(
(event: React.TouchEvent<RootElement>) => {
contentProps.onTouchStart?.(event);
if (event.defaultPrevented) return;
isTouchingRef.current = true;
document.addEventListener("touchmove", onTouchMove, { passive: false });
document.addEventListener("touchend", onDragStopped);
onContentPositionChange();
if (event.touches.length === 2) {
const [firstTouch, secondTouch] = event.touches
? Array.from(event.touches)
: [];
if (firstTouch && secondTouch) {
const pointA = getTouchPoint(firstTouch);
const pointB = getTouchPoint(secondTouch);
lastPinchDistanceRef.current = getDistanceBetweenPoints(
pointA,
pointB,
);
lastPinchRotationRef.current = getRotationBetweenPoints(
pointA,
pointB,
);
onDragStart(getCenter(pointA, pointB));
}
} else if (event.touches.length === 1) {
const firstTouch = event.touches[0];
if (firstTouch) {
onDragStart(getTouchPoint(firstTouch));
}
}
},
[
onDragStopped,
onTouchMove,
onContentPositionChange,
getTouchPoint,
onDragStart,
contentProps.onTouchStart,
],
);
const preventZoomSafari = React.useCallback(
(event: Event) => event.preventDefault(),
[],
);
React.useEffect(() => {
const content = context.rootRef?.current;
if (!content) return;
if (!context.preventScrollZoom) {
content.addEventListener("wheel", onWheelZoom, { passive: false });
}
content.addEventListener("gesturestart", preventZoomSafari);
content.addEventListener("gesturestart", onGestureStart as EventListener);
return () => {
if (!context.preventScrollZoom) {
content.removeEventListener("wheel", onWheelZoom);
}
content.removeEventListener("gesturestart", preventZoomSafari);
content.removeEventListener(
"gesturestart",
onGestureStart as EventListener,
);
onRefsCleanup();
};
}, [
context.rootRef,
context.preventScrollZoom,
onWheelZoom,
onRefsCleanup,
preventZoomSafari,
onGestureStart,
]);
React.useEffect(() => {
return () => {
onRefsCleanup();
onCachesCleanup();
};
}, [onRefsCleanup, onCachesCleanup]);
const RootPrimitive = asChild ? Slot : "div";
return (
<RootPrimitive
data-slot="cropper"
tabIndex={0}
{...contentProps}
ref={composedRef}
className={cn(
"absolute inset-0 flex cursor-move touch-none select-none items-center justify-center overflow-hidden outline-none",
className,
)}
onKeyUp={onKeyUp}
onKeyDown={onKeyDown}
onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
/>
);
}
const cropperMediaVariants = cva("will-change-transform", {
variants: {
objectFit: {
contain: "absolute inset-0 m-auto max-h-full max-w-full",
cover: "h-auto w-full",
"horizontal-cover": "h-auto w-full",
"vertical-cover": "h-full w-auto",
},
},
defaultVariants: {
objectFit: "contain",
},
});
interface UseMediaComputationProps<
T extends HTMLImageElement | HTMLVideoElement,
> {
mediaRef: React.RefObject<T | null>;
context: CropperContextValue;
store: Store;
rotation: number;
getNaturalDimensions: (media: T) => Size;
}
function useMediaComputation<T extends HTMLImageElement | HTMLVideoElement>({
mediaRef,
context,
store,
rotation,
getNaturalDimensions,
}: UseMediaComputationProps<T>) {
const computeSizes = React.useCallback(() => {
const media = mediaRef.current;
const content = context.rootRef?.current;
if (!media || !content) return;
const contentRect = content.getBoundingClientRect();
const containerAspect = contentRect.width / contentRect.height;
const { width: naturalWidth, height: naturalHeight } =
getNaturalDimensions(media);
const isScaledDown =
media.offsetWidth < naturalWidth || media.offsetHeight < naturalHeight;
const mediaAspect = naturalWidth / naturalHeight;
let renderedMediaSize: Size;
if (isScaledDown) {
const objectFitCallbacks = {
contain: () =>
containerAspect > mediaAspect
? {
width: contentRect.height * mediaAspect,
height: contentRect.height,
}
: {
width: contentRect.width,
height: contentRect.width / mediaAspect,
},
"horizontal-cover": () => ({
width: contentRect.width,
height: contentRect.width / mediaAspect,
}),
"vertical-cover": () => ({
width: contentRect.height * mediaAspect,
height: contentRect.height,
}),
cover: () =>
containerAspect < mediaAspect
? {
width: contentRect.width,
height: contentRect.width / mediaAspect,
}
: {
width: contentRect.height * mediaAspect,
height: contentRect.height,
},
} as const;
const callback = objectFitCallbacks[context.objectFit];
renderedMediaSize = callback
? callback()
: containerAspect > mediaAspect
? {
width: contentRect.height * mediaAspect,
height: contentRect.height,
}
: {
width: contentRect.width,
height: contentRect.width / mediaAspect,
};
} else {
renderedMediaSize = {
width: media.offsetWidth,
height: media.offsetHeight,
};
}
const mediaSize: MediaSize = {
...renderedMediaSize,
naturalWidth,
naturalHeight,
};
store.setState("mediaSize", mediaSize);
const cropSize = getCropSize(
mediaSize.width,
mediaSize.height,
contentRect.width,
contentRect.height,
context.aspectRatio,
rotation,
);
store.setState("cropSize", cropSize);
requestAnimationFrame(() => {
const currentState = store.getState();
if (currentState.cropSize && currentState.mediaSize) {
const newPosition = onPositionClamp(
currentState.crop,
currentState.mediaSize,
currentState.cropSize,
currentState.zoom,
currentState.rotation,
);
if (
Math.abs(newPosition.x - currentState.crop.x) > 0.001 ||
Math.abs(newPosition.y - currentState.crop.y) > 0.001
) {
store.setState("crop", newPosition);
}
}
});
return { mediaSize, cropSize };
}, [
mediaRef,
context.aspectRatio,
context.rootRef,
context.objectFit,
store,
rotation,
getNaturalDimensions,
]);
return { computeSizes };
}
interface CropperImageProps
extends React.ComponentProps<"img">,
VariantProps<typeof cropperMediaVariants> {
asChild?: boolean;
snapPixels?: boolean;
}
function CropperImage(props: CropperImageProps) {
const {
className,
style,
asChild,
ref,
onLoad,
objectFit,
snapPixels = false,
...imageProps
} = props;
const context = useCropperContext(IMAGE_NAME);
const store = useStoreContext(IMAGE_NAME);
const crop = useStore((state) => state.crop);
const zoom = useStore((state) => state.zoom);
const rotation = useStore((state) => state.rotation);
const imageRef = React.useRef<HTMLImageElement>(null);
const composedRef = useComposedRefs(ref, imageRef);
const getNaturalDimensions = React.useCallback(
(image: HTMLImageElement) => ({
width: image.naturalWidth,
height: image.naturalHeight,
}),
[],
);
const { computeSizes } = useMediaComputation({
mediaRef: imageRef,
context,
store,
rotation,
getNaturalDimensions,
});
const onMediaLoad = React.useCallback(() => {
const image = imageRef.current;
if (!image) return;
computeSizes();
onLoad?.(
new Event("load") as unknown as React.SyntheticEvent<HTMLImageElement>,
);
}, [computeSizes, onLoad]);
React.useEffect(() => {
const image = imageRef.current;
if (image?.complete && image.naturalWidth > 0) {
onMediaLoad();
}
}, [onMediaLoad]);
React.useEffect(() => {
const content = context.rootRef?.current;
if (!content) return;
if (typeof ResizeObserver !== "undefined") {
let isFirstResize = true;
const resizeObserver = new ResizeObserver(() => {
if (isFirstResize) {
isFirstResize = false;
return;
}
const callback = () => {
const image = imageRef.current;
if (image?.complete && image.naturalWidth > 0) {
computeSizes();
}
};
if ("requestIdleCallback" in window) {
requestIdleCallback(callback);
} else {
setTimeout(callback, 16);
}
});
resizeObserver.observe(content);
return () => {
resizeObserver.disconnect();
};
} else {
const onWindowResize = () => {
const image = imageRef.current;
if (image?.complete && image.naturalWidth > 0) {
computeSizes();
}
};
window.addEventListener("resize", onWindowResize);
return () => {
window.removeEventListener("resize", onWindowResize);
};
}
}, [context.rootRef, computeSizes]);
const ImagePrimitive = asChild ? Slot : "img";
return (
<ImagePrimitive
data-slot="cropper-image"
{...imageProps}
ref={composedRef}
className={cn(
cropperMediaVariants({
objectFit: objectFit ?? context.objectFit,
className,
}),
)}
style={{
transform: snapPixels
? `translate(${snapToDevicePixel(crop.x)}px, ${snapToDevicePixel(crop.y)}px) rotate(${rotation}deg) scale(${zoom})`
: `translate(${crop.x}px, ${crop.y}px) rotate(${rotation}deg) scale(${zoom})`,
...style,
}}
onLoad={onMediaLoad}
/>
);
}
interface CropperVideoProps
extends React.ComponentProps<"video">,
VariantProps<typeof cropperMediaVariants> {
asChild?: boolean;
snapPixels?: boolean;
}
function CropperVideo(props: CropperVideoProps) {
const {
className,
style,
asChild,
ref,
onLoadedMetadata,
objectFit,
snapPixels = false,
...videoProps
} = props;
const context = useCropperContext(VIDEO_NAME);
const store = useStoreContext(VIDEO_NAME);
const crop = useStore((state) => state.crop);
const zoom = useStore((state) => state.zoom);
const rotation = useStore((state) => state.rotation);
const videoRef = React.useRef<HTMLVideoElement>(null);
const composedRef = useComposedRefs(ref, videoRef);
const getNaturalDimensions = React.useCallback(
(video: HTMLVideoElement) => ({
width: video.videoWidth,
height: video.videoHeight,
}),
[],
);
const { computeSizes } = useMediaComputation({
mediaRef: videoRef,
context,
store,
rotation,
getNaturalDimensions,
});
const onMediaLoad = React.useCallback(() => {
const video = videoRef.current;
if (!video) return;
computeSizes();
onLoadedMetadata?.(
new Event(
"loadedmetadata",
) as unknown as React.SyntheticEvent<HTMLVideoElement>,
);
}, [computeSizes, onLoadedMetadata]);
React.useEffect(() => {
const content = context.rootRef?.current;
if (!content) return;
if (typeof ResizeObserver !== "undefined") {
let isFirstResize = true;
const resizeObserver = new ResizeObserver(() => {
if (isFirstResize) {
isFirstResize = false;
return;
}
const callback = () => {
const video = videoRef.current;
if (video && video.videoWidth > 0 && video.videoHeight > 0) {
computeSizes();
}
};
if ("requestIdleCallback" in window) {
requestIdleCallback(callback);
} else {
setTimeout(callback, 16);
}
});
resizeObserver.observe(content);
return () => {
resizeObserver.disconnect();
};
} else {
const onWindowResize = () => {
const video = videoRef.current;
if (video && video.videoWidth > 0 && video.videoHeight > 0) {
computeSizes();
}
};
window.addEventListener("resize", onWindowResize);
return () => {
window.removeEventListener("resize", onWindowResize);
};
}
}, [context.rootRef, computeSizes]);
const VideoPrimitive = asChild ? Slot : "video";
return (
<VideoPrimitive
data-slot="cropper-video"
autoPlay
playsInline
loop
muted
controls={false}
{...videoProps}
ref={composedRef}
className={cn(
cropperMediaVariants({
objectFit: objectFit ?? context.objectFit,
className,
}),
)}
style={{
transform: snapPixels
? `translate(${snapToDevicePixel(crop.x)}px, ${snapToDevicePixel(crop.y)}px) rotate(${rotation}deg) scale(${zoom})`
: `translate(${crop.x}px, ${crop.y}px) rotate(${rotation}deg) scale(${zoom})`,
...style,
}}
onLoadedMetadata={onMediaLoad}
/>
);
}
const cropperAreaVariants = cva(
"-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-1/2 box-border overflow-hidden border border-[2.5px] border-white/90 shadow-[0_0_0_9999em_rgba(0,0,0,0.5)]",
{
variants: {
shape: {
rectangular: "",
circular: "rounded-full",
},
withGrid: {
true: "before:absolute before:top-0 before:right-1/3 before:bottom-0 before:left-1/3 before:box-border before:border before:border-white/50 before:border-t-0 before:border-b-0 before:content-[''] after:absolute after:top-1/3 after:right-0 after:bottom-1/3 after:left-0 after:box-border after:border after:border-white/50 after:border-r-0 after:border-l-0 after:content-['']",
false: "",
},
},
defaultVariants: {
shape: "rectangular",
withGrid: false,
},
},
);
interface CropperAreaProps
extends DivProps,
VariantProps<typeof cropperAreaVariants> {
snapPixels?: boolean;
}
function CropperArea(props: CropperAreaProps) {
const {
className,
style,
asChild,
ref,
snapPixels = false,
shape,
withGrid,
...areaProps
} = props;
const context = useCropperContext(AREA_NAME);
const cropSize = useStore((state) => state.cropSize);
if (!cropSize) return null;
const AreaPrimitive = asChild ? Slot : "div";
return (
<AreaPrimitive
data-slot="cropper-area"
{...areaProps}
ref={ref}
className={cn(
cropperAreaVariants({
shape: shape ?? context.shape,
withGrid: withGrid ?? context.withGrid,
className,
}),
)}
style={{
width: snapPixels ? Math.round(cropSize.width) : cropSize.width,
height: snapPixels ? Math.round(cropSize.height) : cropSize.height,
...style,
}}
/>
);
}
export {
CropperRoot as Root,
CropperImage as Image,
CropperVideo as Video,
CropperArea as Area,
//
CropperRoot as Cropper,
CropperImage,
CropperVideo,
CropperArea,
//
useStore as useCropper,
//
type CropperRootProps as CropperProps,
type Point as CropperPoint,
type Size as CropperSize,
type Area as CropperAreaData,
type Shape as CropperShape,
type ObjectFit as CropperObjectFit,
};
Layout
Import the parts, and compose them together.
import {
Cropper,
CropperArea,
CropperImage,
CropperVideo,
} from "@/components/ui/cropper";
<Cropper>
<CropperImage src="/image.jpg" alt="Image to crop" />
<CropperArea />
</Cropper>
Examples
Controlled State
A cropper with external controls for zoom, rotation, and crop position.
"use client";
import { RotateCcwIcon } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import {
Cropper,
CropperArea,
CropperImage,
type CropperPoint,
} from "@/components/ui/cropper";
export function CropperControlledDemo() {
const id = React.useId();
const [crop, setCrop] = React.useState<CropperPoint>({ x: 0, y: 0 });
const [zoom, setZoom] = React.useState(1);
const [rotation, setRotation] = React.useState(0);
const onCropReset = React.useCallback(() => {
setCrop({ x: 0, y: 0 });
setZoom(1);
setRotation(0);
}, []);
return (
<div className="relative flex size-full max-w-lg flex-col gap-4">
<Cropper
aspectRatio={1}
crop={crop}
zoom={zoom}
rotation={rotation}
onCropChange={setCrop}
onZoomChange={setZoom}
onRotationChange={setRotation}
className="min-h-[260px]"
>
<CropperImage
src="https://images.unsplash.com/photo-1472214103451-9374bd1c798e?w=1920&h=1080&fit=crop&auto=format&fm=webp&q=80"
alt="Landscape"
crossOrigin="anonymous"
/>
<CropperArea />
</Cropper>
<div className="flex flex-col items-center gap-4 sm:flex-row">
<div className="flex w-full flex-col gap-2.5">
<Label htmlFor={`${id}-zoom`}>Zoom: {zoom.toFixed(2)}</Label>
<Slider
id={`${id}-zoom`}
value={[zoom]}
onValueChange={(value) => setZoom(value[0] ?? 1)}
min={1}
max={3}
step={0.1}
/>
</div>
<div className="flex w-full flex-col gap-2.5">
<Label htmlFor={`${id}-rotation`}>
Rotation: {rotation.toFixed(0)}°
</Label>
<Slider
id={`${id}-rotation`}
value={[rotation]}
onValueChange={(value) => setRotation(value[0] ?? 0)}
min={-180}
max={180}
step={1}
/>
</div>
</div>
<Button
variant="outline"
size="icon"
className="absolute top-3 right-2 size-8"
onClick={onCropReset}
>
<RotateCcwIcon />
</Button>
</div>
);
}
With File Upload
A cropper integrated with the FileUpload component for uploading and cropping images.
"use client";
import { CropIcon, UploadIcon, XIcon } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import {
Cropper,
CropperArea,
type CropperAreaData,
CropperImage,
type CropperPoint,
type CropperProps,
} from "@/components/ui/cropper";
import {
FileUpload,
FileUploadDropzone,
FileUploadItem,
FileUploadItemDelete,
FileUploadItemMetadata,
FileUploadItemPreview,
FileUploadList,
FileUploadTrigger,
} from "@/components/ui/file-upload";
async function createCroppedImage(
imageSrc: string,
cropData: CropperAreaData,
fileName: string,
): Promise<File> {
const image = new Image();
image.crossOrigin = "anonymous";
return new Promise((resolve, reject) => {
image.onload = () => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("Could not get canvas context"));
return;
}
canvas.width = cropData.width;
canvas.height = cropData.height;
ctx.drawImage(
image,
cropData.x,
cropData.y,
cropData.width,
cropData.height,
0,
0,
cropData.width,
cropData.height,
);
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error("Canvas is empty"));
return;
}
const croppedFile = new File([blob], `cropped-${fileName}`, {
type: "image/png",
});
resolve(croppedFile);
}, "image/png");
};
image.onerror = () => reject(new Error("Failed to load image"));
image.src = imageSrc;
});
}
interface FileWithCrop {
original: File;
cropped?: File;
}
export function CropperFileUploadDemo() {
const [files, setFiles] = React.useState<File[]>([]);
const [filesWithCrops, setFilesWithCrops] = React.useState<
Map<string, FileWithCrop>
>(new Map());
const [selectedFile, setSelectedFile] = React.useState<File | null>(null);
const [crop, setCrop] = React.useState<CropperPoint>({ x: 0, y: 0 });
const [zoom, setZoom] = React.useState(1);
const [croppedArea, setCroppedArea] = React.useState<CropperAreaData | null>(
null,
);
const [showCropDialog, setShowCropDialog] = React.useState(false);
const selectedImageUrl = React.useMemo(() => {
if (!selectedFile) return null;
return URL.createObjectURL(selectedFile);
}, [selectedFile]);
React.useEffect(() => {
return () => {
if (selectedImageUrl) {
URL.revokeObjectURL(selectedImageUrl);
}
};
}, [selectedImageUrl]);
const onFilesChange = React.useCallback((newFiles: File[]) => {
setFiles(newFiles);
setFilesWithCrops((prevFilesWithCrops) => {
const updatedFilesWithCrops = new Map(prevFilesWithCrops);
for (const file of newFiles) {
if (!updatedFilesWithCrops.has(file.name)) {
updatedFilesWithCrops.set(file.name, { original: file });
}
}
const fileNames = new Set(newFiles.map((f) => f.name));
for (const [fileName] of updatedFilesWithCrops) {
if (!fileNames.has(fileName)) {
updatedFilesWithCrops.delete(fileName);
}
}
return updatedFilesWithCrops;
});
}, []);
const onFileSelect = React.useCallback(
(file: File) => {
const fileWithCrop = filesWithCrops.get(file.name);
const originalFile = fileWithCrop?.original ?? file;
setSelectedFile(originalFile);
setCrop({ x: 0, y: 0 });
setZoom(1);
setCroppedArea(null);
setShowCropDialog(true);
},
[filesWithCrops],
);
const onCropAreaChange: NonNullable<CropperProps["onCropAreaChange"]> =
React.useCallback((_, croppedAreaPixels) => {
setCroppedArea(croppedAreaPixels);
}, []);
const onCropComplete: NonNullable<CropperProps["onCropComplete"]> =
React.useCallback((_, croppedAreaPixels) => {
setCroppedArea(croppedAreaPixels);
}, []);
const onCropReset = React.useCallback(() => {
setCrop({ x: 0, y: 0 });
setZoom(1);
setCroppedArea(null);
}, []);
const onCropDialogOpenChange = React.useCallback((open: boolean) => {
if (!open) {
setShowCropDialog(false);
setCrop({ x: 0, y: 0 });
setZoom(1);
setCroppedArea(null);
}
}, []);
const onCropApply = React.useCallback(async () => {
if (!selectedFile || !croppedArea || !selectedImageUrl) return;
try {
const croppedFile = await createCroppedImage(
selectedImageUrl,
croppedArea,
selectedFile.name,
);
const newFilesWithCrops = new Map(filesWithCrops);
const existing = newFilesWithCrops.get(selectedFile.name);
if (existing) {
newFilesWithCrops.set(selectedFile.name, {
...existing,
cropped: croppedFile,
});
setFilesWithCrops(newFilesWithCrops);
}
onCropDialogOpenChange(false);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to crop image",
);
}
}, [
selectedFile,
croppedArea,
selectedImageUrl,
filesWithCrops,
onCropDialogOpenChange,
]);
return (
<FileUpload
value={files}
onValueChange={onFilesChange}
accept="image/*"
maxFiles={2}
maxSize={10 * 1024 * 1024}
multiple
className="w-full max-w-lg"
>
<FileUploadDropzone className="min-h-32">
<div className="flex flex-col items-center gap-2 text-center">
<UploadIcon className="size-8 text-muted-foreground" />
<div>
<p className="font-medium text-sm">
Drop images here or click to upload
</p>
<p className="text-muted-foreground text-xs">
PNG, JPG, WebP up to 10MB
</p>
</div>
<FileUploadTrigger asChild>
<Button variant="outline" size="sm">
Choose Files
</Button>
</FileUploadTrigger>
</div>
</FileUploadDropzone>
<FileUploadList className="max-h-96 overflow-y-auto">
{files.map((file) => {
const fileWithCrop = filesWithCrops.get(file.name);
return (
<FileUploadItem key={file.name} value={file}>
<FileUploadItemPreview
render={(originalFile, fallback) => {
if (
fileWithCrop?.cropped &&
originalFile.type.startsWith("image/")
) {
const url = URL.createObjectURL(fileWithCrop.cropped);
return (
// biome-ignore lint/performance/noImgElement: dynamic cropped file URLs from user uploads don't work well with Next.js Image optimization
<img
src={url}
alt={originalFile.name}
className="size-full object-cover"
/>
);
}
return fallback();
}}
/>
<FileUploadItemMetadata />
<div className="flex gap-1">
<Dialog
open={showCropDialog}
onOpenChange={onCropDialogOpenChange}
>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={() => onFileSelect(file)}
>
<CropIcon />
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>Crop Image</DialogTitle>
<DialogDescription>
Adjust the crop area and zoom level for{" "}
{selectedFile?.name}
</DialogDescription>
</DialogHeader>
{selectedFile && selectedImageUrl && (
<div className="flex flex-col gap-4">
<Cropper
aspectRatio={1}
shape="circular"
crop={crop}
onCropChange={setCrop}
zoom={zoom}
onZoomChange={setZoom}
onCropAreaChange={onCropAreaChange}
onCropComplete={onCropComplete}
className="h-96"
>
<CropperImage
src={selectedImageUrl}
alt={selectedFile.name}
crossOrigin="anonymous"
/>
<CropperArea />
</Cropper>
<div className="flex flex-col gap-2">
<Label className="text-sm">
Zoom: {zoom.toFixed(2)}
</Label>
<Slider
value={[zoom]}
onValueChange={(value) => setZoom(value[0] ?? 1)}
min={1}
max={3}
step={0.1}
className="w-full"
/>
</div>
</div>
)}
<DialogFooter>
<Button onClick={onCropReset} variant="outline">
Reset
</Button>
<Button onClick={onCropApply} disabled={!croppedArea}>
Crop
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<FileUploadItemDelete asChild>
<Button
variant="ghost"
size="icon"
className="size-8 hover:bg-destructive/30 hover:text-destructive-foreground dark:hover:bg-destructive dark:hover:text-destructive-foreground"
>
<XIcon />
</Button>
</FileUploadItemDelete>
</div>
</FileUploadItem>
);
})}
</FileUploadList>
</FileUpload>
);
}
Different Shapes
A cropper with different shapes and configuration options.
"use client";
import * as React from "react";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Cropper,
CropperArea,
CropperImage,
type CropperObjectFit,
type CropperPoint,
type CropperProps,
type CropperShape,
} from "@/components/ui/cropper";
const shapes: { label: string; value: CropperShape }[] = [
{ label: "Rectangular", value: "rectangular" },
{ label: "Circular", value: "circular" },
] as const;
const objectFits: { label: string; value: CropperObjectFit }[] = [
{ label: "Contain", value: "contain" },
{ label: "Cover", value: "cover" },
{ label: "Horizontal Cover", value: "horizontal-cover" },
{ label: "Vertical Cover", value: "vertical-cover" },
] as const;
export function CropperShapesDemo() {
const id = React.useId();
const [crop, setCrop] = React.useState<CropperPoint>({ x: 0, y: 0 });
const [zoom, setZoom] = React.useState(1);
const [shape, setShape] =
React.useState<NonNullable<CropperShape>>("rectangular");
const [objectFit, setObjectFit] =
React.useState<NonNullable<CropperObjectFit>>("contain");
const [withGrid, setWithGrid] = React.useState(false);
const [allowOverflow, setAllowOverflow] = React.useState(false);
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center gap-2">
<Label htmlFor={`${id}-shape`}>Shape:</Label>
<Select
value={shape}
onValueChange={(value: CropperShape) => setShape(value)}
>
<SelectTrigger id={`${id}-shape`} size="sm" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{shapes.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label htmlFor={`${id}-object-fit`}>Object Fit:</Label>
<Select
value={objectFit}
onValueChange={(value: CropperObjectFit) => setObjectFit(value)}
>
<SelectTrigger id={`${id}-object-fit`} size="sm" className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{objectFits.map((fit) => (
<SelectItem key={fit.value} value={fit.value}>
{fit.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Switch
id={`${id}-grid`}
checked={withGrid}
onCheckedChange={setWithGrid}
/>
<Label htmlFor={`${id}-grid`}>Show Grid</Label>
</div>
<div className="flex items-center gap-2">
<Switch
id={`${id}-overflow`}
checked={allowOverflow}
onCheckedChange={setAllowOverflow}
/>
<Label htmlFor={`${id}-overflow`}>Allow Overflow</Label>
</div>
</div>
<Cropper
aspectRatio={1}
crop={crop}
zoom={zoom}
shape={shape}
objectFit={objectFit}
withGrid={withGrid}
allowOverflow={allowOverflow}
onCropChange={setCrop}
onZoomChange={setZoom}
className="min-h-72"
>
<CropperImage
src="https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=1920&h=1080&fit=crop&auto=format&fm=webp&q=80"
alt="Forest landscape"
crossOrigin="anonymous"
/>
<CropperArea />
</Cropper>
</div>
);
}
Video Cropping
A cropper that works with video content.
"use client";
import { PauseIcon, PlayIcon } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
Cropper,
CropperArea,
type CropperPoint,
CropperVideo,
} from "@/components/ui/cropper";
export function CropperVideoDemo() {
const [crop, setCrop] = React.useState<CropperPoint>({ x: 0, y: 0 });
const [zoom, setZoom] = React.useState(1);
const [isPlaying, setIsPlaying] = React.useState(true);
const videoRef = React.useRef<HTMLVideoElement>(null);
const onPlayToggle = React.useCallback(() => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
setIsPlaying(!isPlaying);
}
}, [isPlaying]);
const onMetadataLoaded = React.useCallback(() => {
if (videoRef.current && isPlaying) {
videoRef.current.play();
}
}, [isPlaying]);
return (
<div className="flex size-full flex-col gap-4">
<Button
size="sm"
className="w-fit [&_svg]:fill-current"
onClick={onPlayToggle}
>
{isPlaying ? (
<>
<PauseIcon />
Pause
</>
) : (
<>
<PlayIcon />
Play
</>
)}
</Button>
<Cropper
aspectRatio={16 / 9}
crop={crop}
zoom={zoom}
onCropChange={setCrop}
onZoomChange={setZoom}
className="h-96"
objectFit="cover"
withGrid
>
<CropperVideo
ref={videoRef}
src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"
crossOrigin="anonymous"
onLoadedMetadata={onMetadataLoaded}
/>
<CropperArea />
</Cropper>
</div>
);
}
API Reference
Root
The root container for the cropper component.
Prop | Type | Default |
---|---|---|
crop? | Point | { x: 0, y: 0 } |
zoom? | number | 1 |
minZoom? | number | 1 |
maxZoom? | number | 3 |
zoomSpeed? | number | 1 |
rotation? | number | 0 |
keyboardStep? | number | 1 |
aspectRatio? | number | 4/3 |
shape? | Shape | "rectangular" |
objectFit? | ObjectFit | "contain" |
allowOverflow? | boolean | false |
preventScrollZoom? | boolean | false |
withGrid? | boolean | false |
onCropChange? | ((crop: Point) => void) | - |
onCropSizeChange? | ((cropSize: Size) => void) | - |
onCropAreaChange? | ((croppedArea: Area, croppedAreaPixels: Area) => void) | - |
onCropComplete? | ((croppedArea: Area, croppedAreaPixels: Area) => void) | - |
onZoomChange? | ((zoom: number) => void) | - |
onRotationChange? | ((rotation: number) => void) | - |
onMediaLoaded? | ((mediaSize: MediaSize) => void) | - |
onInteractionStart? | (() => void) | - |
onInteractionEnd? | (() => void) | - |
onWheelZoom? | ((event: WheelEvent) => void) | - |
asChild? | boolean | false |
Image
The image element to be cropped.
Prop | Type | Default |
---|---|---|
objectFit? | ObjectFit | - |
snapPixels? | boolean | false |
asChild? | boolean | false |
Video
The video element to be cropped.
Prop | Type | Default |
---|---|---|
objectFit? | ObjectFit | - |
snapPixels? | boolean | false |
asChild? | boolean | false |
Area
The crop area overlay that shows the selected region.
Prop | Type | Default |
---|---|---|
shape? | Shape | - |
withGrid? | boolean | - |
snapPixels? | boolean | false |
asChild? | boolean | false |
Accessibility
Keyboard Interactions
Key | Description |
---|---|
Tab | Moves focus to the cropper content. |
ArrowUp | Moves the crop area up by the keyboard step amount. |
ArrowDown | Moves the crop area down by the keyboard step amount. |
ArrowLeft | Moves the crop area left by the keyboard step amount. |
ArrowRight | Moves the crop area right by the keyboard step amount. |
Shift + Arrow Keys | Moves the crop area with finer precision (20% of keyboard step). |
Mouse and Touch Interactions
- Drag: Pan the media within the crop area
- Scroll/Wheel: Zoom in and out (can be disabled with
preventScrollZoom
) - Pinch: Zoom and rotate on touch devices
- Two-finger drag: Pan while maintaining pinch zoom
Advanced Usage
Custom Crop Calculations
You can use the crop data from onCropComplete
to perform server-side cropping:
const onCropComplete = (croppedArea, croppedAreaPixels) => {
// croppedArea contains percentages (0-100)
// croppedAreaPixels contains actual pixel coordinates
// Send to server for processing
cropImage({
x: croppedAreaPixels.x,
y: croppedAreaPixels.y,
width: croppedAreaPixels.width,
height: croppedAreaPixels.height,
});
};
Performance Optimization
The cropper includes several performance optimizations:
- LRU Caching: Frequently used calculations are cached
- RAF Throttling: UI updates are throttled using requestAnimationFrame
- Quantization: Values are quantized to reduce cache misses
- Lazy Computation: Expensive calculations are deferred when possible
Object Fit Modes
The cropper supports different object fit modes:
- contain: Media fits entirely within the container (default)
- cover: Media covers the entire container, may be cropped
- horizontal-cover: Media width matches container width
- vertical-cover: Media height matches container height
Browser Support
Core Features
All core cropping features work in modern browsers:
- Chrome/Edge: Full support
- Firefox: Full support
- Safari: Full support (iOS 13+)
Touch Gestures
Multi-touch gestures require modern touch APIs:
- iOS Safari: Supported from iOS 13+
- Chrome Mobile: Full support
- Firefox Mobile: Basic touch support
Video Support
Video cropping requires modern video APIs:
- Chrome/Edge: Full support for all video formats
- Firefox: Full support with some codec limitations
- Safari: Full support with H.264/HEVC
Troubleshooting
CORS Issues
When cropping images from external domains, ensure proper CORS headers:
<CropperImage
src="https://example.com/image.jpg"
crossOrigin="anonymous"
alt="External image"
/>
Performance with Large Media
For large images or videos, consider:
- Pre-processing media to reasonable sizes
- Using
snapPixels
for crisp rendering - Limiting zoom range with
minZoom
andmaxZoom
- Reducing
keyboardStep
for smoother interactions
Mobile Considerations
On mobile devices:
- Use appropriate viewport meta tags
- Consider touch target sizes for controls
- Test pinch-to-zoom interactions
- Ensure adequate spacing around interactive elements
Credits
- Unsplash - For the photos used in examples.
- Blender Foundation - For the video used in examples.