Image Cropper
A shadcn-style image cropper component built with Ark UI primitives.
import {
ImageCropper,
ImageCropperGrid,
ImageCropperHandle,
ImageCropperImage,
ImageCropperSelection,
ImageCropperViewport,
imageCropperHandles,
} from "@/components/ui/image-cropper";
const IMAGE_CROPPER_SAMPLE =
"https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800";
const ImageCropperBasicDemo = () => (
<ImageCropper className="max-w-md">
<ImageCropperViewport>
<ImageCropperImage alt="Mountain" src={IMAGE_CROPPER_SAMPLE} />
<ImageCropperSelection>
{imageCropperHandles.map((position) => (
<ImageCropperHandle key={position} position={position} />
))}
<ImageCropperGrid axis="horizontal" />
<ImageCropperGrid axis="vertical" />
</ImageCropperSelection>
</ImageCropperViewport>
</ImageCropper>
);
export default ImageCropperBasicDemo;
Installation
npx shadcn@latest add @ark-cn/image-cropperInstall the dependency required by this primitive:
npm install @ark-ui/reactCopy the component source into your app:
TSXcomponents/ui/image-cropper.tsx
"use client";
import { ImageCropper as ImageCropperPrimitive } from "@ark-ui/react/image-cropper";
import { cn } from "@/lib/utils";
export const imageCropperHandles = ImageCropperPrimitive.handles;
const imageCropperRootLayoutClass =
"relative flex w-full max-w-lg flex-col gap-4 text-foreground [--cropper-accent:theme(colors.primary)] [--cropper-handler-size:6px] [--cropper-handler-width:3px] [--cropper-line-width:2px] [--cropper-overlay-color:rgb(0_0_0/0.5)]";
export const ImageCropper = ({
className,
...props
}: ImageCropperPrimitive.RootProps) => (
<ImageCropperPrimitive.Root
data-slot="image-cropper"
className={cn(imageCropperRootLayoutClass, className)}
{...props}
/>
);
export const ImageCropperRootProvider = ({
className,
...props
}: ImageCropperPrimitive.RootProviderProps) => (
<ImageCropperPrimitive.RootProvider
data-slot="image-cropper"
className={cn(imageCropperRootLayoutClass, className)}
{...props}
/>
);
export const ImageCropperViewport = ({
className,
...props
}: ImageCropperPrimitive.ViewportProps) => (
<ImageCropperPrimitive.Viewport
data-slot="image-cropper-viewport"
className={cn(
"relative aspect-video w-full overflow-hidden rounded-lg bg-muted",
className,
)}
{...props}
/>
);
export const ImageCropperImage = ({
className,
...props
}: ImageCropperPrimitive.ImageProps) => (
<ImageCropperPrimitive.Image
data-slot="image-cropper-image"
className={cn(
"pointer-events-none absolute inset-0 size-full select-none object-contain backface-hidden origin-center",
className,
)}
{...props}
/>
);
export const ImageCropperSelection = ({
className,
...props
}: ImageCropperPrimitive.SelectionProps) => (
<ImageCropperPrimitive.Selection
data-slot="image-cropper-selection"
className={cn(
"box-border cursor-move outline-none backface-hidden",
"[box-shadow:0_0_0_9999px_var(--cropper-overlay-color)]",
"border-(length:--cropper-line-width) border-white/50",
"data-[shape=circle]:rounded-full",
"focus-visible:border-primary",
"data-disabled:cursor-default",
"data-dragging:cursor-grabbing data-dragging:border-white/80",
className,
)}
{...props}
/>
);
export const ImageCropperHandle = ({
className,
children,
...props
}: ImageCropperPrimitive.HandleProps) => (
<ImageCropperPrimitive.Handle
data-slot="image-cropper-handle"
className={cn(
"absolute flex size-[calc(var(--cropper-handler-size)+8px)] touch-none items-center justify-center",
"data-disabled:hidden",
"data-[position=bottom]:cursor-ns-resize data-[position=top]:cursor-ns-resize",
"data-[position=left]:cursor-ew-resize data-[position=right]:cursor-ew-resize",
"data-[position=bottom-left]:cursor-nesw-resize data-[position=bottom-right]:cursor-nwse-resize",
"data-[position=top-left]:cursor-nwse-resize data-[position=top-right]:cursor-nesw-resize",
"[&>div]:size-(--cropper-handler-size) [&>div]:rounded-full [&>div]:border-2 [&>div]:border-primary [&>div]:bg-background [&>div]:shadow-sm",
className,
)}
{...props}
>
{children ?? <div />}
</ImageCropperPrimitive.Handle>
);
export const ImageCropperGrid = ({
className,
...props
}: ImageCropperPrimitive.GridProps) => (
<ImageCropperPrimitive.Grid
data-slot="image-cropper-grid"
className={cn(
"pointer-events-none absolute opacity-0 transition-opacity",
"data-[axis=horizontal]:inset-[33.33%_0] data-[axis=horizontal]:border-y data-[axis=horizontal]:border-white/40",
"data-[axis=vertical]:inset-[0_33.33%] data-[axis=vertical]:border-x data-[axis=vertical]:border-white/40",
"data-dragging:opacity-100 data-panning:opacity-100",
className,
)}
{...props}
/>
);
export type {
ImageCropperContextProps,
ImageCropperCropChangeDetails,
ImageCropperFlipChangeDetails,
ImageCropperFlipState,
ImageCropperHandlePosition,
ImageCropperRotationChangeDetails,
ImageCropperZoomChangeDetails,
UseImageCropperContext,
UseImageCropperProps,
UseImageCropperReturn,
} from "@ark-ui/react/image-cropper";
export {
ImageCropperContext,
imageCropperAnatomy,
useImageCropper,
useImageCropperContext,
} from "@ark-ui/react/image-cropper";
Update import aliases to match your project setup.
Usage
import * as ImageCropper from "@/components/ui/image-cropper"Read exported parts in src/components/ui/image-cropper.tsx and compose the primitive according to the Ark UI pattern for this component.
Examples
Aspect Ratio
import {
ImageCropper,
ImageCropperGrid,
ImageCropperHandle,
ImageCropperImage,
ImageCropperSelection,
ImageCropperViewport,
imageCropperHandles,
} from "@/components/ui/image-cropper";
const IMAGE_CROPPER_SAMPLE =
"https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800";
const ImageCropperAspectRatioDemo = () => (
<ImageCropper aspectRatio={1} className="max-w-xs">
<ImageCropperViewport className="aspect-square max-h-80">
<ImageCropperImage alt="Mountain" src={IMAGE_CROPPER_SAMPLE} />
<ImageCropperSelection>
{imageCropperHandles.map((position) => (
<ImageCropperHandle key={position} position={position} />
))}
<ImageCropperGrid axis="horizontal" />
<ImageCropperGrid axis="vertical" />
</ImageCropperSelection>
</ImageCropperViewport>
</ImageCropper>
);
export default ImageCropperAspectRatioDemo;
Basic
import {
ImageCropper,
ImageCropperGrid,
ImageCropperHandle,
ImageCropperImage,
ImageCropperSelection,
ImageCropperViewport,
imageCropperHandles,
} from "@/components/ui/image-cropper";
const IMAGE_CROPPER_SAMPLE =
"https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800";
const ImageCropperBasicDemo = () => (
<ImageCropper className="max-w-md">
<ImageCropperViewport>
<ImageCropperImage alt="Mountain" src={IMAGE_CROPPER_SAMPLE} />
<ImageCropperSelection>
{imageCropperHandles.map((position) => (
<ImageCropperHandle key={position} position={position} />
))}
<ImageCropperGrid axis="horizontal" />
<ImageCropperGrid axis="vertical" />
</ImageCropperSelection>
</ImageCropperViewport>
</ImageCropper>
);
export default ImageCropperBasicDemo;
Circle
import {
ImageCropper,
ImageCropperGrid,
ImageCropperHandle,
ImageCropperImage,
ImageCropperSelection,
ImageCropperViewport,
imageCropperHandles,
} from "@/components/ui/image-cropper";
const IMAGE_CROPPER_SAMPLE =
"https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800";
const ImageCropperCircleDemo = () => (
<ImageCropper className="max-w-md" cropShape="circle">
<ImageCropperViewport>
<ImageCropperImage alt="Mountain" src={IMAGE_CROPPER_SAMPLE} />
<ImageCropperSelection>
{imageCropperHandles.map((position) => (
<ImageCropperHandle key={position} position={position} />
))}
<ImageCropperGrid axis="horizontal" />
<ImageCropperGrid axis="vertical" />
</ImageCropperSelection>
</ImageCropperViewport>
</ImageCropper>
);
export default ImageCropperCircleDemo;
Controlled Zoom
1.0×
import { ZoomInIcon, ZoomOutIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
ImageCropper,
ImageCropperGrid,
ImageCropperHandle,
ImageCropperImage,
ImageCropperSelection,
ImageCropperViewport,
imageCropperHandles,
} from "@/components/ui/image-cropper";
const IMAGE_CROPPER_SAMPLE =
"https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800";
const ImageCropperControlledZoomDemo = () => {
const [zoom, setZoom] = useState(1);
return (
<div className="flex w-full max-w-md flex-col gap-3">
<div className="flex items-center gap-2">
<Button
aria-label="Zoom out"
variant="outline"
size="icon-sm"
onClick={() => {
setZoom((z) => Math.max(1, z - 0.1));
}}
>
<ZoomOutIcon aria-hidden className="size-4" />
</Button>
<span className="min-w-12 text-center font-mono text-muted-foreground text-xs tabular-nums">
{zoom.toFixed(1)}×
</span>
<Button
aria-label="Zoom in"
variant="outline"
size="icon-sm"
onClick={() => {
setZoom((z) => Math.min(5, z + 0.1));
}}
>
<ZoomInIcon aria-hidden className="size-4" />
</Button>
</div>
<ImageCropper
maxZoom={5}
minZoom={1}
zoom={zoom}
onZoomChange={(e) => {
setZoom(e.zoom);
}}
>
<ImageCropperViewport>
<ImageCropperImage alt="Mountain" src={IMAGE_CROPPER_SAMPLE} />
<ImageCropperSelection>
{imageCropperHandles.map((position) => (
<ImageCropperHandle key={position} position={position} />
))}
<ImageCropperGrid axis="horizontal" />
<ImageCropperGrid axis="vertical" />
</ImageCropperSelection>
</ImageCropperViewport>
</ImageCropper>
</div>
);
};
export default ImageCropperControlledZoomDemo;
Context
import { ZoomInIcon, ZoomOutIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
ImageCropper,
ImageCropperContext,
ImageCropperGrid,
ImageCropperHandle,
ImageCropperImage,
ImageCropperSelection,
ImageCropperViewport,
imageCropperHandles,
} from "@/components/ui/image-cropper";
const IMAGE_CROPPER_SAMPLE =
"https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800";
const ImageCropperContextDemo = () => (
<div className="flex w-full max-w-md flex-col gap-3">
<ImageCropper className="max-w-md">
<ImageCropperContext>
{(context) => (
<div className="flex items-center gap-2">
<Button
size="icon-sm"
type="button"
variant="outline"
onClick={() => context.zoomBy(-0.1)}
>
<ZoomOutIcon aria-hidden />
</Button>
<span className="min-w-12 text-center font-mono text-muted-foreground text-xs tabular-nums">
{context.zoom.toFixed(1)}x
</span>
<Button
size="icon-sm"
type="button"
variant="outline"
onClick={() => context.zoomBy(0.1)}
>
<ZoomInIcon aria-hidden />
</Button>
</div>
)}
</ImageCropperContext>
<ImageCropperViewport>
<ImageCropperImage alt="Mountain" src={IMAGE_CROPPER_SAMPLE} />
<ImageCropperSelection>
{imageCropperHandles.map((position) => (
<ImageCropperHandle key={position} position={position} />
))}
<ImageCropperGrid axis="horizontal" />
<ImageCropperGrid axis="vertical" />
</ImageCropperSelection>
</ImageCropperViewport>
</ImageCropper>
</div>
);
export default ImageCropperContextDemo;
Crop Preview
Preview
Click "Crop Image" to generate.
import { CropIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
ImageCropperGrid,
ImageCropperHandle,
ImageCropperImage,
ImageCropperRootProvider,
ImageCropperSelection,
ImageCropperViewport,
imageCropperHandles,
useImageCropper,
} from "@/components/ui/image-cropper";
const IMAGE_CROPPER_SAMPLE =
"https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800";
const ImageCropperCropPreviewDemo = () => {
const imageCropper = useImageCropper();
const [preview, setPreview] = useState<string | null>(null);
const handleCrop = async () => {
const result = await imageCropper.getCroppedImage({ output: "dataUrl" });
if (typeof result === "string") {
setPreview(result);
}
};
return (
<div className="flex w-full max-w-md flex-col gap-3">
<Button type="button" variant="default" onClick={handleCrop}>
<CropIcon aria-hidden />
Crop Image
</Button>
<ImageCropperRootProvider value={imageCropper}>
<ImageCropperViewport>
<ImageCropperImage
alt="Mountain"
crossOrigin="anonymous"
src={IMAGE_CROPPER_SAMPLE}
/>
<ImageCropperSelection>
{imageCropperHandles.map((position) => (
<ImageCropperHandle key={position} position={position} />
))}
<ImageCropperGrid axis="horizontal" />
<ImageCropperGrid axis="vertical" />
</ImageCropperSelection>
</ImageCropperViewport>
</ImageCropperRootProvider>
<div className="rounded-md border bg-muted/40 p-3">
<div className="mb-2 text-muted-foreground text-xs uppercase">
Preview
</div>
{preview ? (
<img
alt="Cropped preview"
className="max-h-36 rounded border"
src={preview}
/>
) : (
<p className="text-muted-foreground text-sm">
Click "Crop Image" to generate.
</p>
)}
</div>
</div>
);
};
export default ImageCropperCropPreviewDemo;
Events
Zoom
1.00x
Position
0, 0
Size
0 x 0
import { useState } from "react";
import {
ImageCropper,
ImageCropperGrid,
ImageCropperHandle,
ImageCropperImage,
ImageCropperSelection,
ImageCropperViewport,
imageCropperHandles,
} from "@/components/ui/image-cropper";
const IMAGE_CROPPER_SAMPLE =
"https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800";
const ImageCropperEventsDemo = () => {
const [crop, setCrop] = useState({ x: 0, y: 0, width: 0, height: 0 });
const [zoom, setZoom] = useState(1);
return (
<div className="flex w-full max-w-md flex-col gap-3">
<ImageCropper
className="max-w-md"
onCropChange={(event) => setCrop(event.crop)}
onZoomChange={(event) => setZoom(event.zoom)}
>
<ImageCropperViewport>
<ImageCropperImage alt="Mountain" src={IMAGE_CROPPER_SAMPLE} />
<ImageCropperSelection>
{imageCropperHandles.map((position) => (
<ImageCropperHandle key={position} position={position} />
))}
<ImageCropperGrid axis="horizontal" />
<ImageCropperGrid axis="vertical" />
</ImageCropperSelection>
</ImageCropperViewport>
</ImageCropper>
<div className="grid grid-cols-3 gap-2 rounded-md border bg-muted/40 p-2 text-xs">
<div className="space-y-1">
<div className="text-muted-foreground">Zoom</div>
<div className="font-mono">{zoom.toFixed(2)}x</div>
</div>
<div className="space-y-1">
<div className="text-muted-foreground">Position</div>
<div className="font-mono">
{Math.round(crop.x)}, {Math.round(crop.y)}
</div>
</div>
<div className="space-y-1">
<div className="text-muted-foreground">Size</div>
<div className="font-mono">
{Math.round(crop.width)} x {Math.round(crop.height)}
</div>
</div>
</div>
</div>
);
};
export default ImageCropperEventsDemo;
Fixed Crop Area
import {
ImageCropper,
ImageCropperGrid,
ImageCropperImage,
ImageCropperSelection,
ImageCropperViewport,
} from "@/components/ui/image-cropper";
const IMAGE_CROPPER_SAMPLE =
"https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800";
const ImageCropperFixedDemo = () => (
<ImageCropper className="max-w-md" fixedCropArea>
<ImageCropperViewport>
<ImageCropperImage alt="Mountain" src={IMAGE_CROPPER_SAMPLE} />
<ImageCropperSelection>
<ImageCropperGrid axis="horizontal" />
<ImageCropperGrid axis="vertical" />
</ImageCropperSelection>
</ImageCropperViewport>
</ImageCropper>
);
export default ImageCropperFixedDemo;
Flip
import { FlipHorizontalIcon, FlipVerticalIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
ImageCropper,
type ImageCropperFlipState,
ImageCropperGrid,
ImageCropperHandle,
ImageCropperImage,
ImageCropperSelection,
ImageCropperViewport,
imageCropperHandles,
} from "@/components/ui/image-cropper";
const IMAGE_CROPPER_SAMPLE =
"https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800";
const ImageCropperFlipDemo = () => {
const [flip, setFlip] = useState<ImageCropperFlipState>({
horizontal: false,
vertical: false,
});
return (
<div className="flex w-full max-w-md flex-col gap-3">
<div className="flex items-center gap-2">
<Button
size="sm"
type="button"
variant={flip.horizontal ? "default" : "outline"}
onClick={() =>
setFlip((prev) => ({ ...prev, horizontal: !prev.horizontal }))
}
>
<FlipHorizontalIcon aria-hidden />
Horizontal
</Button>
<Button
size="sm"
type="button"
variant={flip.vertical ? "default" : "outline"}
onClick={() =>
setFlip((prev) => ({ ...prev, vertical: !prev.vertical }))
}
>
<FlipVerticalIcon aria-hidden />
Vertical
</Button>
</div>
<ImageCropper
className="max-w-md"
flip={flip}
onFlipChange={(event) => setFlip(event.flip)}
>
<ImageCropperViewport>
<ImageCropperImage alt="Mountain" src={IMAGE_CROPPER_SAMPLE} />
<ImageCropperSelection>
{imageCropperHandles.map((position) => (
<ImageCropperHandle key={position} position={position} />
))}
<ImageCropperGrid axis="horizontal" />
<ImageCropperGrid axis="vertical" />
</ImageCropperSelection>
</ImageCropperViewport>
</ImageCropper>
</div>
);
};
export default ImageCropperFlipDemo;
Initial Crop
Starts with a pre-defined crop area.
import {
ImageCropper,
ImageCropperGrid,
ImageCropperHandle,
ImageCropperImage,
ImageCropperSelection,
ImageCropperViewport,
imageCropperHandles,
} from "@/components/ui/image-cropper";
const IMAGE_CROPPER_SAMPLE =
"https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800";
const ImageCropperInitialCropDemo = () => (
<div className="flex w-full max-w-md flex-col gap-2">
<p className="text-muted-foreground text-sm">
Starts with a pre-defined crop area.
</p>
<ImageCropper
className="max-w-md"
initialCrop={{ x: 50, y: 30, width: 200, height: 120 }}
>
<ImageCropperViewport>
<ImageCropperImage alt="Mountain" src={IMAGE_CROPPER_SAMPLE} />
<ImageCropperSelection>
{imageCropperHandles.map((position) => (
<ImageCropperHandle key={position} position={position} />
))}
<ImageCropperGrid axis="horizontal" />
<ImageCropperGrid axis="vertical" />
</ImageCropperSelection>
</ImageCropperViewport>
</ImageCropper>
</div>
);
export default ImageCropperInitialCropDemo;
Min Max Size
Crop area constrained to min 80px and max 200px.
import {
ImageCropper,
ImageCropperGrid,
ImageCropperHandle,
ImageCropperImage,
ImageCropperSelection,
ImageCropperViewport,
imageCropperHandles,
} from "@/components/ui/image-cropper";
const IMAGE_CROPPER_SAMPLE =
"https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800";
const ImageCropperMinMaxSizeDemo = () => (
<div className="flex w-full max-w-md flex-col gap-2">
<p className="text-muted-foreground text-sm">
Crop area constrained to min 80px and max 200px.
</p>
<ImageCropper
className="max-w-md"
maxHeight={200}
maxWidth={200}
minHeight={80}
minWidth={80}
>
<ImageCropperViewport>
<ImageCropperImage alt="Mountain" src={IMAGE_CROPPER_SAMPLE} />
<ImageCropperSelection>
{imageCropperHandles.map((position) => (
<ImageCropperHandle key={position} position={position} />
))}
<ImageCropperGrid axis="horizontal" />
<ImageCropperGrid axis="vertical" />
</ImageCropperSelection>
</ImageCropperViewport>
</ImageCropper>
</div>
);
export default ImageCropperMinMaxSizeDemo;
Reset
import {
FlipHorizontalIcon,
RefreshCwIcon,
RotateCcwIcon,
RotateCwIcon,
ZoomInIcon,
ZoomOutIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
ImageCropperGrid,
ImageCropperHandle,
ImageCropperImage,
ImageCropperRootProvider,
ImageCropperSelection,
ImageCropperViewport,
imageCropperHandles,
useImageCropper,
} from "@/components/ui/image-cropper";
const IMAGE_CROPPER_SAMPLE =
"https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800";
const ImageCropperResetDemo = () => {
const imageCropper = useImageCropper();
return (
<div className="flex w-full max-w-md flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<Button
size="icon-sm"
type="button"
variant="outline"
onClick={() => imageCropper.zoomBy(-0.1)}
>
<ZoomOutIcon aria-hidden />
</Button>
<Button
size="icon-sm"
type="button"
variant="outline"
onClick={() => imageCropper.zoomBy(0.1)}
>
<ZoomInIcon aria-hidden />
</Button>
<Button
size="icon-sm"
type="button"
variant="outline"
onClick={() => imageCropper.rotateBy(-90)}
>
<RotateCcwIcon aria-hidden />
</Button>
<Button
size="icon-sm"
type="button"
variant="outline"
onClick={() => imageCropper.rotateBy(90)}
>
<RotateCwIcon aria-hidden />
</Button>
<Button
size="icon-sm"
type="button"
variant="outline"
onClick={() => imageCropper.flipHorizontally()}
>
<FlipHorizontalIcon aria-hidden />
</Button>
<Button
type="button"
variant="secondary"
onClick={() => imageCropper.reset()}
>
<RefreshCwIcon aria-hidden />
Reset
</Button>
</div>
<ImageCropperRootProvider value={imageCropper}>
<ImageCropperViewport>
<ImageCropperImage alt="Mountain" src={IMAGE_CROPPER_SAMPLE} />
<ImageCropperSelection>
{imageCropperHandles.map((position) => (
<ImageCropperHandle key={position} position={position} />
))}
<ImageCropperGrid axis="horizontal" />
<ImageCropperGrid axis="vertical" />
</ImageCropperSelection>
</ImageCropperViewport>
</ImageCropperRootProvider>
</div>
);
};
export default ImageCropperResetDemo;
Root Provider
1.0x
import { ZoomInIcon, ZoomOutIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
ImageCropperGrid,
ImageCropperHandle,
ImageCropperImage,
ImageCropperRootProvider,
ImageCropperSelection,
ImageCropperViewport,
imageCropperHandles,
useImageCropper,
} from "@/components/ui/image-cropper";
const IMAGE_CROPPER_SAMPLE =
"https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800";
const ImageCropperRootProviderDemo = () => {
const imageCropper = useImageCropper();
return (
<div className="flex w-full max-w-md flex-col gap-3">
<div className="flex items-center gap-2">
<Button
size="icon-sm"
type="button"
variant="outline"
onClick={() => imageCropper.setZoom(imageCropper.zoom - 0.1)}
>
<ZoomOutIcon aria-hidden />
</Button>
<span className="min-w-12 text-center font-mono text-muted-foreground text-xs tabular-nums">
{imageCropper.zoom.toFixed(1)}x
</span>
<Button
size="icon-sm"
type="button"
variant="outline"
onClick={() => imageCropper.setZoom(imageCropper.zoom + 0.1)}
>
<ZoomInIcon aria-hidden />
</Button>
</div>
<ImageCropperRootProvider value={imageCropper}>
<ImageCropperViewport>
<ImageCropperImage alt="Mountain" src={IMAGE_CROPPER_SAMPLE} />
<ImageCropperSelection>
{imageCropperHandles.map((position) => (
<ImageCropperHandle key={position} position={position} />
))}
<ImageCropperGrid axis="horizontal" />
<ImageCropperGrid axis="vertical" />
</ImageCropperSelection>
</ImageCropperViewport>
</ImageCropperRootProvider>
</div>
);
};
export default ImageCropperRootProviderDemo;
Root Provider Zoom
1.0×
import { ZoomInIcon, ZoomOutIcon } from "lucide-react";
import {
ImageCropperGrid,
ImageCropperHandle,
ImageCropperImage,
ImageCropperRootProvider,
ImageCropperSelection,
ImageCropperViewport,
imageCropperHandles,
useImageCropper,
} from "@/components/ui/image-cropper";
const IMAGE_CROPPER_SAMPLE =
"https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800";
const ImageCropperRootProviderZoomDemo = () => {
const imageCropper = useImageCropper({ maxZoom: 5, minZoom: 1 });
return (
<div className="flex w-full max-w-md flex-col gap-3">
<div className="flex items-center gap-2">
<button
aria-label="Zoom out"
type="button"
onClick={() => {
imageCropper.setZoom(imageCropper.zoom - 0.1);
}}
>
<ZoomOutIcon aria-hidden className="size-4" />
</button>
<span className="min-w-12 text-center font-mono text-muted-foreground text-xs tabular-nums">
{imageCropper.zoom.toFixed(1)}×
</span>
<button
aria-label="Zoom in"
type="button"
onClick={() => {
imageCropper.setZoom(imageCropper.zoom + 0.1);
}}
>
<ZoomInIcon aria-hidden className="size-4" />
</button>
</div>
<ImageCropperRootProvider value={imageCropper}>
<ImageCropperViewport>
<ImageCropperImage alt="Mountain" src={IMAGE_CROPPER_SAMPLE} />
<ImageCropperSelection>
{imageCropperHandles.map((position) => (
<ImageCropperHandle key={position} position={position} />
))}
<ImageCropperGrid axis="horizontal" />
<ImageCropperGrid axis="vertical" />
</ImageCropperSelection>
</ImageCropperViewport>
</ImageCropperRootProvider>
</div>
);
};
export default ImageCropperRootProviderZoomDemo;
Rotation
0°
import { RotateCcwIcon, RotateCwIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
ImageCropper,
ImageCropperGrid,
ImageCropperHandle,
ImageCropperImage,
ImageCropperSelection,
ImageCropperViewport,
imageCropperHandles,
} from "@/components/ui/image-cropper";
const IMAGE_CROPPER_SAMPLE =
"https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800";
const ImageCropperRotationDemo = () => {
const [rotation, setRotation] = useState(0);
return (
<div className="flex w-full max-w-md flex-col gap-3">
<div className="flex items-center gap-2">
<Button
size="icon-sm"
type="button"
variant="outline"
onClick={() => setRotation((prev) => prev - 90)}
>
<RotateCcwIcon aria-hidden />
</Button>
<Button
size="icon-sm"
type="button"
variant="outline"
onClick={() => setRotation((prev) => prev + 90)}
>
<RotateCwIcon aria-hidden />
</Button>
<span className="font-mono text-muted-foreground text-sm tabular-nums">
{rotation}°
</span>
</div>
<ImageCropper
className="max-w-md"
rotation={rotation}
onRotationChange={(event) => setRotation(event.rotation)}
>
<ImageCropperViewport>
<ImageCropperImage alt="Mountain" src={IMAGE_CROPPER_SAMPLE} />
<ImageCropperSelection>
{imageCropperHandles.map((position) => (
<ImageCropperHandle key={position} position={position} />
))}
<ImageCropperGrid axis="horizontal" />
<ImageCropperGrid axis="vertical" />
</ImageCropperSelection>
</ImageCropperViewport>
</ImageCropper>
</div>
);
};
export default ImageCropperRotationDemo;
Zoom Limits
1.0x
Zoom constrained between 0.5x and 2x.
import { ZoomInIcon, ZoomOutIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
ImageCropper,
ImageCropperGrid,
ImageCropperHandle,
ImageCropperImage,
ImageCropperSelection,
ImageCropperViewport,
imageCropperHandles,
} from "@/components/ui/image-cropper";
const IMAGE_CROPPER_SAMPLE =
"https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800";
const minZoom = 0.5;
const maxZoom = 2;
const ImageCropperZoomLimitsDemo = () => {
const [zoom, setZoom] = useState(1);
return (
<div className="flex w-full max-w-md flex-col gap-3">
<div className="flex items-center gap-2">
<Button
size="icon-sm"
type="button"
variant="outline"
onClick={() => setZoom((prev) => Math.max(minZoom, prev - 0.1))}
>
<ZoomOutIcon aria-hidden />
</Button>
<span className="min-w-12 text-center font-mono text-muted-foreground text-xs tabular-nums">
{zoom.toFixed(1)}x
</span>
<Button
size="icon-sm"
type="button"
variant="outline"
onClick={() => setZoom((prev) => Math.min(maxZoom, prev + 0.1))}
>
<ZoomInIcon aria-hidden />
</Button>
</div>
<p className="text-muted-foreground text-sm">
Zoom constrained between {minZoom}x and {maxZoom}x.
</p>
<ImageCropper
className="max-w-md"
maxZoom={maxZoom}
minZoom={minZoom}
zoom={zoom}
onZoomChange={(event) => setZoom(event.zoom)}
>
<ImageCropperViewport>
<ImageCropperImage alt="Mountain" src={IMAGE_CROPPER_SAMPLE} />
<ImageCropperSelection>
{imageCropperHandles.map((position) => (
<ImageCropperHandle key={position} position={position} />
))}
<ImageCropperGrid axis="horizontal" />
<ImageCropperGrid axis="vertical" />
</ImageCropperSelection>
</ImageCropperViewport>
</ImageCropper>
</div>
);
};
export default ImageCropperZoomLimitsDemo;
API reference
This component mirrors the upstream Ark UI primitive.
See the ARK UI documentation for the full API.