Dice UI
Components

Cropper

A powerful image and video cropper with zoom, rotation, and customizable crop areas.

"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.

PropTypeDefault
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.

PropTypeDefault
objectFit?
ObjectFit
-
snapPixels?
boolean
false
asChild?
boolean
false

Video

The video element to be cropped.

PropTypeDefault
objectFit?
ObjectFit
-
snapPixels?
boolean
false
asChild?
boolean
false

Area

The crop area overlay that shows the selected region.

PropTypeDefault
shape?
Shape
-
withGrid?
boolean
-
snapPixels?
boolean
false
asChild?
boolean
false

Accessibility

Keyboard Interactions

KeyDescription
TabMoves focus to the cropper content.
ArrowUpMoves the crop area up by the keyboard step amount.
ArrowDownMoves the crop area down by the keyboard step amount.
ArrowLeftMoves the crop area left by the keyboard step amount.
ArrowRightMoves the crop area right by the keyboard step amount.
Shift + Arrow KeysMoves 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:

  1. Pre-processing media to reasonable sizes
  2. Using snapPixels for crisp rendering
  3. Limiting zoom range with minZoom and maxZoom
  4. 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