# React Image Cropper: Crop Before Upload, Zoom, Rotate

> Build a React image cropper that crops before upload with pinch zoom and rotation, exports a sized WebP, and powers a round avatar upload with progress.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 19, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 9 min
- Canonical: https://wingo-ui.com/blog/react-image-cropper

## TL;DR

A React image cropper should crop in the browser before the upload: move with a drag, zoom with a pinch or the wheel, straighten with rotation, then export a resized WebP or JPEG Blob through a canvas so a 12 megapixel phone photo never travels at full size. For avatars, use a round frame and export a PNG or WebP with transparent corners. The Wingo UI Image Cropper does this with a setApi handle and a cropImage helper, and Avatar Upload wraps it into a pick, crop and upload flow with a progress ring.

You let people upload a profile photo or a listing picture, and the photos arrive as 4,000 pixel JPEGs, sideways faces and heads cut off by `object-fit: cover`. A React image cropper fixes this before the file leaves the browser: the user frames the photo, you export a small image at the exact size you need, and the server stores what the page will show. This guide covers what a cropper must handle, how the common libraries compare, how to crop before upload with the Wingo UI [Image Cropper](https://wingo-ui.com/components/image-cropper), and the round avatar flow built on it.

## What should a React image cropper handle?

A cropper that holds up in production handles seven things:

1. **Pan and zoom** with a mouse drag, the wheel, a one finger drag and a two finger pinch, while the photo always covers the frame.
2. **Aspect ratios**: a fixed one for covers and avatars, presets for galleries, and a free crop with resizable corners.
3. **Rotation**: 90 degree turns for sideways photos and a fine slider to straighten a horizon.
4. **A sized export**: an exact width or a cap on the longest side, in WebP, JPEG or PNG, as a `Blob` you can put in `FormData`.
5. **A round frame** for avatars and logos, with transparent corners in the export.
6. **Keyboard support**: arrows to move, keys to zoom and rotate, and sliders that read their value.
7. **A phone layout** where the toolbar stays on screen and every control is a 44px target.

Point 4 is where hand-rolled croppers usually break. Drawing a large photo straight down to 512px in one `drawImage` call can leave jagged edges, and a canvas past the browser's size limit can fail or come back blank.

## Which React image crop library should I use?

These are the names that come up when you search for a React image crop library. Facts are from each project's README on GitHub, as of October 2026.

| Option | License | What you get | Zoom and rotation | Round crop | Export |
|---|---|---|---|---|---|
| [react-easy-crop](https://github.com/ValentinH/react-easy-crop) | MIT | A fixed frame, the photo moves under it | `zoom` (`maxZoom` defaults to 3), wheel zoom, `rotation` | `cropShape="round"` | Pixel coordinates through `onCropComplete`; the canvas step is yours |
| [react-image-crop](https://github.com/dominictobias/react-image-crop) | ISC | A crop box you drag and resize over a fixed image, no dependencies, under 5KB gzip | Not in the component; the README's helpers take optional scale and rotate | `circularCrop` | Crop coordinates; the README's FAQ shows `cropToCanvas` and `cropToImg` helpers |
| [Wingo UI Image Cropper](https://wingo-ui.com/components/image-cropper) | Pro license | react-easy-crop plus a toolbar: ratio presets, zoom and rotation sliders, flip, free crop corners | Pinch, wheel, buttons, `maxZoom` 4, 90 degree turns and a fine slider from −45 to 45 degrees | `cropShape="round"` with a masked export | `toBlob`, `toCanvas` and `cropImage` with size, type, quality and mask |

react-image-crop is the better choice when users should draw a box over a document or a screenshot, and when bundle size matters most. react-easy-crop fits photos, where moving the picture under a fixed frame feels more natural on a phone. The Wingo UI component is react-easy-crop with the UI and the export already written, as source you can edit.

## How do I crop an image before upload in React?

Put the cropper between the file input and the upload. The Image Cropper takes a `File` as `src` (it creates and revokes the object URL for you), and `setApi` hands you a handle with `toBlob`. A crop before upload React flow needs three pieces: a file input, the cropper and the upload call.

```bash
npx wingo-ui@latest add image-cropper
```

```tsx
"use client";

import { useRef, useState, type ChangeEvent } from "react";
import { Button } from "@/components/ui/button";
import { ImageCropper, type ImageCropperApi } from "@/components/ui/image-cropper";

export function CoverPhotoField() {
  const [file, setFile] = useState<File | null>(null);
  const [api, setApi] = useState<ImageCropperApi | null>(null);
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState("");
  const inputRef = useRef<HTMLInputElement>(null);

  const onPick = (event: ChangeEvent<HTMLInputElement>) => {
    const picked = event.target.files?.[0];
    if (picked) setFile(picked);
    // the same file can be picked again
    event.target.value = "";
  };

  const save = async () => {
    if (!api) return;
    setSaving(true);
    setError("");
    try {
      const blob = await api.toBlob({ type: "image/webp", quality: 0.85, maxSize: 1600 });
      const body = new FormData();
      body.append("cover", blob, "cover.webp");
      const response = await fetch("/api/listings/cover", { method: "POST", body });
      if (!response.ok) throw new Error("The upload failed");
    } catch (failure) {
      setError(failure instanceof Error ? failure.message : "The upload failed");
    } finally {
      setSaving(false);
    }
  };

  return (
    <div className="flex max-w-xl flex-col gap-4">
      <input ref={inputRef} type="file" accept="image/jpeg,image/png,image/webp" className="sr-only" tabIndex={-1} onChange={onPick} />
      {file && <ImageCropper src={file} defaultAspect={16 / 9} setApi={setApi} />}
      <div className="flex gap-2">
        <Button variant="soft" onClick={() => inputRef.current?.click()}>
          Choose a photo
        </Button>
        <Button loading={saving} disabled={!file} onClick={save}>
          Save cover
        </Button>
      </div>
      {error && <p role="alert" className="text-sm text-destructive-soft-foreground">{error}</p>}
    </div>
  );
}
```

`defaultAspect` sets the starting ratio and leaves the presets (Free, 1:1, 4:3, 3:4, 16:9, 9:16) available; pass `aspectPresets={false}` to lock it. `onValueChange` fires when an interaction ends, never per frame, with a `CropValue`: the area in source pixels, the zoom, the rotation, the aspect and the flip.

> Live demo (Image Cropper): Drag the photo, pinch or scroll to zoom, turn it with the rotation slider, then press Crop to see the exported size and weight. Try it at [Image Cropper](https://wingo-ui.com/components/image-cropper) and install it with `npx wingo-ui@latest add image-cropper`.

## How do I export a sized WebP from the crop?

Pass export options to `toBlob`, or set them once with the `export` prop. `maxSize` caps the longest side and never upscales, `width` and `height` set an exact size, `type` picks `image/webp`, `image/jpeg` or `image/png`, and `quality` (0 to 1) applies to WebP and JPEG. Without options the export is a JPEG at 90% quality, capped at 2048px.

Three details of the export are worth knowing before you write your own:

- **Stepped downscaling.** The crop is first drawn at a power-of-two multiple of the final size (never above the source resolution), then halved step by step while the next half is still at least the target, and only then drawn at the final size. A large phone photo exported at 512px stays sharp instead of aliased.
- **A canvas cap.** The working canvas never exceeds 16,777,216 pixels (4096 by 4096), so a huge photo is scaled down instead of producing a blank export.
- **No metadata.** The file is re-encoded from a canvas, so it carries no EXIF data, which also drops the GPS location a phone writes into photos. Current browsers apply the EXIF orientation when they draw the photo ([`image-orientation`](https://developer.mozilla.org/en-US/docs/Web/CSS/image-orientation) defaults to `from-image`), so the preview and the export match.

JPEG has no transparency, so the export fills the background white unless you pass `background`. If a browser cannot encode the type you ask for, [canvas `toBlob`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob) falls back to PNG, so read `blob.type` before you name the file.

## How do I make a React image crop circle?

Set `cropShape="round"`. The frame becomes a circle, the ratio is forced to 1, the presets hide, and the export masks the circle by default. Ask for PNG or WebP to keep the corners transparent:

```tsx
"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";
import { cropImage, ImageCropper, type CropValue } from "@/components/ui/image-cropper";

export function LogoCrop({ file, onDone }: { file: File; onDone: (blob: Blob) => void }) {
  const [crop, setCrop] = useState<CropValue | null>(null);

  const save = async () => {
    if (!crop) return;
    onDone(await cropImage(file, crop, { type: "image/png", width: 512, height: 512, round: true }));
  };

  return (
    <div className="flex flex-col gap-4">
      <ImageCropper src={file} cropShape="round" rotatable={false} onValueChange={setCrop} />
      <Button onClick={save} disabled={!crop}>
        Save logo
      </Button>
    </div>
  );
}
```

`cropImage` runs the same export without the component, from the original file and a saved `CropValue`. That is the pattern to use when you want to re-crop later: store the original upload and the crop value, pass the value back as `defaultValue` when the user edits again, and regenerate any size you need.

> Live demo (Image Cropper): The round frame forces a 1:1 crop. Move the photo inside the circle, then press Crop: the PNG keeps transparent corners on the checkerboard. Try it at [Image Cropper](https://wingo-ui.com/components/image-cropper) and install it with `npx wingo-ui@latest add image-cropper`.

## How do I build an avatar upload crop React flow?

Use [Avatar Upload](https://wingo-ui.com/components/avatar-upload), which puts the cropper inside the whole profile photo flow. Tap the avatar to open a menu (Choose a photo, Take photo, and Remove photo when you pass `onRemove`), crop in a round frame in a dialog, and Save exports a square WebP at 512px. The new picture shows at once while a ring around the [Avatar](https://wingo-ui.com/components/avatar) fills with the upload progress. A failed upload brings the old picture back and offers Try again.

```bash
npx wingo-ui@latest add avatar-upload
```

```tsx
"use client";

import { useState } from "react";
import { AvatarUpload } from "@/components/ui/avatar-upload";

export function ProfilePhoto({ initial }: { initial: string | null }) {
  const [avatar, setAvatar] = useState<string | null>(initial);

  return (
    <AvatarUpload
      name="Emma Carter"
      value={avatar}
      onValueChange={setAvatar}
      outputSize={512}
      outputType="image/webp"
      onUpload={(blob, { signal, onProgress }) =>
        new Promise<string>((resolve, reject) => {
          const request = new XMLHttpRequest();
          // fetch has no upload progress, XMLHttpRequest does
          request.upload.onprogress = (event) => {
            if (event.lengthComputable) onProgress(event.loaded / event.total);
          };
          request.onload = () =>
            request.status < 300
              ? resolve((JSON.parse(request.responseText) as { url: string }).url)
              : reject(new Error("The upload failed"));
          request.onerror = () => reject(new Error("The connection dropped"));
          request.onabort = () => reject(new DOMException("Aborted", "AbortError"));
          signal.addEventListener("abort", () => request.abort());
          const body = new FormData();
          body.append("avatar", blob, "avatar.webp");
          request.open("POST", "/api/profile/avatar");
          request.send(body);
        })
      }
      onRemove={() => fetch("/api/profile/avatar", { method: "DELETE" })}
    />
  );
}
```

Resolve with the stored URL and it becomes the value. Without `onProgress` calls the ring spins instead of filling. The `signal` aborts when the component unmounts or a newer upload starts. `variant="row"` adds Change photo and Remove buttons beside the avatar for a settings list, and `variant="dropzone"` is a dashed tile for company logos. `crop={false}` skips the dialog and uploads the largest centered square. Pass `cropperProps` to tune the crop step, for example `{ flippable: true }`.

> Live demo (Avatar Upload): Tap the avatar, choose Choose a photo, crop it in the round frame and press Save: the ring fills while the upload runs. Try it at [Avatar Upload](https://wingo-ui.com/components/avatar-upload) and install it with `npx wingo-ui@latest add avatar-upload`.

## How does cropping work on phones and with a keyboard?

On a phone the photo moves with one finger and zooms with a pinch, the stage caps at 60% of the screen height so the toolbar stays visible, the toolbar stacks, and the ratio presets become a scrolling chip rail with 44px targets. In Avatar Upload the source menu, the crop step and the camera turn into bottom sheets below 768px (`responsive`, on by default), the same rule as in the [responsive dialog on mobile guide](https://wingo-ui.com/blog/shadcn-responsive-dialog-on-mobile). Take photo opens the front camera through `capture="user"` on phones and a live camera dialog on desktops.

With a keyboard, Tab to the crop frame: arrow keys move the photo by 10px (Shift plus arrows by 2px), `+` and `-` zoom, `r` turns 90 degrees left, `f` flips when `flippable` is on, and `0` resets. The zoom and rotation sliders read their values as 150% or −12 degrees, and ratio changes, turns and resets are announced. Every string, announcements included, lives in `labels` and merges over `DEFAULT_IMAGE_CROPPER_LABELS`.

## What are the common image cropper pitfalls?

- **Remote images without CORS.** The preview works, but `toBlob` rejects with `labels.corsError` because the canvas is tainted. Crop the `File` from the input, or serve the image with CORS headers.
- **iPhone HEIC photos.** Avatar Upload accepts JPEG, PNG and WebP by default. Safari converts HEIC to JPEG on pick when JPEG is in `accept`; in other browsers a HEIC file fails the type check and the user sees `labels.wrongType`.
- **Uploading the original.** If you send the picked file and crop on display with CSS, every visitor downloads the full photo. Send the export.
- **Trusting the client.** The browser export is a convenience. Check type, size and dimensions again on the server.
- **Listening to every frame.** `onCropChange` fires on every frame while the crop moves. Save on `onValueChange`, which fires once per interaction.

For the picker side (drag and drop, paste, type and size checks, an upload queue), the [React file upload guide](https://wingo-ui.com/blog/react-file-upload) pairs the [File Upload](https://wingo-ui.com/components/file-upload) with this cropper, and the [React settings page tutorial](https://wingo-ui.com/blog/react-settings-page) shows where a profile photo row sits in a full settings screen.

## How do I add it to my project?

Run `npx wingo-ui@latest add image-cropper` or `npx wingo-ui@latest add avatar-upload`. The CLI copies the source into `components/ui`, adds the components and helpers they import, and installs the npm packages they need (`react-easy-crop`, `motion` and `lucide-react` for the cropper). Both are Pro components ([pricing](https://wingo-ui.com/pricing)). More single component deep dives live in the [component guides category](https://wingo-ui.com/blog/category/components), and the [React form components guide](https://wingo-ui.com/blog/react-form-components-guide) covers the label, error and touch rules every field in a profile form shares.

## Components in this post

- [Image Cropper](https://wingo-ui.com/components/image-cropper) (Pro): A React image cropper for photos before upload: aspect presets, pinch or wheel zoom, rotation, a thirds grid and a JPEG, PNG or WebP export. Install: `npx wingo-ui@latest add image-cropper`
- [Avatar Upload](https://wingo-ui.com/components/avatar-upload) (Pro): A React avatar upload for profile pictures: pick or shoot a photo, crop it in a round frame and watch a ring fill while it uploads. Install: `npx wingo-ui@latest add avatar-upload`

## FAQ

### What is the best React image crop library?

react-easy-crop is the usual pick when you want pan, pinch zoom and rotation, and react-image-crop when you want a small, dependency free crop box you resize by its edges. Both report crop coordinates and leave the final image to you or a helper; a source component such as the Wingo UI Image Cropper adds the toolbar and the export on top of react-easy-crop.

### How do I crop an image to a circle in React?

Draw a round frame for the user (cropShape="round" in react-easy-crop and the Wingo UI Image Cropper, circularCrop in react-image-crop), then mask the export with a circular clip on the canvas. Export PNG or WebP to keep the corners transparent, because JPEG has no alpha channel.

### Should I crop images on the client or the server?

Crop on the client so the user sees the result and the upload is small, then validate type, size and dimensions again on the server. Keeping the original file and the crop coordinates also lets you re-crop later without asking the user again.

### Why does my cropped image fail to export from a remote URL?

Drawing a cross-origin image without CORS headers taints the canvas, and toBlob then throws a SecurityError. Serve the image with Access-Control-Allow-Origin, proxy it through your own domain, or crop the File from the input instead of a URL.

### Is the Wingo UI Image Cropper free?

No. The Image Cropper and Avatar Upload are Pro components. The Avatar component that Avatar Upload shows the picture in is free.

---

Source: https://wingo-ui.com/blog/react-image-cropper
