# Image Cropper

> 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.

- Type: React component (Media)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add image-cropper`
- Docs: https://wingo-ui.com/components/image-cropper

## Interaction

Drag the photo with a finger or the mouse to move it under the frame; pinch with two fingers or turn the mouse wheel to zoom, and the image always keeps covering the frame. While you move it a rule-of-thirds grid fades in and a small pill on the stage shows the zoom and the angle; a first-run hint says how to move and zoom and disappears after the first touch. Pick a ratio (Free, 1:1, 4:3, 3:4, 16:9, 9:16) from the segmented switch on wide layouts or the scrolling chip rail on phones, where every chip is a 44px target. In Free mode the frame starts at the photo's own ratio and its four corner brackets resize it with a drag (the frame stays centered). The zoom row has minus and plus buttons that glide the zoom on a short tween around a slider; the rotation row has a 90° left turn that animates, a slider from −45° to 45° with a center mark for straightening, and an optional horizontal flip that turns like a card. Reset puts position, zoom, rotation and flip back. Tab to the crop frame (it shows a white focus ring): arrow keys move the photo by 10px (Shift+arrows by 2px for fine nudges), + and − zoom, r turns 90° left, f flips when flippable, 0 resets and Escape cancels a corner drag. The sliders take arrows, Home and End, and read their value as 150% or −12°; aspect changes, turns, flips and resets are announced. On phones the stage caps at 60% of the screen height and the toolbar stacks. The export crops in a canvas with the rotation and flip, steps down large photos in halves for a sharp result, and can mask a circle with transparent corners.

## Installation

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

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/image-cropper` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/image-cropper.tsx`

npm dependencies: `npm i react-easy-crop motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Slider](https://wingo-ui.com/components/slider) ([Markdown](https://wingo-ui.com/components/slider.md))
- [Segmented Control](https://wingo-ui.com/components/segmented-control) ([Markdown](https://wingo-ui.com/components/segmented-control.md))
- [Tooltip](https://wingo-ui.com/components/tooltip) ([Markdown](https://wingo-ui.com/components/tooltip.md))
- [Skeleton](https://wingo-ui.com/components/skeleton) ([Markdown](https://wingo-ui.com/components/skeleton.md))
- [File export](https://wingo-ui.com/components/file-export) ([Markdown](https://wingo-ui.com/components/file-export.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

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

export function ListingPhoto({ onSave }: { onSave: (file: Blob) => void }) {
  const [api, setApi] = useState<ImageCropperApi | null>(null)
  const [saving, setSaving] = useState(false)

  const crop = async () => {
    if (!api) return
    setSaving(true)
    try {
      onSave(await api.toBlob({ type: "image/jpeg", quality: 0.9, maxSize: 1600 }))
    } finally {
      setSaving(false)
    }
  }

  return (
    <div className="flex max-w-xl flex-col gap-4">
      <ImageCropper
        src="/demo/apartment.jpg"
        defaultAspect={4 / 3}
        setApi={setApi}
        labels={{
          aspect: "Ratio",
          hint: "Drag to move, scroll to zoom",
          touchHint: "Drag to move, pinch to zoom",
        }}
      />
      <Button loading={saving} onClick={crop}>
        Crop
      </Button>
    </div>
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `src` | `string \| Blob \| File` |  | The photo: a URL, a data URL or a File / Blob from an input (its object URL is created and revoked for you). Modern browsers apply EXIF orientation to both the preview and the export. |
| `value?` | `CropValue ({ area: CropArea; zoom: number; rotation: number; aspect: number \| null; flipH?: boolean })` |  | The controlled crop. area is { x, y, width, height } in source pixels of the rotated photo. A value from outside that differs from the last one reported restores that exact crop. |
| `defaultValue?` | `CropValue` |  | The uncontrolled start, for example a crop saved earlier. Without it the frame starts centered at the largest area of the ratio. |
| `onValueChange?` | `(value: CropValue) => void` |  | Fires when an interaction ends (a drag, a pinch, the wheel settling, a slider released, a key released, a tween finished), never per frame. Also once when the photo is ready, with the starting crop. |
| `onCropChange?` | `(value: CropValue) => void` |  | Every frame while the crop moves. Rarely needed; prefer onValueChange. |
| `aspect?` | `number \| null` |  | The controlled ratio (width / height). null is a free crop that starts at the photo's own ratio and has corner handles. |
| `defaultAspect?` | `number \| null` | `"null"` | The uncontrolled starting ratio, e.g. 4 / 3 for a listing photo. |
| `onAspectChange?` | `(aspect: number \| null) => void` |  | A ratio was picked from the presets. |
| `aspectPresets?` | `AspectPreset[] \| false ({ key: string; label: ReactNode; value: number \| null; icon?: ReactNode })` | `"DEFAULT_ASPECT_PRESETS"` | The ratios offered: Free, 1:1, 4:3, 3:4, 16:9, 9:16 by default, each with a small outline of its shape. false hides the picker (a fixed ratio). The default Free label follows labels.free. Exported as DEFAULT_ASPECT_PRESETS, so you can spread it and add your own. |
| `cropShape?` | `"rect" \| "round"` | `"rect"` | round draws a circular frame for avatars and logos, forces the ratio to 1 and hides the presets; the export masks the circle. |
| `showGrid?` | `"always" \| "interacting" \| "never"` | `"interacting"` | The rule-of-thirds grid inside the frame. interacting fades it in while you drag, pinch, zoom or rotate. |
| `minZoom?` | `number` | `1` | The smallest zoom. At 1 the photo just fits the stage. With a resized free frame the zoom also stops where the photo would stop covering it. |
| `maxZoom?` | `number` | `4` | The largest zoom for the slider, the buttons, the wheel and pinch. |
| `zoomStep?` | `number` | `0.1` | The slider step and the + / − key step. The zoom buttons move five steps with a short glide. |
| `rotatable?` | `boolean` | `true` | Shows the rotation row: a 90° left turn and a fine slider from −45° to 45° with a center mark. |
| `flippable?` | `boolean` | `false` | Adds a horizontal flip button (and the f key). The export mirrors the photo too. |
| `restrictPosition?` | `boolean` | `true` | Keeps the photo covering the whole frame. Off, the photo can move past the frame and the uncovered part exports as the background. |
| `objectFit?` | `"contain" \| "cover"` | `"contain"` | How the photo sits in the stage at zoom 1: contain shows all of it, cover fills the stage. |
| `height?` | `number` | `360` | The stage height in px. Phones cap it at 60dvh so the toolbar stays on screen. |
| `toolbar?` | `boolean` | `true` | The controls under the stage. Off, the photo still moves, pinches, zooms with the wheel and answers the keys; drive the rest through setApi. |
| `export?` | `CropExportOptions ({ type?: ImageType; quality?: number; maxSize?: number; width?: number; height?: number; background?: string; round?: boolean })` | `"{ type: \"image/jpeg\", quality: 0.9, maxSize: 2048 }"` | Defaults for toBlob / toCanvas. maxSize caps the longest side (never upscales), width / height set an exact size, round masks a circle (on by default for a round crop), background fills behind (JPEG gets white). cropImage (see Parts) runs the same export without the component. |
| `overlayColor?` | `string` | `"rgba(0, 0, 0, 0.55)"` | The dim outside the frame. Any CSS color. |
| `size?` | `"sm" \| "md"` | `"md"` | The toolbar controls: sm 32px buttons and slim sliders, md 40px (44px on touch). |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The slider fills, the selected ratio chip and the segmented thumb. |
| `color?` | `string` | `""` | Any CSS color; replaces the tone for the sliders and the selected ratio. |
| `radius?` | `"none" \| "sm" \| "md" \| "lg"` | `"md"` | The stage corners: 0 / 12 / 16 / 24px. |
| `disabled?` | `boolean` | `false` | Dims the stage and the toolbar and ignores every gesture and key. |
| `setApi?` | `(api: ImageCropperApi) => void` |  | Receives { toBlob(options?), toCanvas(options?), reset(), rotateBy(degrees), zoomTo(zoom) } after mount. toBlob rejects with labels.corsError when a remote photo has no CORS headers (the preview still works). |
| `onError?` | `(error: Error) => void` |  | The photo failed to load, or an export failed. Load errors also show a retry on the stage for URLs. |
| `labels?` | `Partial<ImageCropperLabels>` | `"DEFAULT_IMAGE_CROPPER_LABELS"` | Every string: aspect, free, zoom, zoomIn, zoomOut, rotate, rotateLeft, flip, reset, area (the frame's name for screen readers), loading, error, retry, hint, touchHint, corsError, exportError, and the announcements aspectChanged(label), rotated(degrees, formatted), flipped, resetDone. English defaults in DEFAULT_IMAGE_CROPPER_LABELS. |
| `classNames?` | `Partial<Record<"stage" \| "area" \| "grid" \| "handle" \| "toolbar" \| "aspects" \| "zoom" \| "rotate" \| "reset" \| "pill", string>>` |  | Extra classes for the inner parts. |
| `className?` | `string` |  | Extra classes for the root, merged last. |

## Parts

### cropImage (function)

Runs a saved crop without the component, e.g. on submit or in avatar-upload: rotation and flip aware, two-step downscaling for large photos, and a round mask with transparent PNG or WebP corners. Browser only.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `source` | `string \| Blob` |  | The image URL or file. |
| `value` | `CropValue` |  | The crop from onValueChange: area, zoom, rotation, aspect, flipH. |
| `options?` | `CropExportOptions` |  | type, quality, maxSize, width, height, background, round. |
| `returns?` | `Promise<Blob>` |  | The cropped image, ready to upload. |

### DEFAULT_ASPECT_PRESETS (constant)

The ratios the picker offers: Free, 1:1, 4:3, 3:4, 16:9 and 9:16. Spread it and add your own { key, label, value }.

### DEFAULT_IMAGE_CROPPER_LABELS (constant)

Every English string: toolbar buttons, hints, loading and error states, and the screen reader announcements. Spread it to translate.

### CropValue (type)

Everything needed to redo a crop later: { area, zoom, rotation, aspect, flipH? }. Store it, pass it back as value or to cropImage.

### ImageCropperApi (type)

The handle setApi hands you: toBlob(options?), toCanvas(options?), reset(), rotateBy(degrees), zoomTo(zoom).

## Examples

- **Cover image**: 16:9 only, exported as WebP at up to 1920px.
- **Round logo**: cropShape="round" with a 512px PNG export: the corners stay transparent.
- **Your own photo**: A file input hands a File to src; the object URL is created and released for you.
- **Free aspect**: Drag the frame's corners to resize it; the grid stays on.
- **Scanned document**: Custom A4 presets, fine rotation and a flip button.
- **Phone editor**: A 360px screen: stacked toolbar, scrolling ratio chips, pinch to zoom.
- **Brand tone**: tone="brand", size sm, a warmer overlay.

## See also

- [File Upload](https://wingo-ui.com/components/file-upload) ([Markdown](https://wingo-ui.com/components/file-upload.md))
- [Image Lightbox](https://wingo-ui.com/components/image-lightbox) ([Markdown](https://wingo-ui.com/components/image-lightbox.md))
- [File export](https://wingo-ui.com/components/file-export) ([Markdown](https://wingo-ui.com/components/file-export.md))
- [Slider](https://wingo-ui.com/components/slider) ([Markdown](https://wingo-ui.com/components/slider.md))

---

Canonical: https://wingo-ui.com/components/image-cropper
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
