DEV Community

Israel Merljak
Israel Merljak

Posted on

Building a Modern Image Cropper in React with CropperJS 2.x

Image cropping is a common requirement in web applications - from profile picture uploads to photo editing tools. While CropperJS has been the go-to library for years, version 2.x brought a complete rewrite using modern web components. This created a gap: the existing react-cropper library only supports v1.x.

In this tutorial, I'll show you how to implement image cropping in React using CropperJS 2.x with full TypeScript support.

Why CropperJS 2.x?

CropperJS 2.x is a ground-up rewrite that leverages modern web standards:

  • Web Components - Built on native Custom Elements
  • Better Performance - Optimized rendering and memory usage
  • Modern Architecture - Cleaner API surface
  • Framework Agnostic - Works anywhere web components work

However, using web components directly in React requires careful handling of refs, events, and lifecycle management.

Meet react-cropper-2

I built @imerljak/react-cropper-2 to provide idiomatic React bindings for CropperJS 2.x. The library offers three flexible APIs:

  1. Component API - Drop-in React component (easiest)
  2. Hook API - Composable hook with render function
  3. Advanced Hook API - Maximum control for power users

All with full TypeScript support.

Installation

npm install @imerljak/react-cropper-2 cropperjs@2
Enter fullscreen mode Exit fullscreen mode

The library automatically imports and registers CropperJS web components, so you don't need to do it manually.

Quick Start: Component API

The simplest way to get started is using the <Cropper> component:

import { useRef } from 'react';
import { Cropper, type CropperRef } from '@imerljak/react-cropper-2';

function ProfilePicture() {
  const cropperRef = useRef<CropperRef>(null);

  const handleSave = async () => {
    const canvas = await cropperRef.current?.getCroppedCanvas();
    if (canvas) {
      const blob = await new Promise(resolve =>
        canvas.toBlob(resolve, 'image/jpeg', 0.9)
      );
      // Upload blob to your server
      console.log('Ready to upload:', blob);
    }
  };

  return (
    <div>
      <Cropper
        ref={cropperRef}
        src="/profile-photo.jpg"
        alt="Profile picture"
        aspectRatio={1} // Square crop
        onReady={() => console.log('Cropper ready!')}
        onChange={(e) => console.log('Crop changed:', e.detail.bounds)}
      />
      <button onClick={handleSave}>Save</button>
      <button onClick={() => cropperRef.current?.reset()}>Reset</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Live Preview with useCropper Hook

For more control, use the useCropper hook which provides built-in state management:

import { useState } from 'react';
import { useCropper } from '@imerljak/react-cropper-2';

function ImageEditor() {
  const [previewUrl, setPreviewUrl] = useState<string>();

  const { renderCropper, bounds, reset, getCroppedCanvas } = useCropper({
    src: '/my-image.jpg',
    alt: 'Edit me',
    aspectRatio: 16 / 9,
    onChange: async () => {
      // Update preview on every crop change
      const canvas = await getCroppedCanvas();
      setPreviewUrl(canvas?.toDataURL('image/png'));
    },
  });

  return (
    <div className="editor-layout">
      <div className="cropper-section">
        {renderCropper({ style: { maxHeight: '500px' } })}
      </div>

      <div className="preview-section">
        <h3>Preview</h3>
        {previewUrl && <img src={previewUrl} alt="Preview" />}
        {bounds && (
          <pre>
            {JSON.stringify(bounds, null, 2)}
          </pre>
        )}
        <button onClick={reset}>Reset</button>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Advanced: Custom Cropper UI

Need complete control? Use useCropperAdvanced to build custom UIs:

import { useCropperAdvanced } from '@imerljak/react-cropper-2';

function CustomCropper() {
  const {
    canvasRef,
    selectionRef,
    bounds,
    setBounds,
    reset,
  } = useCropperAdvanced({
    autoInitialize: true,
    onReady: () => console.log('Ready!'),
  });

  const setSquareCrop = () => {
    setBounds({ x: 50, y: 50, width: 300, height: 300 });
  };

  return (
    <div>
      <cropper-canvas ref={canvasRef} background>
        <cropper-image
          src="/image.jpg"
          alt="Custom cropper"
          rotatable
          scalable
        />
        <cropper-selection
          ref={selectionRef}
          initial-coverage={0.8}
          movable
          resizable
          outlined
        >
          <cropper-grid role="grid" bordered covered />
          <cropper-handle action="move" />
          <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>

      <div className="custom-controls">
        <button onClick={setSquareCrop}>Square Crop</button>
        <button onClick={reset}>Reset</button>
        {bounds && (
          <span>
            {bounds.width}x{bounds.height}
          </span>
        )}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

TypeScript Benefits

The library is TypeScript-first with comprehensive type definitions:

import type {
  CropperRef,
  CropperBounds,
  CropperEventHandler,
  GetCroppedCanvasOptions
} from '@imerljak/react-cropper-2';

// Fully typed ref
const cropperRef = useRef<CropperRef>(null);

// Type-safe event handlers
const handleChange: CropperEventHandler = (event) => {
  const { bounds, canvas } = event.detail;
  console.log(`Crop: ${bounds.width}x${bounds.height}`);
};

// Type-safe options
const options: GetCroppedCanvasOptions = {
  width: 800,
  height: 600,
  fillColor: '#fff',
};
Enter fullscreen mode Exit fullscreen mode

Common Use Cases

Avatar Upload with Circular Crop

function AvatarUpload() {
  const cropperRef = useRef<CropperRef>(null);

  const handleUpload = async () => {
    const canvas = await cropperRef.current?.getCroppedCanvas({
      width: 200,
      height: 200,
    });

    if (canvas) {
      const blob = await new Promise<Blob | null>(resolve =>
        canvas.toBlob(resolve, 'image/jpeg', 0.95)
      );

      const formData = new FormData();
      formData.append('avatar', blob!, 'avatar.jpg');

      await fetch('/api/upload-avatar', {
        method: 'POST',
        body: formData,
      });
    }
  };

  return (
    <Cropper
      ref={cropperRef}
      src="/uploaded-photo.jpg"
      aspectRatio={1}
      initialCoverage={0.8}
      rounded // Makes it appear circular
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Multiple Aspect Ratios

function MultiAspectCropper() {
  const [aspectRatio, setAspectRatio] = useState(16 / 9);

  return (
    <div>
      <div className="aspect-ratio-controls">
        <button onClick={() => setAspectRatio(16 / 9)}>16:9</button>
        <button onClick={() => setAspectRatio(4 / 3)}>4:3</button>
        <button onClick={() => setAspectRatio(1)}>1:1</button>
        <button onClick={() => setAspectRatio(undefined)}>Free</button>
      </div>

      <Cropper
        src="/image.jpg"
        aspectRatio={aspectRatio}
      />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

CORS Image Handling

When cropping images from different domains:

<Cropper
  src="https://example.com/image.jpg"
  crossOrigin="anonymous"
  alt="Remote image"
/>
Enter fullscreen mode Exit fullscreen mode

API Overview

Component Props

Prop Type Default Description
src string required Image URL
aspectRatio number - Aspect ratio (e.g., 16/9)
initialCoverage number 0.5 Initial crop coverage (0-1)
background boolean true Show background
movable boolean true Allow moving selection
resizable boolean true Allow resizing
zoomable boolean true Allow zooming
onReady function - Fires when ready
onChange function - Fires on crop change

Ref Methods

interface CropperRef {
  getCanvas: () => CropperCanvasElement | null;
  getBounds: () => CropperBounds | null;
  setBounds: (bounds: Partial<CropperBounds>) => void;
  reset: () => void;
  clear: () => void;
  getCroppedCanvas: (options?) => Promise<HTMLCanvasElement | null>;
}
Enter fullscreen mode Exit fullscreen mode

Live Examples

Check out the interactive Storybook documentation with live examples covering:

  • Basic cropping scenarios
  • Different aspect ratios
  • Custom styling
  • Event handling patterns
  • All three API approaches
  • Advanced configurations

Browser Support

Works in all modern browsers that support:

  • ES2020
  • Web Components (Custom Elements)
  • ES Modules

Comparison with react-cropper (v1)

If you're coming from react-cropper (v1.x wrapper):

Feature react-cropper react-cropper-2
CropperJS version 1.x 2.x
Architecture jQuery-based Web Components
TypeScript Partial Full, strict mode
React 19
Bundle size Larger Smaller
APIs 1 (component) 3 (component + hooks)

When to Use Each API

  • Component API - Quick integration, simple use cases, prefer components
  • Hook API - Need state management, composable logic, custom layouts
  • Advanced Hook API - Complete control, custom UI, complex interactions

Wrapping Up

react-cropper-2 brings CropperJS 2.x's modern web component architecture to React with three flexible APIs, full TypeScript support, and comprehensive testing.

Resources

Try it in your next React project and let me know what you think! Questions and feedback are always welcome.

Top comments (0)