zriz vs Uppy Image Editor
Uppy Image Editor is a plugin of the Uppy file uploader that crops files in its Dashboard with a ready toolbar. zriz is a set of unstyled React parts for a cropper that you lay out and label yourself.
We make zriz, so read this page with that in mind. It compares @uppy/image-editor 5.0.1 with @zriz/react 0.2.1. If something here is wrong, open an issue and we will fix it.
To move a project over, paste this into Claude Code, Cursor or another coding agent:
Migrate this project from @uppy/image-editor to zriz. Read https://zriz.dev/docs/zriz-vs-uppy-image-editor.md and follow its section on migrating. Keep the crop behaviour the same where zriz can, and list for me anything zriz has no equivalent for before you remove it.
In short
The Uppy docs describe it as "Image editor. Designed to be used with the Dashboard UI." It is a plugin of the Uppy file uploader, it edits files that are already in the Uppy queue, and it wraps Cropper.js 1. A fresh install gives Cropper.js 1.7.0. Its README marks it as in beta.
Pick Uppy Image Editor when the app uploads with Uppy or needs an uploader. Two use() calls give a toolbar with icons, Save, Cancel and English labels that the locale option or some locale packs replace, and the edited file takes the original's place in the upload queue. An app that already loads Uppy and the Dashboard adds about 17 kB for it. The useImageEditor hook in @uppy/react runs the editor under your own toolbar, without the Dashboard. Uppy has Dashboard components for React, Vue, Svelte and Angular. Through Cropper.js it can draw a new crop box with a drag, take a fillColor for any exported area outside the photo and run a beforeDraw hook on the export. The package has been on npm since 2020 and has over 270,000 downloads a week.
Pick zriz when the page needs a cropper on its own, such as an avatar or cover field in a form. zriz adds 22 to 30 kB to the bundle, against about 89 kB for Uppy's documented setup with its uploader and Dashboard, or about 35 kB for Uppy's useImageEditor hook without the Dashboard. The crop is a value you can save and restore, the crop area takes keys, and the photo moves under the frame with a pinch and a two-finger twist. You write the layout, the styles and every label. zriz had its first release in November 2026, has one maintainer and under a thousand downloads a week.
Numbers
| Fact | Uppy Image Editor | zriz |
|---|---|---|
| First release | 2020 | November 2026 |
| npm downloads a week | 270k+ | Under 1,000 |
| GitHub stars | 31k+, the whole Uppy repo | Under 100 |
| Licence | MIT | MIT |
| Dependencies | Cropper.js and Preact, @uppy/core as a peer | None, @zriz/core and React as peers |
| React | Not needed, @uppy/react takes 18 and 19 | 18.2 and 19 |
| Size | About 89 kB, about 17 kB as a plugin | 22 to 30 kB |
Size is minified JavaScript and CSS, compressed with Brotli, without React. For Uppy Image Editor it is the basic example from its docs, built with esbuild. It holds @uppy/core, the Dashboard with its file picker, Cropper.js and the Save code, and in an app that already loads Uppy and the Dashboard the plugin adds about 17 kB. The useImageEditor hook in @uppy/react runs the editor without the Dashboard, at about 35 kB with its stylesheet. The uppy.io pages we read do not describe it. The documented Uppy setup installs 22 packages, or 28 with @uppy/react. For zriz it depends on the parts you import: about 22 kB for Root, Viewport, Image, Mask and Area, and about 29 kB with every part. Up to 11 kB more loads later, for reading the file, export, sharpening and SVG. We measured the zriz sets with Rolldown. Built with esbuild as one file, the way the other example was, zriz comes to about 33 kB for those five parts and about 41 kB with every part, because the code that loads later is then in that file. Bundle size has each set. @zriz/react needs @zriz/core and React as peers.
Features
| Feature | Uppy Image Editor | zriz |
|---|---|---|
| Crop box with handles | YesFour corners and four edges | YesFour corners and four edges |
| Photo moves under the frame | PartlyAfter a double click or by option | Yes |
| Aspect ratio | YesThree ratio buttons, any ratio by option | YesFree, original or any ratio |
| Round crop | PartlyCropper.js borderRadius, mask on screen not verified | YesMask and export |
| Zoom | YesWheel, pinch and two toolbar buttons | YesPinch, Ctrl + wheel, slider part, keys |
| Straighten (free rotation) | PartlySlider 45° each way plus quarter turns | Partly45° each way plus quarter turns |
| Crop stays inside the photo at an angle | PartlyDefault box filled at 15° in Chromium | YesThe crop shrinks to fit |
| Quarter turns and flip | YesCounter-clockwise turn, horizontal flip, not animated | YesAnimated |
| Touch gestures | PartlyBox drag and pinch, no twist | YesPan, pinch, twist, two handles at once |
| Keyboard | PartlyToolbar buttons and slider; crop box no | YesCrop area, dial, zoom and ratio group; handles no |
| Screen reader | PartlyLabelled buttons; crop box has no role | PartlyRoles and live region; you write the labels |
| Image export | YesA File, asks for the source format | YesJPEG, PNG or WebP blob |
| Saved crop | PartlyThrough Cropper.js getData() and data | YesFractions of the photo, restores exactly |
| Ready-made UI | YesToolbar, Save and Cancel in the Dashboard | NoUnstyled parts and an optional stylesheet |
With the default box at 15°, the file Uppy exported in Chromium had no empty corner. Uppy runs Cropper.js in view mode 0 and pulls the box back inside the bounding box of the turned photo when a drag ends. That bounding box includes empty corner triangles, so the code suggests a dragged box can reach one. None of our runs showed it. In zriz the saved value and the exported file stay inside the photo. While a finger pulls the photo past an edge, the background shows until release.
The round crop row rests on Cropper.js 1.7.0, which cuts the exported canvas to borderRadius. Uppy has no option of its own for it, and a lockfile that pins Cropper.js 1.6 has none. The saved crop row rests on plugin.cropper, a public property that the docs page does not list. Its getData() returns the crop rectangle, and cropperOptions.data sets a starting crop. Save replaces the file, so the editor cannot reopen the original with the old crop.
What you will notice
Uppy does the whole job around the crop. The Dashboard picks the file, the editor opens on the edit button or with autoOpen: 'imageEditor', and Save puts the edited file into the upload queue with no app code. zriz has no file picker, no uploader, no icons and no words. The app writes all of that.
The crop model differs. In Uppy a box moves and resizes over a still photo, and a drag outside the box draws a new one. A wheel turn over the editor zooms in 10% steps, at most one step per 50 ms, and the page does not scroll. In zriz a drag moves the photo under the frame, a plain wheel pans it and Ctrl + wheel zooms.
Straighten zooms the photo in both. Uppy takes the factor from the photo and the angle and ignores the crop box, so a small crop zooms as much as a full one. A 2400 by 1600 photo at 15° scaled by 1.354 in Chromium. The function is in getScaleFactorThatRemovesDarkCorners.ts. zriz shrinks the crop by the least that keeps its four corners on the photo. A quarter turn in Uppy sets the slider back to 0 and the crop box back to the whole image, see rotateBy in ImageEditor.tsx. In zriz the crop turns with the photo.
From the keyboard, Uppy's toolbar works as native buttons and one range input, and the Dashboard keeps Tab inside the open editor. The crop box is not a tab stop and takes no keys. The straighten slider and the quarter turn button share the label Rotate 90°. Nothing announces a crop change. In zriz the crop area is one tab stop with arrow, zoom and reset keys, and a live region can speak each change. The handles take no keys, and the app writes every label.
Save in Uppy replaces the file with the cropped image. It asks the browser for the source format at quality 0.8, which Chromium gave for a WebP photo, and it runs on the main thread. We did not run GIF or BMP. The editor keeps no crop, so opening it again starts from the cropped result. zriz keeps the original and hands back a value. It makes a file when you call exportCrop, in a worker by default, with a fallback to the main thread where workers or OffscreenCanvas are missing or blocked.
Migrate from Uppy Image Editor
zriz has no equivalent for the uploader, so it replaces the editor and not Uppy. It has no finished toolbar and no language packs. It has no official Vue, Svelte or Angular components. A crop cannot extend past the photo, so there is no fillColor. The export has no beforeDraw hook, no rounded rectangle or ellipse, and no scaling up to an exact width and height. The zriz docs describe no way to draw a new crop box with a drag.
uppy.use(ImageEditor)inside the DashboardCropper.Rootwithsrc={file}andViewport,Image,Mask,Area,Handle- Dashboard
autoOpen: 'imageEditor'Render the cropper when your state holds a file actions.revertCropper.Resetactions.rotateCropper.Rotate, counter-clockwise by defaultactions.granularRotateCropper.Dial, same rangeactions.flipCropper.Flipactions.zoomIn,actions.zoomOutCropper.ZoomStepwith afactor, orCropper.Zoomactions.cropSquare,cropWidescreen,cropWidescreenVerticalCropper.AspectItemwithvalue={1},{16 / 9},{9 / 16}qualityqualityin theexportCropoptionscropperOptions.aspectRatioaspectonRootcropperOptions.initialAspectRatioNo prop for it.defaultValueonRootwith asizesets the first crop and leaves the ratio freecropperOptions.minCropBoxWidth,minCropBoxHeight, page pixelsminSizeonRoot, source pixelscropperOptions.zoomOnWheelwheelonViewportcropperOptions.borderRadiusshape="circle"onRoot, a circle and no other shapecropperOptions.datavalueordefaultValueonRootcropperOptions.crop,cropendonValueChange,onValueCommitcroppedCanvasOptions.maxWidth,maxHeightmaxSizein theexportCropoptionsfile-editor:startThe render of your cropper.onLoadonRootfires later, when the photo is ready- Save,
file-editor:completeYour button that callsexportCrop - Cancel,
file-editor:cancelYour button locale.stringsaria-labeland children on each partplugin.cropper.getData()getValue()
Uppy saves no crop, so most apps have nothing to convert. An app that stored the result of plugin.cropper.getData() has a rectangle in pixels plus rotate, scaleX and scaleY. zriz has no importer for it. With rotate at 0 and both scales at 1 the rectangle is in source pixels, and value describes the fractions zriz stores. We did not check the conversion with a rotation or a flip.
Take the file from your own input or from Uppy and pass it to Root as src. Build the toolbar from the parts in the table. Call exportCrop in your Save button. To keep Uppy as the uploader, write the result back the way the plugin does. Call uppy.setFileState(id, { data: file, size: file.size, preview: undefined }), then uppy.emit('thumbnail:request', uppy.getFile(id)). Remove @uppy/image-editor and its stylesheet last.
"use client";
import { exportCrop } from "@zriz/core/dom";
import type { Cropper as CropperApi } from "@zriz/react";
import * as Cropper from "@zriz/react/cropper";
import "@zriz/react/styles.css";
import { useRef, useState } from "react";
type Props = {
file: File;
onSave: (edited: File) => void;
onCancel: () => void;
};
const handles = [
"top-left",
"top",
"top-right",
"right",
"bottom-right",
"bottom",
"bottom-left",
"left",
] as const;
const types = [
"image/jpeg",
"image/png",
"image/webp",
] as const;
const ratios = [
{ label: "Free", value: "free" },
{ label: "Crop square", value: 1 },
{ label: "Crop landscape (16:9)", value: 16 / 9 },
{ label: "Crop portrait (9:16)", value: 9 / 16 },
] as const;
export const ImageEditor = ({
file,
onSave,
onCancel,
}: Props) => {
const actions = useRef<CropperApi>(null);
const [ready, setReady] = useState(false);
const [saving, setSaving] = useState(false);
const [failed, setFailed] = useState(false);
const save = async () => {
if (!actions.current || saving) return;
setSaving(true);
setFailed(false);
try {
const { blob, type } = await exportCrop(actions.current, {
type: types.find((known) => known === file.type),
quality: 0.8,
});
onSave(new File([blob], file.name, { type }));
} catch {
setFailed(true);
} finally {
setSaving(false);
}
};
return (
<Cropper.Root
actionsRef={actions}
src={file}
onLoad={() => setReady(true)}
onError={() => setFailed(true)}
>
<button type="button" onClick={onCancel}>
Cancel
</button>
<button
type="button"
disabled={!ready}
aria-disabled={saving}
onClick={save}
>
Save
</button>
<Cropper.Viewport
wheel="zoom"
style={{ height: 440, padding: 24 }}
>
<Cropper.Image />
<Cropper.Mask />
<Cropper.Area aria-label={`Crop area of ${file.name}`}>
<Cropper.Grid />
{handles.map((position) => (
<Cropper.Handle
key={position}
position={position}
/>
))}
</Cropper.Area>
</Cropper.Viewport>
<Cropper.Reset>Reset</Cropper.Reset>
<Cropper.Rotate>Rotate 90°</Cropper.Rotate>
<Cropper.Dial aria-label="Straighten">
<Cropper.DialTicks />
</Cropper.Dial>
<Cropper.Flip>Flip horizontally</Cropper.Flip>
<Cropper.ZoomStep factor={1.1}>Zoom in</Cropper.ZoomStep>
<Cropper.ZoomStep factor={1 / 1.1}>
Zoom out
</Cropper.ZoomStep>
<Cropper.AspectGroup aria-label="Aspect ratio">
{ratios.map((ratio) => (
<Cropper.AspectItem
key={ratio.label}
value={ratio.value}
>
{ratio.label}
</Cropper.AspectItem>
))}
</Cropper.AspectGroup>
{failed && (
<p role="alert">This photo did not open or save.</p>
)}
</Cropper.Root>
);
};The component has the same controls as Uppy's toolbar, with Uppy's default English labels on the buttons. Three controls differ. It adds a Free ratio button, where Uppy returns to free through Reset. Reset stays hidden until the crop changes. The dial has the label Straighten. A plain wheel zooms, as in Uppy, and the step size is zriz's own. Save returns a File with the old name at quality 0.8. JPEG and PNG keep their format, WebP keeps it where the browser can write WebP, and any other type comes out as JPEG, so rename the file when the type changes. The app still writes the layout, the styles for every button and the ratio group, icons if it wants them, and translations. Blocks for shadcn/ui are on the shadcn page.
How we checked
We installed the published npm package at the version above and read its source and docs. Then we ran both libraries in headless Chromium with the same photo and the same script: tab order, keys, ARIA attributes, mouse, wheel, touch and a 15° turn. Uppy ran inside an inline Dashboard through @uppy/react 6.1.1 with @uppy/core 6.3.1 and @uppy/dashboard 6.0.3.
Uppy: repository, image editor docs, npm page.
More comparisons
- zriz vs Cropper.jsCropper.js 2, the rewrite of the library Uppy's editor wraps in version 1
- zriz vs react-cropperCropper.js 1.7.0, the version Uppy's editor wraps, as a React component with no uploader
- zriz vs antd-img-cropAnother cropper that opens from an upload component, for Ant Design
Every comparison is on the comparison page.