zriz vs Cropper.js
Cropper.js is a JavaScript image cropper made of web components that runs without a framework. zriz is a set of React parts where a drag moves the photo under the crop by default and the saved crop stays on the photo.
We make zriz, so read this page with that in mind. It compares cropperjs 2.3.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 cropperjs to zriz. Read https://zriz.dev/docs/zriz-vs-cropperjs.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
Cropper.js calls itself a "JavaScript image cropper", and its guide says "Cropper.js 2.0 is a series of web components for image cropping". The npm name cropperjs holds two products. Version 2 is the set of custom elements this page covers. Version 1 is an imperative class, and npm's per-version counts put over 80% of the weekly downloads on it. The react-cropper page covers version 1.
Pick Cropper.js when the page has no React or the cropper has to load from one script tag. It has been on npm since 2015 and has over 2 million downloads a week. Its basic example is about 13 kB, against 22 to 30 kB for zriz. It paints its own crop box and turns the photo to any angle. It keeps several selections on one photo, lets the user draw a box with a drag and rounds the output to any radius.
Pick zriz when the app is React with server rendering and a drag should move the photo under the crop by default. In Cropper.js the photo moves under a fixed box once the canvas handle has action="move" and the selection drops movable. The saved crop stays on the photo at every angle zriz allows. The crop area, dial and zoom slider take focus and keys, and the export is a JPEG, PNG or WebP blob. zriz is new. Its first release was in November 2026, it has one maintainer and under a thousand downloads a week.
Numbers
| Fact | Cropper.js | zriz |
|---|---|---|
| First release | 2015 | November 2026 |
| npm downloads a week | 2.5M+ | Under 1,000 |
| GitHub stars | 13k+ | Under 100 |
| Licence | MIT | MIT |
| Dependencies | @cropper/elements and @cropper/utils, same author | None, @zriz/core and React are peers |
| React | Not needed, no React code | 18.2 and 19 |
| Size | About 13 kB | 22 to 30 kB |
Size is minified JavaScript and CSS, compressed with Brotli, without React. For Cropper.js it is the basic example from its docs, built with esbuild. That example is new Cropper("#image") with the built-in template and has no CSS file. 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.
The first stable 2.0.0 came out in March 2025. The download count covers both versions of Cropper.js.
Features
| Feature | Cropper.js | zriz |
|---|---|---|
| Crop box with handles | YesEight handles in the default template | YesFour corners and four edges |
| Photo moves under the frame | YesWith action="move" on the canvas handle | Yes |
| Aspect ratio | YesFree or any ratio | YesFree, original or any ratio |
| Round crop | YesMask and export, any border radius | YesMask and export |
| Zoom | YesWheel, pinch and the $zoom() method | YesPinch, Ctrl + wheel, slider part, keys |
| Straighten (free rotation) | YesAny angle by method or two fingers | Partly45° each way plus quarter turns |
| Crop stays inside the photo at an angle | NoInsets test boxes against the canvas, not the turned photo's edges | YesThe crop shrinks to fit |
| Quarter turns and flip | YesBy method, without animation | YesAnimated |
| Touch gestures | YesPinch, twist and handles | YesPan, pinch, twist, two handles at once |
| Keyboard | PartlyOpt-in arrows, +, - and Delete | YesCrop area, dial, zoom and ratio group; handles no |
| Screen reader | PartlyPhoto alt text; the box has no role | PartlyRoles and live region; you write the labels |
| Image export | PartlyA canvas; the app makes the file | YesJPEG, PNG or WebP blob |
| Saved crop | YesCanvas CSS px plus a matrix | YesFractions of the photo, restores exactly |
| Ready-made UI | PartlyPainted crop box and grid; no controls | NoUnstyled parts and an optional stylesheet |
$toCanvas() returns a canvas at the size of the box on screen unless the app passes width or height. In Chromium a 240 by 160 px box that covered 1200 by 800 px of a 2400 by 1600 px photo exported a 240 by 160 px canvas. By the source, { width: 1200 } on the same call gives 1200 by 800 px. We did not run that case in Chromium. zriz cuts from the source pixels by default. Its crop of 0.738 of each side at 15° exported 1772 by 1181 px.
The Cropper.js box sits in canvas space, so it can cover the area beside a turned photo, and the export leaves that area transparent. At 15° with the default box, which is half the photo's width and height, the Chromium run found no empty area on screen or in the file.
What you will notice
Cropper.js needs less code to show a cropper. new Cropper(image) writes the canvas, grid, crosshair, eight handles and a hidden shade into the page and paints them from each element's shadow root. Its basic example is also smaller than zriz, see the numbers above. With zriz the app writes each part as JSX and gives Viewport a height. The optional stylesheet paints the mask, frame, handles, grid and dial, and nothing else.
Importing cropperjs on a server throws ReferenceError: HTMLElement is not defined in Node 24, because the base class extends `HTMLElement` when the module loads. A server-rendered app has to load it in the browser, as the Cropper.js docs site does with ClientOnly. The package has no JSX types, so <cropper-canvas> in TSX fails with TS2339 until the app declares the elements. zriz parts render to HTML on the server and are client components.
Cropper.js cancels every wheel event over the canvas and zooms by one scale-step, 0.1 by default. So the page does not scroll while the pointer is over the cropper, and disabled on the canvas is the one switch for it. That comes from the source. We did not measure page scroll in the Chromium run. In zriz a plain wheel pans the photo and Ctrl + wheel zooms. A wheel that starts with the photo already at its edge scrolls the page. wheel="zoom" on Viewport makes a plain wheel zoom, as in Cropper.js.
Rotation works differently. In Cropper.js $rotate() takes any angle, two fingers turn the photo, and the box does not change. In zriz the dial stops at 45° each way and quarter turns cover the rest, so 60° is one quarter turn and -30°. At an angle zriz shrinks the crop until its four corners are on the photo. At 15° a full-photo crop of the 2400 by 1600 px test photo became 0.738 of each side. That holds for the saved value and the exported file. While a finger pulls the photo past an edge, the background shows until release.
In Chromium the Cropper.js demo had no tab stop inside the cropper. Its keyboard attribute is off by default and listens on `document`. Arrows move the box 1 px wherever focus is, unless focus is in an input, a textarea or editable text. + grows it by 10% and - undoes one such step when the selection has zoomable, and Delete clears it. The listener stays until keyboard is set to false. The zriz demo was the full editor from its docs and had six tab stops: the crop area, the dial, the zoom slider, two buttons and the ratio group. The component below has one inside the cropper, the crop area. The crop area is a focusable role="application", the dial and zoom are sliders, and the handles take no keys. zriz ships no words, so the app writes each label.
Migrate from Cropper.js
zriz has no equivalent for several selections on one photo, a box the user draws with a drag, a box that moves over a still photo, or a state with no box. It has no crop that reaches outside the photo, no single angle past 45°, no non-uniform scale and no skew. It has no beforeDraw canvas hook, no canvas result and no rounded-rectangle output, and Root cuts a circle, not an ellipse. It has no script-tag build and no web components, though a plain TypeScript core with DOM binders exists, see vanilla JS. If the project needs one of these, stay on Cropper.js.
new Cropper(image, { container })Rootwithsrc, thenViewport,Image,Mask,Areacropper-handlewith a*-resizeactionHandlewithpositioncropper-gridGridcropper-shadeMaskaspect-ratioon the selectionaspectonRoot,setAspect(),AspectGroupinitial-coverage,x,y,width,heightdefaultValueborder-radiusshape="circle", a circle and no other radiusmin-inset,max-insetNo setting. zriz keeps the saved crop on the photo, not inside the canvas- Wheel zoom with
scale-stepwheel="zoom"onViewport $zoom(),$scale()setZoom(),Zoom,maxZoom. 1 is the largest crop that fits$rotate()setRotation()up to 45° each way,rotate90(),Dial,Rotate$scale(-1, 1),$scale(1, -1)flip(),flip("y"),Flip$move(),$moveTo()panBy(), in fractions of the photochangeon the selectiononValueChangeactionendon the canvasonValueCommittransformevent withpreventDefault()details.cancel()inonValueChange- Selection
x,y,width,heightand$getTransform()getValue(), or the value inonValueCommit $toCanvas()exportCrop(), which resolves to an object with ablobcropper-vieweruseCropperFrame, orexportCropwith a smallmaxSizekeyboardKeys onAreawhile it has focusdisabledon the canvasdisabledonRoot$resetTransform(),$reset()reset(),Resetdestroy()UnmountRoot
A saved Cropper.js crop is the selection's x, y, width and height in CSS px of the canvas, plus the matrix [a, b, c, d, e, f] from $getTransform(). zriz has no importer, so convert each one. The conversion below holds for a uniform scale, no skew and a crop that lies inside the photo, where W and H are the photo's natural size.
Take s = Math.hypot(a, b) and mirrored = a * d - b * c < 0. The total angle is Math.atan2(-b, -a) when mirrored and Math.atan2(b, a) when not, turned from radians into degrees. With q = Math.round(total / 90), rotation = total - 90 * q and quarterTurns is q modulo 4. tw and th are W and H for an even quarterTurns, and H and W for an odd one. Then center.x = (x + width / 2 - W / 2 - e) / s / tw + 0.5, center.y = (y + height / 2 - H / 2 - f) / s / th + 0.5, size.width = width / s / tw and size.height = height / s / th.
We checked this conversion in Node against zriz 0.2.0 for six crops. The check used a rewrite of the Cropper.js drawing maths, not Cropper.js in a browser, so test it on a few of your own crops first. A crop that reaches outside the photo does not convert exactly, because zriz moves it back onto the photo.
The steps: install @zriz/react and @zriz/core, and remove cropperjs with its client-only import and any element type declarations. Replace the template with zriz parts. Move each method call and event to its row in the table. Replace $toCanvas() and toBlob() with exportCrop(). Convert saved crops with the maths above and pass them as value or defaultValue. Delete the keyboard = false cleanup if the project has one.
"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 { useEffect, useRef, useState } from "react";
type Props = { src: string; onSave: (file: Blob) => void };
const handles = [
"top-left",
"top",
"top-right",
"right",
"bottom-right",
"bottom",
"bottom-left",
"left",
] as const;
const half = { size: { width: 0.5, height: 0.5 } };
export const PhotoCropper = ({ src, onSave }: Props) => {
const actions = useRef<CropperApi>(null);
const running = useRef<AbortController | null>(null);
const [loaded, setLoaded] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [failed, setFailed] = useState(false);
useEffect(() => () => running.current?.abort(), [src]);
const save = async () => {
if (!actions.current || running.current) return;
const controller = new AbortController();
running.current = controller;
setSaving(true);
setFailed(false);
try {
const { blob } = await exportCrop(actions.current, {
type: "image/jpeg",
quality: 0.9,
maxSize: { width: 2048, height: 2048 },
signal: controller.signal,
});
onSave(blob);
} catch {
setFailed(!controller.signal.aborted);
} finally {
running.current = null;
setSaving(false);
}
};
return (
<>
<Cropper.Root
key={src}
actionsRef={actions}
src={src}
defaultValue={half}
onLoad={() => setLoaded(src)}
onError={() => setFailed(true)}
>
<Cropper.Viewport
wheel="zoom"
style={{ maxWidth: 480, height: 360, padding: 24 }}
>
<Cropper.Image />
<Cropper.Mask />
<Cropper.Area aria-label="Crop area">
<Cropper.Grid />
{handles.map((position) => (
<Cropper.Handle
key={position}
position={position}
/>
))}
</Cropper.Area>
</Cropper.Viewport>
</Cropper.Root>
{failed && <p role="alert">That failed. Try again.</p>}
<button
type="button"
disabled={loaded !== src}
aria-disabled={saving}
onClick={save}
>
Save
</button>
</>
);
};This component is the nearest zriz match for new Cropper(image) with the default template: a crop box with eight handles and a grid, a wheel that zooms, and a start crop of half the photo on each side. A drag pans the photo under the box. It does not draw a new box or move the box over a still photo. save exports a JPEG of at most 2048 by 2048 px and hands it to onSave. The app still writes the layout, the button styles and the words. A zoom slider, rotate and flip buttons and an aspect picker are parts the app adds and paints, see full editor. 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. Cropper.js ran as its default template with keyboard added, and zriz ran as the full editor from its docs. The script presses keys on each tab stop. Cropper.js had none, so its key behaviour comes from the source, and we pressed no keys on it.
Cropper.js: repository, docs, npm. Its migration guide maps version 1 options to version 2.
More comparisons
- zriz vs react-cropperThe React wrapper around Cropper.js version 1
- zriz vs Uppy image editorCropper.js version 1 inside an upload dashboard with buttons
- zriz vs react-advanced-cropperA React cropper with a box, handles and any rotation angle
Every comparison is on the comparison page.