Introduction:
Welcome to this tutorial on creating an image modal with crop and rotate functionality in React! In this blog post, we'll explore how to leverage the power of react-easy-crop library to build an interactive image modal in react similar to those found in popular platforms like GitHub or Facebook.
here is live demo link
In the live demo, you can experience the full functionality of the image modal with crop and rotate features.
The focus of this tutorial is to develop a solution that minimizes the need for excessive prop passing between components. To achieve this, we'll harness the capabilities of React's context API. By creating a context provider and a custom hook, we can seamlessly share data and functions across our components without the hassle of passing props down the component tree.
So, let's dive in and learn how to create an image modal that allows users to crop and rotate their uploaded images.
Setting Up the Project:
To get started with our image modal implementation, i'll assume you already have a React project set up. For UI iām using Tailwind CSS. But you can use any UI library as your wish.
For the image cropping and rotating functionality, we'll be utilizing the react-easy-crop library. This library provides a simple and intuitive way to crop and interact with images and videos within a React component.
We will also use the heroicons and classnames libraries in our tutorial. To install all the libraries and their dependencies, open your terminal and navigate to your project's directory. Run the following command:
npm install react-easy-crop classnames @heroicons/react
#or
yarn add react-easy-crop classnames @heroicons/react
Now that we have react-easy-crop
and other libraries are installed, let's move on to creating the necessary components and implementing the image modal functionality.
Creating base components:
In this tutorial, we'll create two essential base components Button
and Modal
which will be needed in our image modal with crop and rotate functionality. Let's take a closer look at each of them:
// src/components/base/Button.jsx
import classNames from 'classnames';
const Button = ({ variant, className, children, ...rest }) => {
return (
<button
type="button"
className={classNames(className, 'hover:shadow-inner px-4 py-2 text-sm rounded-3xl', {
'bg-blue-500 text-white hover:bg-blue-700 hover:text-white': variant === 'primary',
'bg-red-500 text-white hover:bg-red-700 hover:text-white': variant === 'secondary',
'bg-white text-gray-900 hover:bg-white hover:text-blue-500': variant === 'light'
})}
{...rest}
>
{children}
</button>
);
};
export default Button;
The Button
component is a reusable and versatile element that allows us to create different styles of buttons with ease.
//src/components/base/Modal.jsx
import classNames from 'classnames';
const Modal = ({ open, children }) => {
return (
<div
className={classNames('fixed z-10 overflow-y-auto top-0 w-full left-0', {
hidden: !open
})}
id="modal"
>
<div className="flex items-center justify-center min-height-100vh pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 transition-opacity">
<div className="absolute inset-0 bg-gray-900 opacity-75"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen">ā</span>
<div
className="inline-block align-center bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline"
>
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">{children}</div>
</div>
</div>
</div>
</div>
);
};
export default Modal;
The Modal component plays a crucial role in our image modal implementation. It handles the display of content in a modal overlay, providing a clean and focused interface for editing images.
Creating the image crop context:
One of the essential aspects of building our image modal with crop and rotate functionality is to manage the state effectively. To achieve this, we'll create a React context that will provide the necessary data and functions to components that require access to this state.
//src/providers/ImageCropProvider.jsx
/* eslint-disable react-refresh/only-export-components */
import { createContext, useCallback, useContext, useState } from 'react';
import getCroppedImg from '../helpers/cropImage';
export const ImageCropContext = createContext({});
const defaultImage = null;
const defaultCrop = { x: 0, y: 0 };
const defaultRotation = 0;
const defaultZoom = 1;
const defaultCroppedAreaPixels = null;
const ImageCropProvider = ({
children,
max_zoom = 3,
min_zoom = 1,
zoom_step = 0.1,
max_rotation = 360,
min_rotation = 0,
rotation_step = 5
}) => {
const [image, setImage] = useState(defaultImage);
const [crop, setCrop] = useState(defaultCrop);
const [rotation, setRotation] = useState(defaultRotation);
const [zoom, setZoom] = useState(defaultZoom);
const [croppedAreaPixels, setCroppedAreaPixels] = useState(defaultCroppedAreaPixels);
const onCropComplete = useCallback((_croppedArea, croppedAreaPixels) => {
setCroppedAreaPixels(croppedAreaPixels);
}, []);
const handleZoomIn = () => {
if (zoom < max_zoom) {
setZoom(zoom + zoom_step * 2);
}
};
const handleZoomOut = () => {
if (zoom > min_zoom) {
setZoom(zoom - zoom_step * 2);
}
};
const handleRotateCw = () => {
setRotation(rotation + rotation_step);
};
const handleRotateAntiCw = () => {
setRotation(rotation - rotation_step);
};
const getProcessedImage = async () => {
if (image && croppedAreaPixels) {
const croppedImage = await getCroppedImg(image, croppedAreaPixels, rotation);
const imageFile = new File([croppedImage.file], `img-${Date.now()}.png`, {
type: 'image/png'
});
return imageFile;
}
};
const resetStates = () => {
setImage(defaultImage);
setCrop(defaultCrop);
setRotation(defaultRotation);
setZoom(defaultZoom);
setCroppedAreaPixels(defaultCroppedAreaPixels);
};
return (
<ImageCropContext.Provider
value={{
image,
setImage,
zoom,
setZoom,
rotation,
setRotation,
crop,
setCrop,
croppedAreaPixels,
setCroppedAreaPixels,
onCropComplete,
getProcessedImage,
handleZoomIn,
handleZoomOut,
handleRotateAntiCw,
handleRotateCw,
max_zoom,
min_zoom,
zoom_step,
max_rotation,
min_rotation,
rotation_step,
resetStates
}}
>
{children}
</ImageCropContext.Provider>
);
};
export const useImageCropContext = () => useContext(ImageCropContext);
export default ImageCropProvider;
In the provided code snippet, we've defined the ImageCropProvider
component. This component serves as the context provider and wraps its children with the ImageCropContext.Provider
. Let's take a closer look at how this works:
The ImageCropContext
will be our centralized state management solution for image cropping and rotating.
It is a functional component that takes several optional props related to the configuration of image cropping and rotation. These props include max_zoom
, min_zoom
, zoom_step
, max_rotation
, min_rotation
, and rotation_step
, which can be customized based on the specific requirements of the application.
It will encapsulate the following state variables:
-
image
: Holds the selected image that we want to crop and rotate. -
crop
: Represents the x and y coordinates of the current cropping area. -
rotation
: Keeps track of the rotation angle of the image. -
zoom
: Manages the zoom level of the image. -
croppedAreaPixels
: Stores the pixel values of the cropped area.
Additionally, we'll include several utility functions that allow us to manipulate the zoom and rotation of the image:
-
handleZoomIn
: Increases the zoom level (up to a maximum value). -
handleZoomOut
: Decreases the zoom level (down to a minimum value). -
handleRotateCw
: Rotates the image clockwise by a specified angle. -
handleRotateAntiCw
: Rotates the image anti-clockwise by a specified angle. -
resetStates
: Reset all the states.
Furthermore, we've created a function called getProcessedImage
. This function uses a helper function, getCroppedImg
(not shown in this code snippet), to extract the cropped image based on the original image, the pixel values of the cropped area, and the rotation angle. The result is a new File object representing the cropped image, which can be used later to upload to the server.
Also we created a custom hook useImageCropContext
to consume the ImageCropContext
in other components
Building the Custom Cropper Component
Now, it's time to build a custom Cropper component that utilizes the react-easy-crop
library, allowing users to interactively crop and rotate their selected images.
//src/components/cropper/Cropper.jsx
import EasyCropper from 'react-easy-crop';
import { useImageCropContext } from '../../providers/ImageCropProvider';
const Cropper = () => {
const { image, zoom, setZoom, rotation, setRotation, crop, setCrop, onCropComplete } =
useImageCropContext();
return (
<EasyCropper
image={image || undefined}
crop={crop}
zoom={zoom}
rotation={rotation}
cropShape="round"
aspect={1}
onCropChange={setCrop}
onCropComplete={onCropComplete}
onZoomChange={setZoom}
setRotation={setRotation}
showGrid={false}
cropSize={{ width: 185, height: 185 }}
style={{
containerStyle: {
height: 220,
width: 220,
top: 8,
bottom: 8,
left: 8,
right: 8
}
}}
/>
);
};
export default Cropper;
The Cropper
component serves as an interface between the react-easy-crop
library and our ImageCropContext
. It accesses the necessary state and functions from the context using the useImageCropContext
custom hook. Here's how it works:
- Accessing Context Data:
- We use the
useImageCropContext
hook to access the data from theImageCropContext
. This hook returns an object that contains the state variables and functions we need for the cropping and rotating functionality. - Destructuring this object, we get access to
image
,zoom
,setZoom
,rotation
,setRotation
,crop
,setCrop
, andonCropComplete
.
- We use the
- EasyCropper Component:
- The
EasyCropper
component is imported from thereact-easy-crop
library. It is a versatile component that provides interactive image cropping and rotation features. - We pass various props to this component to control its behavior based on the state from the
ImageCropContext
.
- The
- Props and Functionality:
-
image
: We provide the image from our context as a prop toEasyCropper
so that it knows which image to crop and display. If image is undefined, the cropper will not render any image initially. -
crop
,zoom
, androtation
: These props are linked to their respective state variables from the context, ensuring that the cropper reflects the currentcrop
,zoom
, androtation
values. -
cropShape
andaspect
: We setcropShape
to "round" andaspect
to 1, indicating that the cropping area should have a circular shape and maintain a 1:1 aspect ratio. -
onCropChange
,onZoomChange
, andsetRotation
: These callback props update the corresponding state variables in the context whenever the user interacts with the cropper.
-
- Styling the Cropper:
- We provide a style object to customize the appearance of the
EasyCropper
component. In this example, we set the height and width of the cropper container to 220 pixels, with a margin of 8 pixels around the edges to create some padding.
- We provide a style object to customize the appearance of the
Building the Image Crop Modal Content
With the ImageCropContext
and Cropper
component in place, we're ready to construct the core of our image crop modal. The ImageCropModalContent
component is where users can interact with the cropper, adjust zoom and rotation, and upload new images for cropping and rotating.
To begin, we'll need some CSS styles for the crop container. Add the following CSS in the index.css
file:
.crop-container {
position: relative;
width: 236px;
height: 236px;
background: linear-gradient(to right, #cbd4e1 8px, transparent 8px) 0 0,
linear-gradient(to right, #cbd4e1 8px, transparent 8px) 0 100%,
linear-gradient(to left, #cbd4e1 8px, transparent 8px) 100% 0,
linear-gradient(to left, #cbd4e1 8px, transparent 8px) 100% 100%,
linear-gradient(to bottom, #cbd4e1 8px, transparent 8px) 0 0,
linear-gradient(to bottom, #cbd4e1 8px, transparent 8px) 100% 0,
linear-gradient(to top, #cbd4e1 8px, transparent 8px) 0 100%,
linear-gradient(to top, #cbd4e1 8px, transparent 8px) 100% 100%;
background-repeat: no-repeat;
background-size: 70px 70px;
}
.reactEasyCrop_CropArea {
color: rgba(255, 255, 255, 0.8) !important;
}
Now, let's proceed with the ImageCropModalContent
component implementation:
//src/components/ImageCropModalContent.jsx
import { readFile } from '../helpers/cropImage';
import { useImageCropContext } from '../providers/ImageCropProvider';
import Button from '../components/base/Button';
import Cropper from '../components/cropper/Cropper';
import { RotationSlider, ZoomSlider } from '../components/cropper/Sliders';
const ImageCropModalContent = ({ handleDone, handleClose }) => {
const { setImage } = useImageCropContext();
const handleFileChange = async ({ target: { files } }) => {
const file = files && files[0];
const imageDataUrl = await readFile(file);
setImage(imageDataUrl);
};
return (
<div className="text-center relative">
<h5 className="text-gray-800 mb-4">Edit profile picture</h5>
<div className="border border-dashed border-gray-200 p-6 rounded-lg">
<div className="flex justify-center">
<div className="crop-container mb-4">
<Cropper />
</div>
</div>
<ZoomSlider className="mb-4" />
<RotationSlider className="mb-4" />
<input
type="file"
multiple
onChange={handleFileChange}
className="hidden"
id="avatarInput"
accept="image/*"
/>
<Button variant="light" className="shadow w-full mb-4 hover:shadow-lg">
<label htmlFor="avatarInput">Upload Another Picture</label>
</Button>
<div className="flex gap-2">
<Button variant="secondary" onClick={handleClose}>
Cancel
</Button>
<Button variant="primary" className="w-full" onClick={handleDone}>
Done & Save
</Button>
</div>
</div>
</div>
);
};
export default ImageCropModalContent;
Let's explore the code of the ImageCropModalContent
component and the ZoomSlider
and RotationSlider
components, which enable users to control the zoom and rotation of the image while cropping.
- The
ImageCropModalContent
component begins by importing thereadFile
function fromhelpers/cropImage
. This function is responsible for reading the image file and returning its data URL. - We also import the
useImageCropContext
hook fromproviders/ImageCropProvider
to access thesetImage
function from the context. When users select an image file, thehandleFileChange
function reads the image data URL and sets it in the context usingsetImage
. - Rendering the Cropper:
The
ImageCropModalContent
component renders the Cropper component, allowing users to interactively crop and rotate the selected image. We place the Cropper inside a container with the classcrop-container
for styling purposes. - Zoom and Rotation Sliders:
We import the
ZoomSlider
andRotationSlider
components fromcomponents/cropper/Sliders
. These sliders are responsible for adjusting the zoom level and rotation angle of the image, respectively. - File Input and Upload Button:
We include an input element with type file to allow users to upload another picture for cropping. The
handleFileChange
function is triggered when users select a new image file. When users click on the Upload Another Picture button, it triggers the file input to open, enabling them to choose a new image. - Action Buttons:
We provide two action buttons: Cancel and Done & Save. The Cancel button closes the image crop modal when clicked, while the Done & Save button saves the cropped image and triggers the
handleDone
function, which is passed as a prop from the parent component.
ZoomSlider and RotationSlider Components:
The ZoomSlider
and RotationSlider
components allow users to control the zoom level and rotation angle of the image during cropping. They interact with the useImageCropContext
hook to access the necessary state and functions from the ImageCropContext
.
//src/components/cropper/Sliders.jsx
import { useImageCropContext } from '../../providers/ImageCropProvider';
import {
ArrowUturnLeftIcon,
ArrowUturnRightIcon,
MinusIcon,
PlusIcon
} from '@heroicons/react/24/solid';
import classNames from 'classnames';
export const ZoomSlider = ({ className }) => {
const { zoom, setZoom, handleZoomIn, handleZoomOut, max_zoom, min_zoom, zoom_step } =
useImageCropContext();
return (
<div className={classNames(className, 'flex items-center justify-center gap-2')}>
<button className="p-1" onClick={handleZoomOut}>
<MinusIcon className="text-gray-400 w-4" />
</button>
<input
type="range"
name="volju"
min={min_zoom}
max={max_zoom}
step={zoom_step}
value={zoom}
onChange={e => {
setZoom(Number(e.target.value));
}}
/>
<button className="p-1" onClick={handleZoomIn}>
<PlusIcon className="text-gray-400 w-4" />
</button>
</div>
);
};
export const RotationSlider = ({ className }) => {
const {
rotation,
setRotation,
max_rotation,
min_rotation,
rotation_step,
handleRotateAntiCw,
handleRotateCw
} = useImageCropContext();
return (
<div className={classNames(className, 'flex items-center justify-center gap-2')}>
<button className="p-1" onClick={handleRotateAntiCw}>
<ArrowUturnLeftIcon className="text-gray-400 w-4" />
</button>
<input
type="range"
name="volju"
min={min_rotation}
max={max_rotation}
step={rotation_step}
value={rotation}
onChange={e => {
setRotation(Number(e.target.value));
}}
/>
<button className="p-1" onClick={handleRotateCw}>
<ArrowUturnRightIcon className="text-gray-400 w-4" />
</button>
</div>
);
};
Helper Functions for Image Processing:
To achieve image cropping and rotating, we utilize a set of helper functions adapted from the examples of the react-easy-crop
library. These functions simplify the image processing tasks and enable us to generate the final cropped image.
//src/helpers/cropImage.js
export const readFile = file => {
return new Promise(resolve => {
const reader = new FileReader();
reader.addEventListener('load', () => resolve(reader.result), false);
reader.readAsDataURL(file);
});
};
export const createImage = url =>
new Promise((resolve, reject) => {
const image = new Image();
image.addEventListener('load', () => resolve(image));
image.addEventListener('error', error => reject(error));
image.setAttribute('crossOrigin', 'anonymous'); // needed to avoid cross-origin issues on CodeSandbox
image.src = url;
});
export function getRadianAngle(degreeValue) {
return (degreeValue * Math.PI) / 180;
}
/**
* Returns the new bounding area of a rotated rectangle.
*/
export function rotateSize(width, height, rotation) {
const rotRad = getRadianAngle(rotation);
return {
width: Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height),
height: Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height)
};
}
const getCroppedImg = async (
imageSrc,
pixelCrop = { x: 0, y: 0 },
rotation = 0,
flip = { horizontal: false, vertical: false }
) => {
const image = await createImage(imageSrc);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
return null;
}
const rotRad = getRadianAngle(rotation);
// calculate bounding box of the rotated image
const { width: bBoxWidth, height: bBoxHeight } = rotateSize(image.width, image.height, rotation);
// set canvas size to match the bounding box
canvas.width = bBoxWidth;
canvas.height = bBoxHeight;
// translate canvas context to a central location to allow rotating and flipping around the center
ctx.translate(bBoxWidth / 2, bBoxHeight / 2);
ctx.rotate(rotRad);
ctx.scale(flip.horizontal ? -1 : 1, flip.vertical ? -1 : 1);
ctx.translate(-image.width / 2, -image.height / 2);
// draw rotated image
ctx.drawImage(image, 0, 0);
// croppedAreaPixels values are bounding box relative
// extract the cropped image using these values
const data = ctx.getImageData(pixelCrop.x, pixelCrop.y, pixelCrop.width, pixelCrop.height);
// set canvas width to final desired crop size - this will clear existing context
canvas.width = pixelCrop.width;
canvas.height = pixelCrop.height;
// paste generated rotate image at the top left corner
ctx.putImageData(data, 0, 0);
// As Base64 string
// return canvas.toDataURL('image/jpeg');
// As a blob
return new Promise(resolve => {
canvas.toBlob(file => {
resolve({ file, url: URL.createObjectURL(file) });
}, 'image/jpeg');
});
};
export default getCroppedImg;
We used readFile
, getCroppedImg
functions in our components. Others functions wil be user internally among this functions.
Implementing the ImageModal and ImageCrop Interaction
In this section, we'll create the ImageCrop
component, which acts as the main entry point for users to interact with the image modal and crop/rotate their chosen images.
//src/components/ImageCrop.jsx
import { useState } from 'react';
import user1 from '../assets/user_1.png';
import Modal from '../components/base/Modal';
import { readFile } from '../helpers/cropImage';
import ImageCropModalContent from './ImageCropModalContent';
import { useImageCropContext } from '../providers/ImageCropProvider';
const ImageCrop = () => {
const [openModal, setOpenModal] = useState(false);
const [preview, setPreview] = useState(user1);
const { getProcessedImage, setImage, resetStates } = useImageCropContext();
const handleDone = async () => {
const avatar = await getProcessedImage();
setPreview(window.URL.createObjectURL(avatar));
resetStates();
setOpenModal(false);
};
const handleFileChange = async ({ target: { files } }) => {
const file = files && files[0];
const imageDataUrl = await readFile(file);
setImage(imageDataUrl);
setOpenModal(true);
};
return (
<div className="bg-gray-100 h-screen flex justify-center items-center">
<input
type="file"
onChange={handleFileChange}
className="hidden"
id="avatarInput"
accept="image/*"
/>
<label htmlFor="avatarInput" className="cursor-pointer">
<img
src={preview}
height={192}
width={192}
className="object-cover rounded-full h-48 w-48"
alt=""
/>
</label>
<Modal open={openModal} handleClose={() => setOpenModal(false)}>
<ImageCropModalContent handleDone={handleDone} handleClose={() => setOpenModal(false)} />
</Modal>
</div>
);
};
export default ImageCrop;
Integrating with ImageCropContext:
- State and Image Preview:
The
ImageCrop
component sets up two state variables:openModal
andpreview
.openModal
: Controls the visibility of the image modal. It is initially set tofalse
and becomestrue
when users upload an image or open the modal to crop and rotate.preview
: Holds the URL of the image to be displayed as a preview before cropping. It is initially set to a default image (user1
in this case). - Image Upload and Modal Interaction:
Users can click on the
img
element, which is a label for the hidden file input. When clicked, the file input dialog opens, allowing users to select an image to upload. ThehandleUpload
function is triggered when an image is selected. It reads the image data URL using thereadFile
function and sets it in the context usingsetImage
. It also sets theopenModal
state totrue
, showing the image modal. -
ImageModal
andImageCropModalContent
Integration: TheImageCrop
component renders anImageModal
component. TheImageCropModalContent
is passed as the content of the modal. The open prop of Modal controls the visibility of the modal, and thehandleClose
function passed as thehandleClose
prop is used to close the modal when needed. - Image Cropping and Preview Update:
The
handleDone
function is called when users finish cropping and click the Done & Save button in theImageCropModalContent
. InsidehandleDone
, we call thegetCroppedImage
function to get the cropped image from the context. The resulting File object is converted into a preview URL usingURL.createObjectURL
, which is then set in the preview state, updating the image preview with the cropped version. Finally, we setopenModal
to false, closing the image modal.
Integrating the ImageCrop Component
The App
component serves as the container for the ImageCrop
component, which provides users with an interface to interact with the image modal for cropping and rotating images. In your case it may be a different component
//src/App.jsx
import ImageCrop from './components/ImageCrop';
import ImageCropProvider from './providers/ImageCropProvider';
const App = () => {
return (
<div className="bg-gray-100 h-screen flex justify-center items-center">
<ImageCropProvider>
<ImageCrop />
</ImageCropProvider>
</div>
);
};
export default App;
In the App
component, we wrap the ImageCrop
component with the ImageCropProvider
. This ensures that the ImageCrop
component has access to the ImageCropContext
, enabling it to manage the state and functionalities for image cropping and rotation effectively.
The ImageCrop
component serves as the main user interaction point for selecting, cropping, and rotating images.
By wrapping the ImageCrop
component with the ImageCropProvider
, it can access the necessary context and seamlessly interact with the Cropper
component and ImageCropModalContent
.
Conclusion
In conclusion, we have successfully implemented an image modal with crop and rotation functionalities using React, and the react-easy-crop
library. By utilizing the ImageCropProvider
context, we effectively managed the state and actions required for image manipulation, reducing the need for excessive prop passing between components.
With this image modal in place, users can now effortlessly edit their profile pictures or any other images uploaded to the application.
here is full project code link: GitHub Code Source
Hope this tutorial has been helpful in expanding your knowledge of React, and that you're inspired to apply these techniques to enhance your own projects.
Top comments (2)
Hey, I am a little lost in why the image crop provider has to wrap around the image crop. The modal just remains open and has no way to close
Share your code...