Skip to navigation

Events

Two callbacks on Root report crop changes: onValueChange for live UI and onValueCommit for saving.

<Cropper.Root
  src={src}
  onValueChange={(value) => setAngle(value.rotation)}
  onValueCommit={(value) => save(value)}
/>

onValueChange runs at most once per frame while the crop moves. onValueCommit runs once when a gesture or a write is done. createCropper takes the same two options.

Save when the user lets go

A drag, a pinch or a turn of the dial commits once, when the finger lifts. A thrown dial commits when it stops. A key on Area, Dial or Zoom commits once when it comes up, however long it was held, or when focus leaves. A write such as rotate90() commits on the next frame, and the turn you see animates after it. A gesture that ends where it started does not commit. The first photo, or a photo after null, fits the crop without a commit. A later photo commits only when fitting it changes the value, such as a locked aspect on a photo of another shape, and that commit has the reason coverage. To save a photo nobody touched, read getValue() or getPixelCrop() from the cropper in actionsRef, see cropper.

stop() and unmount commit synchronously. React warns if onValueCommit calls flushSync inside that unmount, so leave flushSync out of the handler.

<Cropper.Root
  src={src}
  onValueCommit={(value, details) => {
    upload(value, details.pixelCrop);
  }}
/>

Follow one gesture from start to commit

Log both callbacks, then throw the dial:

<Cropper.Root
  src={src}
  onValueChange={(_, d) => console.log(d.reason, d.phase)}
  onValueCommit={(_, d) => console.log(d.reason, "commit")}
/>
LogWhen
dial startThe first frame the dial turns
dial moveEvery later frame of the drag and of the throw
dial endThe last frame of the throw
dial commitonValueCommit, in the same frame

A drag that stops before the finger lifts has no end, and its last move goes straight to the commit. A write outside a gesture logs one instant change, then its commit.

Reject a change

Call details.cancel() and the crop snaps back to where the last change left it. This keeps the angle within 10° each way:

<Cropper.Root
  src={src}
  onValueChange={(value, details) => {
    if (Math.abs(value.rotation) > 10) details.cancel();
  }}
/>

Change details

The second argument of onValueChange:

Field
reason ChangeReason
Names what moved the crop, see reasons below
phase ChangePhase
Is start, move, end or instant, see the order above
changed readonly ValueField[]
Lists the fields that moved. Never empty
previous CropValue
Holds what the last change left
event Event | undefined
Holds the DOM event behind the change
cancel () => void
Rejects this change
isCanceled boolean
Is true once cancel() ran

Commit details

The second argument of onValueCommit:

Field
reason ChangeReason
Names the gesture or write behind the commit
previous CropValue
Holds the last committed value. Push it to a stack for undo
pixelCrop ExportPlan | null
Holds the export plan at scale 1 in source pixels, or null before a photo loads. Use it for your own tools. Server crop sends the value from getValue() and recomputes the plan on the server, see exporting
event Event | undefined
Holds the event that ended the gesture, or the one passed with the write

Reasons

Every change and commit names its reason. Inside a gesture, a write without its own reason reports the gesture's reason.

ReasonSent by
panOne finger or pointer dragging the photo, and panBy()
pinchTwo fingers pinching or twisting, or a trackpad pinch in Safari
wheelA mouse wheel or trackpad scrolling or zooming the photo
resizeA handle being dragged
dialThe dial being dragged or thrown
keyboardKeys on Area and Dial. Keys on Zoom report zoom-control on each change and keyboard on the commit
zoom-controlThe Zoom slider, ZoomStep buttons and useZoom
aspectAspectItem buttons, setAspect() and the aspect prop
orientationThe Orientation button, which swaps portrait and landscape
rotate-90Rotate buttons and rotate90()
flipFlip buttons and flip()
resetReset and reset()
coverageA later photo of another size, or new options, that refit the crop. The first photo sends nothing
historyYour own writes only. zriz never sends it, so undo can skip those writes. Root animates them like reset()
controlledThe rotation and zoom props, on the commit and on a change when zriz applied another value, and shape="circle" setting the aspect to 1 without a morph. A new value prop sends nothing
imperativesetValue(), setRotation(), setZoom() and setCropState() called outside a gesture without a reason

Listen on the cropper

The cropper emits the two callbacks as change and commit, plus six more events. Under Root or useCropper, change also fires when a controlled prop lands as passed, which onValueChange skips. In React, make the cropper with useCropper and listen in an effect: useEffect(() => cropper.on("settle", onSettle), [cropper]), see hooks. createCropper comes from @zriz/core, installed as in exporting. on() returns a function that removes the listener:

import { createCropper } from "@zriz/core";

const cropper = createCropper();
const off = cropper.on("settle", () => {
  console.log("the crop is still");
});

off();
EventListener getsWhat it does
gesturestartreason, eventFires when an input takes hold
gestureendreason, eventFires when end() runs. For a thrown dial, that is when the throw stops
settlenothingFires each time the last animation stops, springs included. stop() ends motion with no settle, so listen to stop for that case
frame{ time, value, state }Fires on every frame that moved. useCropperFrame wraps it
stopnothingFires when stop() ends all motion
destroynothingFires once, when destroy() runs

A listener that throws stops nothing. The other listeners still run, the crop keeps working, and the error comes back as an uncaught error on a microtask.