Skip to navigation

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

FactUppy Image Editorzriz
First release2020November 2026
npm downloads a week270k+Under 1,000
GitHub stars31k+, the whole Uppy repoUnder 100
LicenceMITMIT
DependenciesCropper.js and Preact, @uppy/core as a peerNone, @zriz/core and React as peers
ReactNot needed, @uppy/react takes 18 and 1918.2 and 19
SizeAbout 89 kB, about 17 kB as a plugin22 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

FeatureUppy Image Editorzriz
Crop box with handlesYesFour corners and four edgesYesFour corners and four edges
Photo moves under the framePartlyAfter a double click or by optionYes
Aspect ratioYesThree ratio buttons, any ratio by optionYesFree, original or any ratio
Round cropPartlyCropper.js borderRadius, mask on screen not verifiedYesMask and export
ZoomYesWheel, pinch and two toolbar buttonsYesPinch, Ctrl + wheel, slider part, keys
Straighten (free rotation)PartlySlider 45° each way plus quarter turnsPartly45° each way plus quarter turns
Crop stays inside the photo at an anglePartlyDefault box filled at 15° in ChromiumYesThe crop shrinks to fit
Quarter turns and flipYesCounter-clockwise turn, horizontal flip, not animatedYesAnimated
Touch gesturesPartlyBox drag and pinch, no twistYesPan, pinch, twist, two handles at once
KeyboardPartlyToolbar buttons and slider; crop box noYesCrop area, dial, zoom and ratio group; handles no
Screen readerPartlyLabelled buttons; crop box has no rolePartlyRoles and live region; you write the labels
Image exportYesA File, asks for the source formatYesJPEG, PNG or WebP blob
Saved cropPartlyThrough Cropper.js getData() and dataYesFractions of the photo, restores exactly
Ready-made UIYesToolbar, Save and Cancel in the DashboardNoUnstyled 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.Root with src={file} and Viewport, Image, Mask, Area, Handle
  • Dashboard autoOpen: 'imageEditor'Render the cropper when your state holds a file
  • actions.revertCropper.Reset
  • actions.rotateCropper.Rotate, counter-clockwise by default
  • actions.granularRotateCropper.Dial, same range
  • actions.flipCropper.Flip
  • actions.zoomIn, actions.zoomOutCropper.ZoomStep with a factor, or Cropper.Zoom
  • actions.cropSquare, cropWidescreen, cropWidescreenVerticalCropper.AspectItem with value={1}, {16 / 9}, {9 / 16}
  • qualityquality in the exportCrop options
  • cropperOptions.aspectRatioaspect on Root
  • cropperOptions.initialAspectRatioNo prop for it. defaultValue on Root with a size sets the first crop and leaves the ratio free
  • cropperOptions.minCropBoxWidth, minCropBoxHeight, page pixelsminSize on Root, source pixels
  • cropperOptions.zoomOnWheelwheel on Viewport
  • cropperOptions.borderRadiusshape="circle" on Root, a circle and no other shape
  • cropperOptions.datavalue or defaultValue on Root
  • cropperOptions.crop, cropendonValueChange, onValueCommit
  • croppedCanvasOptions.maxWidth, maxHeightmaxSize in the exportCrop options
  • file-editor:startThe render of your cropper. onLoad on Root fires later, when the photo is ready
  • Save, file-editor:completeYour button that calls exportCrop
  • Cancel, file-editor:cancelYour button
  • locale.stringsaria-label and children on each part
  • plugin.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

Every comparison is on the comparison page.