Skip to content

Cropper — the cross-framework image cropper

Cropper.js is the de-facto vanilla-JS image-cropping engine. But its framework wrappers are lopsided: React has the deep, maintained react-cropper; Vue has the older vue-cropperjs; and Angular, Svelte, Solid and Lit have nothing comparable — thin, stale, or absent. That gap (React served, the rest stranded) is exactly what Rozie's write-once-ship-six thesis exists to close.

One Cropper.rozie source compiles to six idiomatic packages — so Angular, Svelte, Solid and Lit consumers get a category-leading cropper for free, with the same props, events, two-way crop box, and imperative handle as the React one.

The @rozie-ui/cropper packages

PackageFrameworkShips
@rozie-ui/cropper-reactReact 18+compiled .tsx + types
@rozie-ui/cropper-vueVue 3.4+.vue SFC source
@rozie-ui/cropper-svelteSvelte 5+.svelte source
@rozie-ui/cropper-angularAngular 19+standalone component source
@rozie-ui/cropper-solidSolid 1.8+compiled .tsx + types
@rozie-ui/cropper-litLit 3+compiled custom element + types

All six wrap Cropper.js v1 (cropperjs@^1), declared as a peer dependency. (Cropper.js v2 was rewritten as Web Components with a different API — see Gotchas.)

Import the engine CSS yourself

The scoped component <style> cannot reach the engine-rendered .cropper-* crop UI, so each app must import Cropper's stylesheet once at its entry:

ts
import 'cropperjs/dist/cropper.css';

Quick start

The crop box is two-way bound through a single data model prop — { x, y, width, height, rotate, scaleX, scaleY }. Dragging or resizing the crop box writes the new box back through the model path (round-trip-guarded so a programmatic setData doesn't ping-pong); a consumer write reflects into the live cropper. The image comes through src; crop/zoom lifecycle fires as native framework events.

React

tsx
import { useState } from 'react';
import { Cropper } from '@rozie-ui/cropper-react';
import 'cropperjs/dist/cropper.css';

export function Demo() {
  const [data, setData] = useState();
  return (
    <Cropper
      src="/photo.jpg"
      data={data}
      onDataChange={setData}
      aspectRatio={16 / 9}
      viewMode={1}
      onCrop={(e) => console.log(e)}
    />
  );
}

Vue

vue
<script setup lang="ts">
import { ref } from 'vue';
import Cropper from '@rozie-ui/cropper-vue';
import 'cropperjs/dist/cropper.css';

const data = ref();
</script>

<template>
  <Cropper
    src="/photo.jpg"
    v-model:data="data"
    :aspect-ratio="16 / 9"
    :view-mode="1"
    @crop="(e) => console.log(e)"
  />
</template>

Svelte

svelte
<script lang="ts">
  import Cropper from '@rozie-ui/cropper-svelte';
  import 'cropperjs/dist/cropper.css';

  let data = $state();
</script>

<Cropper
  src="/photo.jpg"
  bind:data
  aspectRatio={16 / 9}
  viewMode={1}
  oncrop={(e) => console.log(e)}
/>

Angular

ts
import { Component } from '@angular/core';
import { Cropper } from '@rozie-ui/cropper-angular';
// Add 'cropperjs/dist/cropper.css' to your global styles.

@Component({
  selector: 'app-demo',
  standalone: true,
  imports: [Cropper],
  template: `
    <Cropper
      src="/photo.jpg"
      [(data)]="data"
      [aspectRatio]="16 / 9"
      [viewMode]="1"
      (crop)="onCrop($event)"
    />
  `,
})
export class DemoComponent {
  data: any;
  onCrop(e: any) { console.log(e); }
}

Because data is the lone two-way model, the Angular component is a real ControlValueAccessor[(ngModel)]="data" and reactive formControl bindings work out of the box.

Solid

tsx
import { createSignal } from 'solid-js';
import { Cropper } from '@rozie-ui/cropper-solid';
import 'cropperjs/dist/cropper.css';

export function Demo() {
  const [data, setData] = createSignal();
  return (
    <Cropper
      src="/photo.jpg"
      data={data()}
      onDataChange={setData}
      aspectRatio={16 / 9}
      viewMode={1}
      onCrop={(e) => console.log(e)}
    />
  );
}

Lit

ts
import '@rozie-ui/cropper-lit';
import 'cropperjs/dist/cropper.css';

// <rozie-cropper> is a custom element. Bind `src`/`data` as properties and
// listen for `data-change` (the two-way change channel) + `crop`.
const el = document.querySelector('rozie-cropper');
el.src = '/photo.jpg';
el.aspectRatio = 16 / 9;
el.addEventListener('data-change', (e) => { el.data = e.detail; });
el.addEventListener('crop', (e) => console.log(e.detail));

API

Props

data is the lone two-way model prop (bind with r-model / v-model / bind: / [(…)] / onDataChange). Five props reconcile into the live cropper on change — src (via replace), aspectRatio (setAspectRatio), dragMode (setDragMode), disabled (enable / disable) and data (setData). The remaining options are set at construction (Cropper.js v1 ships no runtime setter for them), including preview (the live crop-thumbnail target(s) — a selector string or element ref(s); v1 has no setPreview); anything not surfaced here can be passed through the options bag.

NameTypeDefaultTwo-way (model)Runtime-updatable?Description
srcString""The image URL the cropper attaches to. Changing it calls replace(url).
dataunknownundefinedThe crop box — { x, y, width, height, rotate, scaleX, scaleY }. Two-way: dragging/resizing writes the box back (round-trip-guarded); a consumer write setDatas the live cropper.
aspectRatioNumberNaNThe crop box aspect ratio. NaN = free ratio. Reconciled via setAspectRatio.
viewModeNumber0The view constraint mode (03). Construction-only.
dragModeString"crop"'crop' (draw a new box) / 'move' (pan the canvas) / 'none'. Reconciled via setDragMode.
disabledBooleanfalseFreeze the cropper. Reconciled via enable() / disable().
guidesBooleantrueShow the dashed guide lines over the crop box. Construction-only.
centerBooleantrueShow the center indicator. Construction-only.
backgroundBooleantrueShow the grid background. Construction-only.
movableBooleantrueAllow moving the image. Construction-only.
rotatableBooleantrueAllow rotating the image. Construction-only.
scalableBooleantrueAllow scaling (flipping) the image. Construction-only.
zoomableBooleantrueAllow zooming the image. Construction-only.
zoomOnWheelBooleantrueAllow zooming via the mouse wheel. Construction-only.
cropBoxMovableBooleantrueAllow moving the crop box. Construction-only.
cropBoxResizableBooleantrueAllow resizing the crop box. Construction-only.
autoCropBooleantrueRender a crop box automatically on init. Construction-only.
autoCropAreaNumber0.8Initial crop-box size as a fraction of the canvas (01). Construction-only.
responsiveBooleantrueRe-render the cropper on window resize. Construction-only.
previewunknownundefinedLive crop-thumbnail target(s) — a selector string or element ref(s) (HTMLElement / array / NodeList). Construction-only (v1 has no setPreview). On Lit prefer an element ref: a document selector can't cross the wrapper's shadow boundary.
optionsObject{}Raw Cropper.js Options passthrough — spread into the constructor before the curated keys (explicit props win). Use it for any v1 option not surfaced above (modal, restore, minCropBoxWidth, wheelZoomRatio, …).

Events

The wrapper forwards Cropper.js's six lifecycle events. The continuous crop event also drives the two-way data model.

EventPayloadFires when
readyThe image is loaded and the cropper is built and ready.
cropstart{ action }A pointer gesture on the crop box / canvas starts.
cropmove{ action }The crop box / canvas is being changed.
cropend{ action }A pointer gesture ends.
crop{ x, y, width, height, rotate, scaleX, scaleY }The crop box changes (fires continuously). Also drives the two-way data model.
zoom{ ratio, oldRatio }The canvas is zoomed in or out.

Imperative handle

Beyond props, the component exposes imperative methods declared once in the Rozie source via $expose. Grab a handle with your framework's native ref mechanism (React useRef / Vue template ref / Svelte bind:this / Angular viewChild / Solid callback ref / the Lit custom element itself) and call them directly:

MethodDescription
getCropperReturn the underlying Cropper.js instance for direct API access (the raw-engine escape hatch). null before mount.
getDataReturn the current crop box as { x, y, width, height, rotate, scaleX, scaleY }getData(rounded?) (pass true to round to whole pixels). null before mount.
getCanvasDataReturn the canvas (wrapped image) position/size as { left, top, width, height, naturalWidth, naturalHeight }. null before mount.
getCropBoxDataReturn the crop-box position/size in canvas pixels as { left, top, width, height }. null before mount.
getImageDataReturn the image data — { left, top, width, height, rotate, scaleX, scaleY, naturalWidth, naturalHeight, aspectRatio }. null before mount.
getContainerDataReturn the container size as { width, height }. null before mount.
getCroppedCanvasReturn an HTMLCanvasElement drawn from the cropped area — getCroppedCanvas(opts?).
getCroppedDataURLConvenience: the cropped area as a toDataURL() string — getCroppedDataURL(opts?).
resetReset the image and crop box to their initial states.
clearClear (hide) the crop box. Pair with showCropBox().
showCropBoxShow the crop box (Cropper crop()) — re-enables cropping after clear().
replaceReplace the image with a new source URL — replace(url).
rotateToRotate the image to an absolute degree — rotateTo(deg).
rotateByRotate the image by a relative degree — rotateBy(deg).
zoomToZoom the canvas to an absolute ratio — zoomTo(ratio, pivot?) (optional { x, y } zoom pivot).
zoomByZoom the canvas by a relative ratio — zoomBy(ratio).
scaleXFlip/scale the image horizontally — scaleX(n) (e.g. -1).
scaleYFlip/scale the image vertically — scaleY(n).
scaleScale (flip) the image on both axes — scale(scaleX, scaleY?) (scaleY defaults to scaleX).
setCanvasDataSet the canvas position/size — setCanvasData({ left?, top?, width?, height? }).
setCropBoxDataSet the crop-box position/size — setCropBoxData({ left?, top?, width?, height? }).
moveToMove the canvas to an absolute position — moveTo(x, y?) (y defaults to x).
moveMove the canvas by a relative offset — move(offsetX, offsetY?) (offsetY defaults to offsetX).
enableEnable (unfreeze) the cropper.
disableDisable (freeze) the cropper.
setAspectRatioSet the crop box aspect ratio — setAspectRatio(ratio) (NaN for free).
setDragModeSet the drag mode — setDragMode('crop' | 'move' | 'none').

Why crop/zoom are not $expose verbs

Cropper.js names crop and zoom as both events and methods, and data is a model prop (so React auto-generates an internal setData setter). A bare crop/zoom verb would collide with the same-named emit (ROZ121) and a setData verb with the model setter (ROZ524). So the imperative crop/zoom are exposed under collision-free names — showCropBox, zoomTo/zoomBy — and the crop box is set through the two-way data binding (getData reads it). The geometry setters setCanvasData/setCropBoxData are distinct names from the model auto-setter (setData), so they don't collide either. None of the 27 verbs shadows a Lit lifecycle method.

React example:

tsx
import { useRef } from 'react';
import { Cropper, type CropperHandle } from '@rozie-ui/cropper-react';

const cropper = useRef<CropperHandle>(null);
// <Cropper ref={cropper} ... />
const url = cropper.current?.getCroppedDataURL();   // export the crop
cropper.current?.rotateBy(90);                       // rotate 90° clockwise

Recipes

Export the cropped image

The money method is getCroppedCanvas() — draw the cropped area into a fresh <canvas> you can read as a data URL or blob:

tsx
const url = cropper.current?.getCroppedDataURL();
// or, for a Blob upload:
cropper.current?.getCroppedCanvas()?.toBlob((blob) => upload(blob), 'image/png');

Aspect-ratio presets

Drive the crop box shape declaratively with the aspectRatio prop, or imperatively with setAspectRatio:

ts
cropper.setAspectRatio(1);     // square
cropper.setAspectRatio(16 / 9);
cropper.setAspectRatio(NaN);   // free

Rotate & flip

ts
cropper.rotateBy(90);    // rotate 90° clockwise
cropper.scaleX(-1);      // flip horizontally
cropper.scaleY(-1);      // flip vertically

Two-way crop box

Bind data to read and drive the crop box. The wrapper echoes the live box on every crop event and applies consumer writes via setData, with a round-trip guard so the two never oscillate:

vue
<Cropper v-model:data="box" src="/photo.jpg" />
<pre>{{ box }}</pre>   <!-- { x, y, width, height, rotate, scaleX, scaleY } -->

Pan vs. crop with dragMode

ts
cropper.setDragMode('move');   // drag pans the image
cropper.setDragMode('crop');   // drag draws a new crop box

Gotchas

Import the engine CSS yourself

The crop UI (.cropper-container, .cropper-view-box, …) is engine-created DOM that never carries Rozie's scope attribute, so the scoped <style> can't ship it. Import cropperjs/dist/cropper.css once at your app entry, or the cropper renders unstyled.

Why v1, not v2

Cropper.js latest is v2, a ground-up rewrite as a set of Web Components (<cropper-canvas>, <cropper-image>, …) with a completely different API. It is already "cross-framework" via custom elements, so wrapping it would be redundant (especially into Lit). These packages wrap the mature, imperative v1 (new Cropper(img, options)) — the engine the competing wrappers target. Pin cropperjs@^1.

Construction-time vs. runtime-reconciled options

Cropper.js v1 ships runtime setters only for the aspect ratio, drag mode, crop box, enable/disable and source. Those props (aspectRatio, dragMode, data, disabled, src) reconcile live; the rest are applied at construction (see the Runtime-updatable? column). Set them once at mount.

The crop box is rounded

getData() returns sub-pixel floats; the two-way round-trip guard compares the box rounded to whole pixels (plus exact rotate/scaleX/scaleY), so a consumer writing integer coordinates won't trigger a redundant setData.

Cross-references

Pre-v1.0 — internal monorepo.