"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 @diceui/cropperManual
Install the following dependencies:
npm install @radix-ui/react-slotCopy and paste 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 hooks into your hooks directory.
import * as React from "react";
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
export { useIsomorphicLayoutEffect };import * as React from "react";
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>;
}
export { useLazyRef };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";
import { useAsRef } from "@/components/hooks/use-as-ref";
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
import { useLazyRef } from "@/components/hooks/use-lazy-ref";
const ROOT_NAME = "Cropper";
const ROOT_IMPL_NAME = "CropperImpl";
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 = "rectangle" | "circle";
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;
}
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;
}
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 CropperImpl>;
interface CropperContextValue {
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 CropperProps 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 Cropper(props: CropperProps) {
const {
crop = { x: 0, y: 0 },
zoom = 1,
minZoom = 1,
maxZoom = 3,
zoomSpeed = 1,
rotation = 0,
keyboardStep = 1,
aspectRatio = 4 / 3,
shape = "rectangle",
objectFit = "contain",
allowOverflow = false,
preventScrollZoom = false,
withGrid = false,
onCropChange,
onCropSizeChange,
onCropAreaChange,
onCropComplete,
onZoomChange,
onRotationChange,
onMediaLoaded,
onInteractionStart,
onInteractionEnd,
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 propsRef = useAsRef({
onCropChange,
onCropSizeChange,
onCropAreaChange,
onCropComplete,
onZoomChange,
onRotationChange,
onMediaLoaded,
onInteractionStart,
onInteractionEnd,
});
const rootRef = React.useRef<RootElement | null>(null);
const store = React.useMemo<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 && propsRef.current.onCropAreaChange) {
const { croppedAreaPercentages, croppedAreaPixels } = getCroppedArea(
s.crop,
s.mediaSize,
s.cropSize,
aspectRatio,
s.zoom,
s.rotation,
);
propsRef.current.onCropAreaChange(
croppedAreaPercentages,
croppedAreaPixels,
);
}
});
}
return {
subscribe: (cb) => {
listenersRef.current.add(cb);
return () => listenersRef.current.delete(cb);
},
getState: () => stateRef.current,
setState: (key, value) => {
if (Object.is(stateRef.current[key], value)) return;
stateRef.current[key] = value;
if (
key === "crop" &&
typeof value === "object" &&
value &&
"x" in value
) {
propsRef.current.onCropChange?.(value);
} else if (key === "zoom" && typeof value === "number") {
propsRef.current.onZoomChange?.(value);
} else if (key === "rotation" && typeof value === "number") {
propsRef.current.onRotationChange?.(value);
} else if (
key === "cropSize" &&
typeof value === "object" &&
value &&
"width" in value
) {
propsRef.current.onCropSizeChange?.(value);
} else if (
key === "mediaSize" &&
typeof value === "object" &&
value &&
"naturalWidth" in value
) {
propsRef.current.onMediaLoaded?.(value);
} else if (key === "isDragging") {
if (value) {
propsRef.current.onInteractionStart?.();
} else {
propsRef.current.onInteractionEnd?.();
const currentState = stateRef.current;
if (
currentState?.mediaSize &&
currentState.cropSize &&
propsRef.current.onCropComplete
) {
const { croppedAreaPercentages, croppedAreaPixels } =
getCroppedArea(
currentState.crop,
currentState.mediaSize,
currentState.cropSize,
aspectRatio,
currentState.zoom,
currentState.rotation,
);
propsRef.current.onCropComplete(
croppedAreaPercentages,
croppedAreaPixels,
);
}
}
}
if (
(key === "crop" ||
key === "zoom" ||
key === "rotation" ||
key === "mediaSize" ||
key === "cropSize") &&
propsRef.current.onCropAreaChange
) {
notifyCropAreaChange();
}
if (!isBatching) {
store.notify();
}
},
notify: () => {
for (const cb of listenersRef.current) {
cb();
}
},
batch: (fn: () => void) => {
if (isBatching) {
fn();
return;
}
isBatching = true;
try {
fn();
} finally {
isBatching = false;
store.notify();
}
},
};
}, [listenersRef, stateRef, propsRef, aspectRatio]);
useIsomorphicLayoutEffect(() => {
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 contextValue = React.useMemo<CropperContextValue>(
() => ({
minZoom,
maxZoom,
zoomSpeed,
keyboardStep,
aspectRatio,
shape,
objectFit,
preventScrollZoom,
allowOverflow,
withGrid,
rootRef,
}),
[
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)}
>
<CropperImpl {...rootProps} />
</div>
</CropperContext.Provider>
</StoreContext.Provider>
);
}
interface CropperImplProps extends CropperProps {
onWheelZoom?: (event: WheelEvent) => void;
}
function CropperImpl(props: CropperImplProps) {
const {
onWheelZoom: onWheelZoomProp,
onKeyUp: onKeyUpProp,
onKeyDown: onKeyDownProp,
onMouseDown: onMouseDownProp,
onTouchStart: onTouchStartProp,
asChild,
className,
ref,
...rootImplProps
} = props;
const context = useCropperContext(ROOT_IMPL_NAME);
const store = useStoreContext(ROOT_IMPL_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 propsRef = useAsRef({
onWheelZoom: onWheelZoomProp,
onKeyUp: onKeyUpProp,
onKeyDown: onKeyDownProp,
onMouseDown: onMouseDownProp,
onTouchStart: onTouchStartProp,
});
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 onCacheCleanup = 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 onSafariZoomPrevent = React.useCallback(
(event: Event) => event.preventDefault(),
[],
);
const onEventsCleanup = 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);
onEventsCleanup();
}, [store, onEventsCleanup, onRefsCleanup]);
const getWheelDelta = 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) => {
propsRef.current.onWheelZoom?.(event);
if (event.defaultPrevented) return;
event.preventDefault();
const point = getMousePoint(event);
const { deltaY } = getWheelDelta(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);
},
[
propsRef,
getMousePoint,
zoom,
context.zoomSpeed,
onZoomChange,
getWheelDelta,
store,
],
);
const onKeyUp = React.useCallback(
(event: React.KeyboardEvent<RootElement>) => {
propsRef.current.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);
}
},
[propsRef, store],
);
const onKeyDown = React.useCallback(
(event: React.KeyboardEvent<RootElement>) => {
propsRef.current.onKeyDown?.(event);
if (event.defaultPrevented || !cropSize || !mediaSize) 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);
},
[
propsRef,
cropSize,
mediaSize,
context.keyboardStep,
context.allowOverflow,
crop,
zoom,
rotation,
store,
],
);
const onMouseDown = React.useCallback(
(event: React.MouseEvent<RootElement>) => {
propsRef.current.onMouseDown?.(event);
if (event.defaultPrevented) return;
event.preventDefault();
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onDragStopped);
onContentPositionChange();
onDragStart(getMousePoint(event));
},
[
propsRef,
getMousePoint,
onDragStart,
onDragStopped,
onMouseMove,
onContentPositionChange,
],
);
const onTouchStart = React.useCallback(
(event: React.TouchEvent<RootElement>) => {
propsRef.current.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));
}
}
},
[
propsRef,
onDragStopped,
onTouchMove,
onContentPositionChange,
getTouchPoint,
onDragStart,
],
);
React.useEffect(() => {
const content = context.rootRef?.current;
if (!content) return;
if (!context.preventScrollZoom) {
content.addEventListener("wheel", onWheelZoom, { passive: false });
}
content.addEventListener("gesturestart", onSafariZoomPrevent);
content.addEventListener("gesturestart", onGestureStart as EventListener);
return () => {
if (!context.preventScrollZoom) {
content.removeEventListener("wheel", onWheelZoom);
}
content.removeEventListener("gesturestart", onSafariZoomPrevent);
content.removeEventListener(
"gesturestart",
onGestureStart as EventListener,
);
onRefsCleanup();
};
}, [
context.rootRef,
context.preventScrollZoom,
onWheelZoom,
onRefsCleanup,
onSafariZoomPrevent,
onGestureStart,
]);
React.useEffect(() => {
return () => {
onRefsCleanup();
onCacheCleanup();
};
}, [onRefsCleanup, onCacheCleanup]);
const RootPrimitive = asChild ? Slot : "div";
return (
<RootPrimitive
data-slot="cropper"
tabIndex={0}
{...rootImplProps}
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: {
rectangle: "",
circle: "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: "rectangle",
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 {
Cropper,
CropperImage,
CropperVideo,
CropperArea,
//
useStore as useCropper,
//
type CropperProps,
type Point as CropperPoint,
type Size as CropperSize,
type Area as CropperAreaData,
type Shape as CropperShape,
type ObjectFit as CropperObjectFit,
};Update the import paths to match your project setup.
Layout
Import the parts, and compose them together.
import {
Cropper,
CropperArea,
CropperImage,
CropperVideo,
} from "@/components/ui/cropper";
return (
<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="circle"
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: "Rectangle", value: "rectangle" },
{ label: "Circle", value: "circle" },
] 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>>("rectangle");
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
Cropper
The root container for the cropper component.
Prop
Type
CropperImage
The image element to be cropped.
Prop
Type
CropperVideo
The video element to be cropped.
Prop
Type
CropperArea
The crop area overlay that shows the selected region.
Prop
Type
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
snapPixelsfor crisp rendering - Limiting zoom range with
minZoomandmaxZoom - Reducing
keyboardStepfor 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.