# zriz vs Ark UI Image Cropper

> [llms.txt](https://zriz.dev/llms.txt)

Ark UI Image Cropper is a set of unstyled React parts on the Zag state machine, with a frame the user drags over the photo. zriz moves the photo under the frame and keeps the crop inside the photo when it tilts.

We make zriz, so read this page with that in mind. It compares `@ark-ui/react` 5.40.0 with `@zag-js/image-cropper` 1.47.0 with `@zriz/react` 0.2.1. If something here is wrong, [open an issue](https://github.com/pqoqubbw/zriz/issues) and we will fix it.

To move a project over, paste this into Claude Code, Cursor or another coding agent:

```text
Migrate this project from the Ark UI Image Cropper in @ark-ui/react to zriz. Read https://zriz.dev/docs/zriz-vs-ark-ui.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

Ark UI describes its Image Cropper as "Crop and transform images with zoom, rotation, and aspect ratio controls." It is a set of unstyled parts in `@ark-ui/react` on top of the `@zag-js/image-cropper` state machine, and the [Ark UI docs](https://ark-ui.com/docs/components/image-cropper) mark it "preview". The machine first shipped in October 2025.

Pick Ark UI when the app already uses Ark UI or Chakra UI 3, because `@chakra-ui/react` 3.38.0 depends on `@ark-ui/react` and the cropper is already installed. Pick it for Solid, Vue or Svelte, where Ark UI ships the same component, or for React 18.0 or 18.1 or a CommonJS build. Pick it when the user drags a frame across a still photo, when a rotated crop may keep clear corners, or when you want English screen reader labels without writing them. Pick it for one angle past 45°, a maximum crop size, key steps in pixels or a data URL from the export call. Pick it when size comes first, at about 16 kB against 22 to 30 kB for zriz.

Pick zriz when the saved crop and the file must stay inside the photo, because zriz shrinks the crop to fit when the photo tilts. Pick it when you want a straighten dial, a two-finger twist, a zoom slider and rotate, flip and reset buttons as parts, a round file, or a saved crop that restores in a viewport of any size. zriz is new and little used. Its first release was in November 2026. It has one maintainer, versions at 0.x and under a thousand downloads a week, against over 1 million for `@ark-ui/react`.

## Numbers

| Fact | Ark UI | zriz |
| - | - | - |
| First release | October 2025, as `@zag-js/image-cropper` | November 2026 |
| npm downloads a week | 1.4M+, all of `@ark-ui/react` | Under 1,000 |
| GitHub stars | 5.4k+, the Ark UI repo | Under 100 |
| Licence | MIT | MIT |
| Dependencies | 69 direct, 89 packages installed | None, `@zriz/core` and React are peers |
| React | 18.0 and newer | 18.2 and 19 |
| Size | About 16 kB | 22 to 30 kB |

Size is minified JavaScript and CSS, compressed with Brotli, without React. For Ark UI it is the basic example from its docs, built with esbuild. Ark UI ships no CSS, so its number counts the stylesheet that the example imports, about 1 kB of the total. 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](https://zriz.dev/docs/bundle-size.md) has each set. `@zriz/react` needs `@zriz/core` and React as peers.

The download count is for the whole `@ark-ui/react` package, which holds every Ark UI component. It does not say how many projects render the cropper. The 89 packages are the tree of all of `@ark-ui/react`, most of them for other components. The machine with `@zag-js/react` alone installs 10, and the Size row shows what reaches the browser.

## Features

| Feature | Ark UI | zriz |
| - | - | - |
| Crop box with handles | Yes. Four corners and four edges | Yes. Four corners and four edges |
| Photo moves under the frame | Yes. Once zoomed or rotated, or with `fixedCropArea` | Yes |
| Aspect ratio | Yes. Free or any ratio | Yes. Free, original or any ratio |
| Round crop | Partly. A square frame you round with CSS; the file is a rectangle | Yes. Mask and export |
| Zoom | Yes. Wheel, pinch, keys and API | Yes. Pinch, Ctrl + wheel, slider part, keys |
| Straighten (free rotation) | Partly. Any angle by prop or API | Partly. 45° each way plus quarter turns |
| Crop stays inside the photo at an angle | No. The export had clear corners at 15° | Yes. The crop shrinks to fit |
| Quarter turns and flip | Partly. By API; the box keeps its shape | Yes. Animated |
| Touch gestures | Partly. Pan and pinch; no twist | Yes. Pan, pinch, twist, two handles at once |
| Keyboard | Yes. Move, resize and zoom; handles no | Yes. Crop area, dial, zoom and ratio group; handles no |
| Screen reader | Yes. Roles, live region and English labels | Partly. Roles and live region; you write the labels |
| Image export | Yes. Blob or data URL, PNG by default | Yes. JPEG, PNG or WebP blob |
| Saved crop | Partly. In viewport pixels; nothing sets the pan | Yes. Fractions of the photo, restores exactly |
| Ready-made UI | No. Unstyled parts and no stylesheet | No. Unstyled parts and an optional stylesheet |

Rotation in Ark UI is a number. No part, gesture or key changes it, so the app builds the control and calls `setRotation` or `rotateBy`. We ran neither library with a screen reader for this page, so the two screen reader cells describe the markup.

## What you will notice

A drag inside the frame does different things. In Ark UI it moves the frame across the photo. The photo moves by a drag on the viewport outside the frame, once the user zooms, because at zoom 1 with no rotation there is nowhere to pan. In zriz the same drag moves the photo under the frame, the handles resize the frame, and 300 ms after the gesture the crop returns to the centre of the viewport. Ark UI's `fixedCropArea` gives a fixed frame over a moving photo and turns the handles off.

The wheel zooms in Ark UI and pans in zriz. Ark UI turns each wheel event over the viewport into one `zoomStep`, 0.1 by default, and calls `preventDefault()` on it, see [image-cropper.machine.ts](https://github.com/chakra-ui/zag/blob/@zag-js/image-cropper@1.47.0/packages/machines/image-cropper/src/image-cropper.machine.ts). No prop turns that off, so the page does not scroll while the pointer is over the viewport. zriz pans on a plain wheel and zooms on Ctrl + wheel, and it also keeps the wheel from the page while it pans or zooms. `wheel="zoom"` on `Viewport` makes a plain wheel zoom as it does in Ark UI, and `wheel="none"` leaves a plain wheel to the page.

A tilt leaves the frame alone in Ark UI. The photo turns about its centre and the selection keeps its place, so part of it can fall outside the photo. In Chromium, a 15° turn with the default selection gave a PNG with two transparent corners. zriz shrinks the crop until all four corners sit on the photo and stops at 45° each way. If you want clear corners in the file, Ark UI does that and zriz cannot.

Ark UI names its parts for a screen reader and zriz leaves the words to you. The Ark UI selection is one tab stop with the label "Crop selection area (rectangle)", a value text with its position and size, and a description of the keys, all in English and all replaceable through `translations`. zriz ships no words, so the app writes each `aria-label`, the `Instructions` text and what `Announcer` says. axe reported no findings for either setup in Chromium.

Ark UI stretches the image to its box. The machine sets `object-fit: fill` inline on the image, see [image-cropper.connect.ts](https://github.com/chakra-ui/zag/blob/@zag-js/image-cropper@1.47.0/packages/machines/image-cropper/src/image-cropper.connect.ts), so the app gives the viewport the photo's own aspect ratio. zriz measures the photo, and its `Viewport` needs a height and can have another shape.

## Migrate from Ark UI

zriz has no equivalent for these, so check them first. It needs React 18.2 or newer and ships ES modules with no CommonJS build, where Ark UI takes React 18.0 and ships both. It has no Solid, Vue or Svelte components. It cannot hold a crop with empty area, such as a rotated photo with clear corners. It has no single angle past 45°, where Ark UI's `rotation` prop takes any number. A pointer cannot drag the frame across a still photo. It has no `maxWidth` or `maxHeight`, no key steps in pixels like `nudgeStep`, no `zoomStep`, no default labels and no data URL from the export call.

- `ImageCropper.Root`. `Cropper.Root`, which takes `src`
- `ImageCropper.Viewport`. `Cropper.Viewport`, with a height
- `ImageCropper.Image` with `src`. `Cropper.Image`, `src` on `Root`; a `File` or `Blob` goes in as it is
- `ImageCropper.Selection`. `Cropper.Area` plus `Cropper.Mask` for the dimmed overlay
- `ImageCropper.Handle position="nw"`. `Cropper.Handle position="top-left"`; `n` is `top`, `e` is `right`
- Two `ImageCropper.Grid` parts. One `Cropper.Grid`
- `RootProvider` with `useImageCropper`. `Cropper.Root cropper={useCropper()}`
- `ImageCropper.Context`, `useImageCropperContext`. `actionsRef` on `Root`, or the hooks from `@zriz/react`
- `aspectRatio`. `aspect`, also `"free"` and `"original"`
- `cropShape="circle"`. `shape="circle"`, which also rounds the file
- `fixedCropArea`. No prop. Leave out `Handle`; Alt + arrows still resize
- `initialCrop`. `defaultValue`. `initialCrop` is in viewport pixels, so read `getCropData()` for it and convert as below
- `minWidth`, `minHeight` in viewport pixels. `minSize` in source pixels
- `minZoom`. None. zriz zoom starts at 1
- `maxZoom`, default 5. `maxZoom`, default 15, counted from the largest crop and not from the image scale
- `zoom`, `onZoomChange`. `zoom`, `onValueChange`
- `rotation`, `onRotationChange`. `rotation` up to 45° each way, `quarterTurns` in the value for the rest
- `flip`, `onFlipChange`. `mirrored` in the value; a vertical flip is `mirrored` with two quarter turns
- `onCropChange`. `onValueChange`, and `onValueCommit` when a gesture ends
- `translations`. `aria-label` on each part, `Cropper.Instructions`, `Cropper.Announcer`
- `api.setZoom`, `api.zoomBy`. `cropper.setZoom`
- `api.setRotation`, `api.rotateBy(90)`. `cropper.setRotation`, `cropper.rotate90("cw")`
- `api.flipHorizontally`, `api.flipVertically`. `cropper.flip()`, `cropper.flip("y")`
- `api.setCrop`. `cropper.setValue`
- `api.reset`. `cropper.reset`, or the `Cropper.Reset` part
- `api.getCropData`. `cropper.getValue`, `cropper.getPixelCrop`
- `api.getCroppedImage`. `exportCrop` from `@zriz/core/dom`, which rejects where Ark UI resolves `null`
- Your buttons for zoom, rotate, flip and aspect. `Cropper.Zoom`, `Cropper.Dial`, `Cropper.Rotate`, `Cropper.Flip`, `Cropper.AspectGroup`

Saved crops need a conversion, and zriz has no importer. If the app stored the result of `api.getCropData()`, a zriz `CropValue` comes from its `corners`, `rotate`, `flipX` and `flipY` and the photo's natural size `W` by `H`. Set `total = rotate + (flipY ? 180 : 0)`, `steps = Math.round(total / 90)`, `quarterTurns = ((steps % 4) + 4) % 4`, `rotation = total - steps * 90` and `mirrored = flipX !== flipY`. Take `m`, the mean of the four corners minus `{ x: W / 2, y: H / 2 }`, and negate `m.x` when `mirrored`. Turn `m` by `total` degrees: `c.x = cos * m.x - sin * m.y` and `c.y = sin * m.x + cos * m.y`. With `w` the distance from `topLeft` to `topRight`, `h` the distance from `topLeft` to `bottomLeft`, and `Wt` by `Ht` the photo's size, swapped when `quarterTurns` is odd, the value has `center: { x: 0.5 + c.x / Wt, y: 0.5 + c.y / Ht }` and `size: { width: w / Wt, height: h / Ht }`.

We checked this by running code on eight states with zoom, pan, flips and angles of 10°, 20°, 100°, 200° and 280°. zriz kept each value unchanged, and the source corners of its export plan matched Ark UI's `corners` within 0.5 px. It holds when the image box had the photo's aspect ratio at the time of saving and the crop lies inside the photo. zriz moves or shrinks a crop that reaches outside. `aspect` is not in Ark UI's data, so take it from your `aspectRatio` prop. If the app stored `api.crop`, `api.zoom` and `api.offset`, the crop and the offset are viewport pixels, and they need the viewport size they were saved with.

Run `pnpm add @zriz/react @zriz/core` and import `@zriz/react/styles.css`. Swap the parts by the table and move `src` from the image to `Root`. Delete the CSS that gave the viewport the photo's aspect ratio and the code that made an object URL for a picked file. Replace `getCroppedImage` with `exportCrop`, and catch its error where the old code tested for `null`. Write an `aria-label` for each part. Remove `@ark-ui/react` when no other component uses it.

```tsx
"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 Source = string | Blob;

type Props = {
  src: Source;
  onSave: (png: Blob) => void;
};

export const ImageCropper = ({ src, onSave }: Props) => {
  const cropper = useRef<CropperApi>(null);
  const running = useRef<AbortController | null>(null);
  const [loaded, setLoaded] = useState<Source | null>(null);
  const [saving, setSaving] = useState(false);
  const [failed, setFailed] = useState(false);

  useEffect(() => () => running.current?.abort(), [src]);

  const save = async () => {
    if (!cropper.current || running.current) return;
    const controller = new AbortController();

    running.current = controller;
    setSaving(true);

    try {
      const { blob } = await exportCrop(cropper.current, {
        type: "image/png",
        signal: controller.signal,
      });

      setFailed(false);
      onSave(blob);
    } catch {
      setFailed(!controller.signal.aborted);
    } finally {
      running.current = null;
      setSaving(false);
    }
  };

  return (
    <>
      <Cropper.Root
        src={src}
        actionsRef={cropper}
        maxZoom={5}
        onLoad={() => setLoaded(src)}
      >
        <Cropper.Viewport
          wheel="zoom"
          style={{ maxWidth: 480, height: 320, padding: 24 }}
        >
          <Cropper.Image />
          <Cropper.Mask />
          <Cropper.Area aria-label="Crop area">
            <Cropper.Grid />
            <Cropper.Handle position="top-left" />
            <Cropper.Handle position="top" />
            <Cropper.Handle position="top-right" />
            <Cropper.Handle position="right" />
            <Cropper.Handle position="bottom-right" />
            <Cropper.Handle position="bottom" />
            <Cropper.Handle position="bottom-left" />
            <Cropper.Handle position="left" />
          </Cropper.Area>
        </Cropper.Viewport>
        <Cropper.Instructions>
          Arrow keys move the crop. Alt and the arrow keys
          resize it. Plus and minus zoom.
        </Cropper.Instructions>
      </Cropper.Root>
      {failed && <p role="alert">This crop did not save.</p>}
      <button
        type="button"
        disabled={loaded !== src}
        aria-disabled={saving}
        onClick={save}
      >
        Save
      </button>
    </>
  );
};
```

This is Ark UI's Basic example in zriz: a crop with eight handles and a grid, `wheel="zoom"` so the wheel still zooms, `maxZoom={5}`, and a PNG at the crop's size in source pixels, as `getCroppedImage` gives by default. Past zriz's export size limit the file comes out scaled down with `scaled: true`. The 5 is the same number as Ark UI's default, but the scales differ. Ark UI scales the image under a frame that can still shrink, and zriz counts from the largest crop and stops the handles there too. Raise it or leave it out if users crop small details. The stylesheet paints the mask, frame, handles and grid. The app still writes the layout, the styles for the button and for any zoom slider or rotate buttons it adds, and every label.

## How we checked

We installed the published npm packages at the versions above and read their source and the Ark UI and Zag docs. We took the counts and dates from the npm registry and GitHub. 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.

Ark UI: [repo](https://github.com/chakra-ui/ark), [docs](https://ark-ui.com/docs/components/image-cropper), [npm](https://www.npmjs.com/package/@ark-ui/react). The machine under it: [Zag repo](https://github.com/chakra-ui/zag), [Zag docs](https://zagjs.com/components/react/image-cropper), [npm](https://www.npmjs.com/package/@zag-js/image-cropper).

## More comparisons

- [zriz vs react-image-crop](https://zriz.dev/docs/zriz-vs-react-image-crop.md). Over 4 million downloads a week
- [zriz vs react-advanced-cropper](https://zriz.dev/docs/zriz-vs-react-advanced-cropper.md). Another cropper whose export had transparent corners at 15°
- [zriz vs react-easy-crop](https://zriz.dev/docs/zriz-vs-react-easy-crop.md). The most downloaded cropper for React, over 5 million a week

Every comparison is on the [comparison](https://zriz.dev/docs/comparison.md) page.
