Image cropper
Crop, zoom, rotate and flip an image.
Example
Installation
pnpm dlx shadcn@latest add superzero11/uiset/image-cropperIt lands in components/ui/image-cropper.tsx, with UISet’s root (uiset). Wrap your app in <UISet> once, and every component takes its skin.
What UISet adds
The component is UI SDK’s own, with its parts, props and names, so everything below works as UI SDK documents it. UISet adds the look and checks it:
- Styles in five skins, each in light and dark, from CSS variables you can override.
- Text at 4.5:1 contrast and control edges and focus rings at 3:1, in every skin and theme, checked with axe in tests.
- Haptic cues on phones, and sound cues when you set
soundon<UISet>. - Examples that name every control, so agents can find it (see Agents below).
The API reference and accessibility notes below are UI SDK’s, from its docs for @ui-sdk/react (Apache-2.0). Image cropper on ui-sdk.dev has more usage examples. Your coding agent can read the same page at node_modules/@ui-sdk/react/docs/components/image-cropper.md.
API Reference
Props and callbacks are the same as below, as React props.
ImageCropper
src?(string): The image's URL, like an object URL for a file someone picked.crop?(ImageCrop): The crop, in the image's own pixels, from the top left of the image as it shows once turned and flipped, with itsrotation,flipXandflipY. Pass it straight tocropImage. Pass it to control the cropper.defaultCrop?(ImageCrop): The crop at first, when uncontrolled.onCropChange?(((crop: Required<ImageCrop>, details: ImageCropperCropChangeDetails) => void)): Runs while the crop changes: on every step of a drag, a key or the wheel, and once an image loads.details.cancel()stops it.onCropCommit?(((crop: Required<ImageCrop>, details: ImageCropperCropCommitDetails) => void)): Runs once an interaction that changed the crop ends: the pointer lifts, a key is pressed, the wheel pauses. For saving.mode?(ImageCropperMode):"box"moves and resizes a selection over the whole image."frame"keeps the selection still and as large as it fits, and pans and zooms the image under it, like an avatar editor.aspectRatio?(number): Keeps the crop's width over its height, like1for a square or16 / 9. Changing it fits the crop to the new ratio.shape?(ImageCropperShape): The selection's shape, asdata-shape. A circle is still a box to the crop, so passshape: "circle"tocropImagetoo.minWidth?(number): The narrowest the crop gets, in image pixels.minHeight?(number): The shortest the crop gets, in image pixels.maxZoom?(number): How far the crop magnifies the image, where 1 is the largest crop the image allows.disabled?(boolean): Stops every change: the selection can't be focused, and the buttons and zoom are disabled.controller?(object): Sets the crop from anywhere. Create one withcreateImageCropperController().debug?(boolean): Logs every state change with its reason to the console, in development builds.
ImageCropperHandle
position(ImageCropperHandlePosition): The edge or corner it resizes, like"nw"for the top left corner.
ImageCropperFlip
direction?(ImageCropperFlipDirection): Which way it mirrors the image.
State
What render functions, state-based class names and slots receive. Every part gets the cropper's state.
crop(Required<ImageCrop> | null): The crop, or null until the image loads.zoom(number): How far the crop magnifies: 1 when it's as large as the image allows at its ratio.loading(boolean): Whether the image is still loading, so nothing can change yet.error(boolean): Whether the image failed to load.dragging(boolean): Whether a pointer is dragging the selection, a handle or the image.mode(ImageCropperMode): How the crop changes.shape(ImageCropperShape): The selection's shape.disabled(boolean): Whether thedisabledoption stops every change.
Handles add their own:
crop(Required<ImageCrop> | null): The crop, or null until the image loads.zoom(number): How far the crop magnifies: 1 when it's as large as the image allows at its ratio.loading(boolean): Whether the image is still loading, so nothing can change yet.error(boolean): Whether the image failed to load.dragging(boolean): Whether a pointer is dragging the selection, a handle or the image.mode(ImageCropperMode): How the crop changes.shape(ImageCropperShape): The selection's shape.disabled(boolean): Whether thedisabledoption stops every change.position(ImageCropperHandlePosition): The edge or corner it resizes.
Reasons
details.reason says why the crop changed.
image-load(Event | undefined): An image loaded. No event when it was already in the browser's cache.drag(PointerEvent): A slider thumb or a resize handle was dragged, or an item was dragged to a new place in a list.pinchwheel(WheelEvent): The mouse wheel stepped a focused number input.step-key(KeyboardEvent): Arrow, Page Up, Page Down, Home or End stepped a slider or a number input.zoom-keyzoom-inputrotate-keyrotate-pressflip-pressaspect-ratio-changeimperative(none): A controller orapicall, or a property set on a web component.
Controller
Create one with createImageCropperController() and pass it as controller.
Each write method takes an optional { reason, event }, runs the change callback like a drag, and returns false when nothing changed or the change is canceled.
getCrop()(() => Required<ImageCrop> | null): The crop, or null until the image loads.setCrop(crop, options?)((crop: ImageCrop, options?: ImageCropperChangeOptions | undefined) => boolean): Sets the crop, as a user would. Returns whether it changed.rotate(direction?, options?)((direction?: 1 | -1 | undefined, options?: ImageCropperChangeOptions | undefined) => boolean): Turns the image a quarter clockwise, or counterclockwise with-1.flip(direction?, options?)((direction?: ImageCropperFlipDirection | undefined, options?: ImageCropperChangeOptions | undefined) => boolean): Mirrors the image left to right, or top to bottom with"vertical".getZoom()(() => number): How far the crop magnifies: 1 when it's as large as the image allows at its ratio.setZoom(zoom, options?)((zoom: number, options?: ImageCropperChangeOptions | undefined) => boolean): Zooms to a level, about the crop's middle, within 1 andmaxZoom.focus()(() => void): Moves focus to the selection.
Events
Callbacks get the new crop first, then the details.
| Prop | On | Receives |
|---|---|---|
onCropChange | ImageCropper | (crop, details): the new crop and the reason |
onCropCommit | ImageCropper | (crop, details): the crop once the interaction ends, its reason |
Accessibility
No WAI-ARIA pattern covers an image cropper, so it's built from ones that do. The cropper is a group. The selection and each handle follow the WAI-ARIA slider pattern, and say the crop in words, like "300 by 300 pixels, at 50, 0". The zoom is a native range input that says its level, like "125%".
The selection, handles and zoom are named in English by default. Pass aria-label to name them in another language.
Keyboard
| Key | Does |
|---|---|
| Arrow keys | Move the selection, or on a handle, move its edges |
| Shift with an arrow | Moves ten times as far |
| Plus or Minus | Zooms in or out, about the crop's middle |
| R | Turns the image a quarter clockwise, or back with Shift |
| Escape | During a drag, puts the crop back where the drag started |
Each key press commits, like the end of a drag.
Tested Behavior
Every line below is an automated test.
Loading
- The crop starts once the image loads, as four fifths of the largest at the ratio.
Box
- A drag moves the selection over the image and commits once.
- A handle resizes from its corner, within the image.
- At a ratio, a handle keeps it.
- Escape during a drag puts the selection back.
Keyboard
- Arrows move the selection, ten times as far with Shift.
- Arrows on a handle move its edges, and the next Tab reaches it.
- Plus and minus zoom about the middle, and R turns the image.
Controls
- The zoom input zooms the crop.
- Rotate turns the image a quarter clockwise, and flip mirrors it.
- A controller sets the crop, and a canceled change keeps it.
- Disabled, nothing takes focus or changes.
Frame
- The selection stays put and a drag pans the image under it.
- Escape during a pan puts the image back.
- The wheel zooms at the pointer and commits after a pause.
Runs in React, Vue and web components.
Agents
UI SDK’s agent bridge finds this component by its accessible name: give it a visible label or an aria-label, and an agent can read its state and call its actions without screenshots. Building with AI agents shows how to connect one.
This is agent.snapshot() for the example above, read live from this page. Change something in the example, then read it again: the values follow.
[
{
"ref": "c8",
"component": "image-cropper",
"actions": [
"setCrop",
"rotate",
"flip",
"setZoom"
],
"label": "Cover photo",
"crop": {
"x": 80,
"y": 143,
"width": 640,
"height": 213,
"rotation": 0,
"flipX": false,
"flipY": false
}
}
]