A modern React wrapper for CropperJS 2.x
A TypeScript-first React library that provides idiomatic React bindings for CropperJS 2.x web components.
- ✅ CropperJS 2.x Support - Built for the latest CropperJS web components
- ✅ React 19 Compatible - Full support for React 18.x and 19.x
- ✅ SSR Safe - Works in Next.js App Router and Remix without
next/dynamic - ✅ TypeScript First - Complete type safety with strict mode
- ✅ Three APIs - Component, simplified hook, and advanced hook
- ✅ Export Helpers - Blob, File and data URL in one call
- ✅ Circular Crops -
shape="circle"for avatars, masked on export - ✅ Live Previews -
<CropperPreview />thumbnails with no React re-renders - ✅ Modern Architecture - Built on web standards
- ✅ Fully Tested - 99%+ test coverage across unit, SSR and browser suites
- ✅ Interactive Docs - Storybook documentation
The original react-cropper library only supports CropperJS 1.x. CropperJS 2.x is a complete rewrite using modern web components, requiring new React bindings. This library provides three flexible APIs to work with CropperJS 2.x in React applications.
npm install @imerljak/react-cropper-2 cropperjs@2The simplest way to use the library:
import { useRef } from 'react';
import { Cropper, type CropperRef } from '@imerljak/react-cropper-2';
function App() {
const cropperRef = useRef<CropperRef>(null);
const handleGetBounds = () => {
const bounds = cropperRef.current?.getBounds();
console.log('Crop bounds:', bounds);
};
return (
<div>
<Cropper
ref={cropperRef}
src="/image.jpg"
alt="Crop me"
crossOrigin="anonymous"
aspectRatio={16 / 9}
onReady={() => console.log('Cropper ready!')}
onChange={(e) => console.log('Crop changed:', e.detail.bounds)}
/>
<button onClick={handleGetBounds}>Get Bounds</button>
<button onClick={() => cropperRef.current?.reset()}>Reset</button>
</div>
);
}For more control and composition:
import { useCropper } from '@imerljak/react-cropper-2';
function App() {
const { renderCropper, bounds, reset, clear, getCroppedCanvas } = useCropper({
src: '/image.jpg',
alt: 'Crop me',
crossOrigin: 'anonymous',
aspectRatio: 16 / 9,
onReady: () => console.log('Ready!'),
onChange: (e) => console.log('Changed:', e.detail),
});
const handleExport = async () => {
const canvas = await getCroppedCanvas();
const url = canvas?.toDataURL('image/png');
// Use the cropped image
};
return (
<div>
{renderCropper({ style: { maxHeight: '500px' } })}
<div>{bounds && <pre>{JSON.stringify(bounds, null, 2)}</pre>}</div>
<button onClick={reset}>Reset</button>
<button onClick={clear}>Clear</button>
<button onClick={handleExport}>Export</button>
</div>
);
}For maximum control, use the advanced hook:
import { useCropperAdvanced } from '@imerljak/react-cropper-2';
function App() {
const {
canvasRef,
selectionRef,
bounds,
isReady,
getBounds,
setBounds,
reset,
getCroppedCanvas,
} = useCropperAdvanced({
autoInitialize: true,
onReady: (canvas) => console.log('Ready!', canvas),
onChange: (e) => console.log('Changed:', e.detail),
});
return (
<div>
<cropper-canvas ref={canvasRef} background>
<cropper-image
src="/image.jpg"
alt="Crop me"
crossorigin="anonymous"
rotatable
scalable
translatable
/>
<cropper-selection
ref={selectionRef}
aspect-ratio={16 / 9}
initial-coverage={0.8}
movable
resizable
zoomable
outlined
>
<cropper-grid role="grid" bordered covered />
<cropper-crosshair centered />
<cropper-handle
action="move"
theme-color="rgba(255, 255, 255, 0.35)"
/>
<cropper-handle action="n-resize" />
<cropper-handle action="e-resize" />
<cropper-handle action="s-resize" />
<cropper-handle action="w-resize" />
<cropper-handle action="ne-resize" />
<cropper-handle action="nw-resize" />
<cropper-handle action="se-resize" />
<cropper-handle action="sw-resize" />
</cropper-selection>
</cropper-canvas>
<button
onClick={() => setBounds({ x: 50, y: 50, width: 200, height: 200 })}
>
Set Custom Bounds
</button>
<button onClick={reset}>Reset</button>
</div>
);
}| Prop | Type | Description |
|---|---|---|
src |
string |
Image URL (required) |
alt |
string |
Image alt text |
crossOrigin |
'anonymous' | 'use-credentials' |
CORS setting |
aspectRatio |
number |
Aspect ratio constraint (e.g., 16/9) |
initialCoverage |
number |
Initial crop coverage (0-1), relative to the canvas (see note) |
shape |
'rect' | 'circle' |
Selection shape (see note). Default: 'rect' |
background |
boolean |
Show background (default: true) |
rotatable |
boolean |
Enable rotation (default: true) |
scalable |
boolean |
Enable scaling (default: true) |
translatable |
boolean |
Enable translation (default: true) |
movable |
boolean |
Enable moving selection (default: true) |
resizable |
boolean |
Enable resizing selection (default: true) |
zoomable |
boolean |
Enable zooming (default: true) |
outlined |
boolean |
Show selection outline (default: true) |
onReady |
(canvas: CropperCanvasElement) => void |
Callback when ready (see note) |
onChange |
CropperEventHandler |
Callback when crop changes |
onCropStart |
CropperEventHandler |
Callback when crop starts |
onCropMove |
CropperEventHandler |
Callback during crop movement |
onCropEnd |
CropperEventHandler |
Callback when crop ends |
interface CropperRef {
getCanvas: () => CropperCanvasElement | null;
getSelection: () => CropperSelectionElement | null;
getImage: () => CropperImageElement | null;
getBounds: () => CropperBounds | null;
setBounds: (bounds: Partial<CropperBounds>) => void;
// Reset
reset: () => void;
resetSelection: () => void;
resetTransform: () => void;
clear: () => void;
// Transform controls
rotate: (degrees: number) => void;
zoom: (ratio: number) => void;
setZoom: (level: number) => void;
flipHorizontal: () => void;
flipVertical: () => void;
// Export
getCroppedCanvas: (options?) => Promise<HTMLCanvasElement | null>;
getCroppedBlob: (options?) => Promise<Blob | null>;
getCroppedFile: (filename: string, options?) => Promise<File | null>;
getCroppedDataURL: (options?) => Promise<string | null>;
}See Transform controls for the reset and transform semantics, and Exporting for the export helpers.
Both hooks return every method listed in CropperRef above, plus bounds and
isReady. They differ only in who renders the markup:
| Hook | Returns additionally | You render |
|---|---|---|
useCropper |
renderCropper() |
Nothing — call renderCropper() |
useCropperAdvanced |
canvasRef, selectionRef, imageRef |
The full element tree yourself |
// Simplified: the hook renders for you
const { renderCropper, bounds, reset } = useCropper({ src: '/image.jpg' });
// Advanced: you attach the refs — see Advanced Usage above
const { canvasRef, selectionRef, imageRef, bounds, reset } = useCropperAdvanced(
{ autoInitialize: true }
);useCropperAdvanced requires all three refs to be attached before it reports
ready.
getCroppedCanvas returns a raw canvas. The export helpers wrap the usual
boilerplate and all return promises:
const blob = await cropperRef.current?.getCroppedBlob({
mimeType: 'image/webp',
});
const file = await cropperRef.current?.getCroppedFile('avatar.png');
const url = await cropperRef.current?.getCroppedDataURL({
mimeType: 'image/jpeg',
quality: 0.8,
});
// Ready for multipart upload
const body = new FormData();
if (file) body.append('avatar', file);| Option | Type | Description |
|---|---|---|
mimeType |
'image/png' | 'image/jpeg' | 'image/webp' |
Output format. Default: 'image/png' |
quality |
number |
Encoder quality (0-1) for lossy formats. Default: 0.92 |
fillColor |
string |
Background painted behind the crop |
JPEG has no alpha channel, so transparent pixels would encode as black. A white
background is filled in automatically for JPEG unless you set fillColor.
Sizing options (width, height) can be passed alongside the encoding options.
cropperRef.current?.rotate(90); // degrees, not radians
cropperRef.current?.rotate(-90);
cropperRef.current?.zoom(0.1); // relative: +10%
cropperRef.current?.setZoom(2); // absolute: 2x natural scale
cropperRef.current?.flipHorizontal();
cropperRef.current?.flipVertical();rotate() takes degrees. CropperJS itself reads a bare number as radians;
this wrapper converts for you.
Reset granularity:
| Method | Selection | Image transform |
|---|---|---|
reset() |
reset | reset |
resetSelection() |
reset | untouched |
resetTransform() |
untouched | reset |
<CropperPreview /> mirrors the current crop as a thumbnail. It wraps
CropperJS's <cropper-viewer>, which syncs inside its own shadow DOM — so
dragging the selection updates every preview without re-rendering the React
tree.
const cropperRef = useRef<CropperRef>(null);
<Cropper ref={cropperRef} src="/avatar.jpg" shape="circle" />
<CropperPreview cropperRef={cropperRef} width={128} height={128} />
<CropperPreview cropperRef={cropperRef} width={48} height={48} shape="circle" />| Prop | Type | Description |
|---|---|---|
cropperRef |
RefObject<CropperRef> |
Ref of the cropper to mirror (required) |
width / height |
number |
Thumbnail size in pixels (required) |
shape |
'rect' | 'circle' |
Thumbnail shape. Default: 'rect' |
className / style |
— | Custom styling |
The package is safe to import in Next.js App Router, Remix and other RSC
setups. Entrypoints carry 'use client', and the CropperJS custom elements are
registered only in the browser, so importing on the server is a no-op rather
than a crash. No next/dynamic wrapper is required.
import {
isBrowserEnvironment,
registerCropperElements,
} from '@imerljak/react-cropper-2';Both are exported if you need to trigger or check registration yourself.
shape="circle" rounds the selection on screen and masks exports to a centred
circle via a canvas clip. Pixels outside the circle stay transparent, so use
image/png or image/webp to preserve the round edge — JPEG will fill them.
It also forces aspectRatio={1}. A circular selection over a non-square box
would not match the exported image, so a conflicting aspectRatio is overridden
and a warning is logged in development.
initialCoverage sizes the selection against the canvas, not the image.
The canvas letterboxes images whose aspect ratio differs from its own, so
initialCoverage={1} selects the entire canvas — including the empty bars
beside the image — not the entire image.
For an 800×400 image in a 908×400 canvas, initialCoverage={1} yields a
908×400 selection with 54px of empty canvas on each side.
onReady fires once the selection has valid bounds and the image has a
non-zero layout box, so every element it exposes is safe to measure
immediately — no polling required.
To open with the whole image selected, measure it in onReady and set the
bounds yourself:
const cropperRef = useRef<CropperRef>(null);
<Cropper
ref={cropperRef}
src="/image.jpg"
onReady={(canvas) => {
const image = cropperRef.current?.getImage();
if (!image) return;
const imageBox = image.getBoundingClientRect();
const canvasBox = canvas.getBoundingClientRect();
cropperRef.current?.setBounds({
x: imageBox.x - canvasBox.x,
y: imageBox.y - canvasBox.y,
width: imageBox.width,
height: imageBox.height,
});
}}
/>;# Install dependencies
npm install
# Start example app
npm run dev
# Run tests
npm test
# Run tests with coverage
npm run test:coverage
# Build library
npm run build
# Start Storybook
npm run storybook
# Build Storybook
npm run build-storybookreact-cropper-2/
├── src/
│ ├── components/
│ │ ├── Cropper.tsx # Main component
│ │ └── CropperPreview.tsx # Live thumbnail component
│ ├── hooks/
│ │ ├── useCropper.tsx # Simplified hook
│ │ └── useCropperAdvanced.ts # Advanced hook
│ ├── utils/
│ │ ├── canvas.ts # Export encoding and masking
│ │ └── shape.ts # Shape / aspect-ratio resolution
│ ├── types/
│ │ ├── index.ts # TypeScript types
│ │ └── jsx.d.ts # JSX declarations
│ ├── register.ts # SSR-safe custom element registration
│ ├── stories/ # Storybook stories
│ └── index.ts # Main export
├── examples/basic/ # Example application
├── tests/
│ ├── setup.ts # Test configuration
│ └── ssr/ # Node-environment SSR tests
└── dist/ # Build output
- React 18.x or 19.x
- CropperJS 2.0.1 or higher
- TypeScript 5.6+ (for TypeScript projects)
Supports all modern browsers that support:
- ES2020
- Web Components (Custom Elements)
- ES Modules
Contributions are welcome! Please feel free to submit issues and pull requests.
MIT © 2025
- CropperJS - The underlying library (v2.x)
- react-cropper - The v1 wrapper that inspired this one