Image Cropper
The image cropper machine keeps track of the crop rectangle, zoom, rotation, pan offset, flip state, and every gesture required to edit them. It exposes a set of DOM props so you can render your own viewport, frame, and handles in any framework.
Features
- Pointer, wheel, and pinch gestures that pan, zoom, rotate, and flip the image
- Handles that resize the crop area with snapping, aspect-ratio locking, and keyboard nudges
- Supports rectangular or circular crops, fixed crop windows, and constrained min/max dimensions
- Fully controllable zoom/rotation/flip values with change callbacks
- Programmatic helpers such as
api.resizeandapi.getCroppedImage - Accessible slider semantics, custom translations, and data attributes for styling
Installation
To use the image cropper machine in your project, run the following command in your command line:
npm install @zag-js/image-cropper @zag-js/react # or yarn add @zag-js/image-cropper @zag-js/react
npm install @zag-js/image-cropper @zag-js/solid # or yarn add @zag-js/image-cropper @zag-js/solid
npm install @zag-js/image-cropper @zag-js/vue # or yarn add @zag-js/image-cropper @zag-js/vue
npm install @zag-js/image-cropper @zag-js/svelte # or yarn add @zag-js/image-cropper @zag-js/svelte
Anatomy
To set up the image cropper correctly, you'll need to understand its anatomy and how we name its parts.
Each part includes a
data-partattribute to help identify them in the DOM.
Usage
First, import the image cropper package into your project:
import * as imageCropper from "@zag-js/image-cropper"
The package exports two key functions:
machine— The state machine logic for the cropper.connect— The function that translates the machine's state to JSX attributes and event handlers.
Next, import the required hooks and functions for your framework and use the image cropper machine in your project 🔥
import * as imageCropper from "@zag-js/image-cropper" import { normalizeProps, useMachine } from "@zag-js/react" import { useId } from "react" export function ImageCropper() { const service = useMachine(imageCropper.machine, { id: useId(), }) const api = imageCropper.connect(service, normalizeProps) return ( <div {...api.getRootProps()}> <div {...api.getViewportProps()}> <img src="https://picsum.photos/seed/crop/640/400" crossOrigin="anonymous" {...api.getImageProps()} /> <div {...api.getSelectionProps()}> {imageCropper.handles.map((position) => ( <div key={position} {...api.getHandleProps({ position })}> <span /> </div> ))} </div> </div> </div> ) }
import * as imageCropper from "@zag-js/image-cropper" import { normalizeProps, useMachine } from "@zag-js/solid" import { For, createMemo, createUniqueId } from "solid-js" export function ImageCropper() { const service = useMachine(imageCropper.machine, { id: createUniqueId(), }) const api = createMemo(() => imageCropper.connect(service, normalizeProps)) return ( <div {...api().getRootProps()}> <div {...api().getViewportProps()}> <img src="https://picsum.photos/seed/crop/640/400" crossOrigin="anonymous" {...api().getImageProps()} /> <div {...api().getSelectionProps()}> <For each={imageCropper.handles}> {(position) => ( <div {...api().getHandleProps({ position })}> <span /> </div> )} </For> </div> </div> </div> ) }
<script setup> import * as imageCropper from "@zag-js/image-cropper" import { normalizeProps, useMachine } from "@zag-js/vue" import { computed } from "vue" const service = useMachine(imageCropper.machine, { id: "image-cropper", }) const api = computed(() => imageCropper.connect(service, normalizeProps)) </script> <template> <div v-bind="api.getRootProps()"> <div v-bind="api.getViewportProps()"> <img src="https://picsum.photos/seed/crop/640/400" crossorigin="anonymous" v-bind="api.getImageProps()" /> <div v-bind="api.getSelectionProps()"> <div v-for="position in imageCropper.handles" :key="position" v-bind="api.getHandleProps({ position })" > <span /> </div> </div> </div> </div> </template>
<script lang="ts"> import * as imageCropper from "@zag-js/image-cropper" import { normalizeProps, useMachine } from "@zag-js/svelte" const id = $props.id() const service = useMachine(imageCropper.machine, { id, }) const api = $derived(imageCropper.connect(service, normalizeProps)) </script> <div {...api.getRootProps()}> <div {...api.getViewportProps()}> <img src="https://picsum.photos/seed/crop/640/400" crossorigin="anonymous" {...api.getImageProps()} /> <div {...api.getSelectionProps()}> {#each imageCropper.handles as position} <div {...api.getHandleProps({ position })}> <span /> </div> {/each} </div> </div> </div>
Setting the initial crop
Pass an initialCrop to start from a specific rectangle. The size is
constrained to your min/max and viewport, and the position is clamped within the
viewport.
const service = useMachine(imageCropper.machine, { initialCrop: { x: 40, y: 40, width: 240, height: 240 }, aspectRatio: 1, // optional, lock to square }) const api = imageCropper.connect(service, normalizeProps)
Fixed crop area
Lock the crop window and allow only panning/zooming of the image beneath it by
setting fixedCropArea: true.
const service = useMachine(imageCropper.machine, { fixedCropArea: true, })
Crop shape and aspect ratio
cropShapecan be"rectangle"or"circle".aspectRatiocan lock the crop to a width/height ratio. WhenaspectRatiois not set andcropShapeis"rectangle", holding Shift while resizing locks to the current ratio.
const service = useMachine(imageCropper.machine, { cropShape: "circle", aspectRatio: 1, // ignored for circle })
Controlling zoom, rotation, and flip
You can configure defaults and limits, and also control them programmatically using the API.
const service = useMachine(imageCropper.machine, { defaultZoom: 1.25, minZoom: 1, maxZoom: 5, defaultRotation: 0, defaultFlip: { horizontal: false, vertical: false }, }) const api = imageCropper.connect(service, normalizeProps) // Programmatic controls api.setZoom(2) // zoom to 2x api.setRotation(90) // rotate to 90 degrees api.flipHorizontally() // toggle horizontal flip api.setFlip({ vertical: true }) // set vertical flip on
Programmatic resizing
Use api.resize(handle, delta) to resize from any handle programmatically.
Positive delta grows outward, negative shrinks inward.
// Grow the selection by 8px from the right edge api.resize("right", 8) // Shrink from top-left corner by 4px in both axes api.resize("top-left", -4)
Getting the cropped image
Use api.getCroppedImage to export the current crop, taking
zoom/rotation/flip/pan into account.
// Blob (default) const blob = await api.getCroppedImage({ type: "image/png", quality: 0.92 }) // Data URL const dataUrl = await api.getCroppedImage({ output: "dataUrl", type: "image/jpeg", quality: 0.85, }) // Example usage if (blob) { const url = URL.createObjectURL(blob) previewImg.src = url }
Touch and wheel gestures
- Use the mouse wheel over the viewport to zoom at the pointer location.
- Pinch with two fingers to zoom and pan; the machine smooths tiny changes and tracks the pinch midpoint.
- Drag on the viewport background to pan the image (when not dragging the selection).
Keyboard nudges
Configure keyboard nudge steps for move/resize:
const service = useMachine(imageCropper.machine, { nudgeStep: 1, nudgeStepShift: 10, nudgeStepCtrl: 50, })
Accessibility
- The root is a live region with helpful descriptions of crop, zoom, and rotation status.
- The selection exposes slider-like semantics to assistive tech and supports keyboard movement, resizing (Alt+Arrows), and zooming (+/-).
- Customize accessible labels and descriptions via
translations:
const service = useMachine(imageCropper.machine, { translations: { rootLabel: "Product image cropper", selectionInstructions: "Use arrow keys to move, Alt+arrows to resize, and +/- to zoom.", }, })
Styling guide
Earlier, we mentioned that each image cropper part has a data-part attribute
added to them to select and style them in the DOM.
[data-scope="image-cropper"][data-part="root"] { /* styles for the root part */ } [data-scope="image-cropper"][data-part="viewport"] { /* styles for the viewport part */ } [data-scope="image-cropper"][data-part="image"] { /* styles for the image part */ } [data-scope="image-cropper"][data-part="selection"] { /* styles for the selection part */ } [data-scope="image-cropper"][data-part="handle"] { /* styles for the handle part */ }
Selection shapes
The selection can be styled based on its shape:
[data-part="selection"][data-shape="circle"] { /* styles for circular selection */ } [data-part="selection"][data-shape="rectangle"] { /* styles for rectangular selection */ }
States
Various states can be styled using data attributes:
[data-part="root"][data-dragging] { /* styles when dragging the selection */ } [data-part="root"][data-fixed] { /* styles when the crop area is fixed */ }
Keyboard Interactions
- ArrowUpMoves the crop selection upward by the configured nudge step. Hold Shift for the `nudgeStepShift` value or Ctrl/Cmd for `nudgeStepCtrl`.
- ArrowDownMoves the crop selection downward by the configured nudge step. Hold Shift for the `nudgeStepShift` value or Ctrl/Cmd for `nudgeStepCtrl`.
- ArrowLeftMoves the crop selection to the left by the configured nudge step. Hold Shift for the `nudgeStepShift` value or Ctrl/Cmd for `nudgeStepCtrl`.
- ArrowRightMoves the crop selection to the right by the configured nudge step. Hold Shift for the `nudgeStepShift` value or Ctrl/Cmd for `nudgeStepCtrl`.
- Alt + ArrowUpResizes the crop vertically from the bottom handle, reducing the height. Hold Shift or Ctrl/Cmd for the larger nudge steps.
- Alt + ArrowDownResizes the crop vertically from the bottom handle, increasing the height. Hold Shift or Ctrl/Cmd for the larger nudge steps.
- Alt + ArrowLeftResizes the crop horizontally from the right handle, reducing the width. Hold Shift or Ctrl/Cmd for the larger nudge steps.
- Alt + ArrowRightResizes the crop horizontally from the right handle, increasing the width. Hold Shift or Ctrl/Cmd for the larger nudge steps.
- +Zooms in on the image. The `=` key performs the same action on keyboards where both symbols share a key.
- -Zooms out of the image. The `_` key performs the same action on keyboards where both symbols share a key.
Methods and Properties
Machine Context
The image cropper machine exposes the following context properties:
idsPartial<{ root: string; viewport: string; image: string; selection: string; handle: (position: string) => string; }>The ids of the image cropper elementstranslationsIntlTranslationsSpecifies the localized strings that identify accessibility elements and their states.initialCropRectThe initial rectangle of the crop area. If not provided, a smart default will be computed based on viewport size and aspect ratio.minWidthnumberThe minimum width of the crop areaminHeightnumberThe minimum height of the crop areamaxWidthnumberThe maximum width of the crop areamaxHeightnumberThe maximum height of the crop areaaspectRationumberThe aspect ratio to maintain for the crop area (width / height). For example, an aspect ratio of 16 / 9 will maintain a width to height ratio of 16:9. If not provided, the crop area can be freely resized.cropShape"rectangle" | "circle"The shape of the crop area.zoomnumberThe controlled zoom level of the image.rotationnumberThe controlled rotation of the image in degrees (0 - 360).flipFlipStateThe controlled flip state of the image.defaultZoomnumberThe initial zoom factor to apply to the image.defaultRotationnumberThe initial rotation to apply to the image in degrees.defaultFlipFlipStateThe initial flip state to apply to the image.zoomStepnumberThe amount of zoom applied per wheel step.zoomSensitivitynumberControls how responsive pinch-to-zoom is.minZoomnumberThe minimum zoom factor allowed.maxZoomnumberThe maximum zoom factor allowed.nudgeStepnumberThe base nudge step for keyboard arrow keys (in pixels).nudgeStepShiftnumberThe nudge step when Shift key is held (in pixels).nudgeStepCtrlnumberThe nudge step when Ctrl/Cmd key is held (in pixels).onZoomChange(details: ZoomChangeDetails) => voidCallback fired when the zoom level changes.onRotationChange(details: RotationChangeDetails) => voidCallback fired when the rotation changes.onFlipChange(details: FlipChangeDetails) => voidCallback fired when the flip state changes.onCropChange(details: CropChangeDetails) => voidCallback fired when the crop area changes.fixedCropAreabooleanWhether the crop area is fixed in size and position.dir"ltr" | "rtl"The document's text/writing direction.idstringThe unique identifier of the machine.getRootNode() => ShadowRoot | Node | DocumentA root node to correctly resolve document in custom environments. E.x.: Iframes, Electron.
Machine API
The image cropper api exposes the following methods:
setZoom(zoom: number) => voidFunction to set the zoom level of the image.setRotation(rotation: number) => voidFunction to set the rotation of the image.setFlip(flip: Partial<FlipState>) => voidFunction to set the flip state of the image.flipHorizontally(value?: boolean) => voidFunction to flip the image horizontally. Pass a boolean to set explicitly or omit to toggle.flipVertically(value?: boolean) => voidFunction to flip the image vertically. Pass a boolean to set explicitly or omit to toggle.resize(handlePosition: HandlePosition, delta: number) => voidFunction to resize the crop area from a handle programmatically.getCroppedImage(options?: GetCroppedImageOptions) => Promise<string | Blob>Function to get the cropped image with all transformations applied. Returns a Promise that resolves to either a Blob or data URL.