CropModal
The CropModal is a comprehensive image cropping solution designed for modern web applications. It provides a complete workflow for image selection, cropping, processing, and upload with features including responsive design, accessibility, and robust error handling.
Quick Start
- Installation
npm install @adaptavant/crop-modal- Import
import { CropModal } from '@adaptavant/crop-modal';
Key Functionalitiesβ
π― Flexible Cropping Optionsβ
- Aspect Ratio Control: Fixed ratios (3:1 for banners, 1:1 for profile or service images)
- Shape Variants: Rectangle, rounded corners, or circles
- Zoom & Pan: Intuitive zoom controls with mouse wheel and touch support
- Precision Controls: Fine-grained positioning with visual guides
π Dual Operation Modesβ
- Offline Mode: Local processing with immediate preview and blob generation
- Online Mode: Direct upload to cloud storage with API integration
- Hybrid Workflow: Local image processing followed by remote upload
π± Responsive Designβ
- Mobile-First: Touch-optimized controls and gestures
- Adaptive UI: Automatic layout adjustments for different screen sizes
π‘οΈ Securityβ
- File Type Validation: Configurable MIME type restrictions
- Size Limits: Customizable file size constraints
- Image Processing: Automatic compression and resizing
- Error Boundaries: Comprehensive error handling and user feedback
Additional Exportsβ
The crop-modal package exports additional components and utilities beyond the main CropModal component:
Componentsβ
UploadFileButton- A wrapper component that provides file upload functionality to any button using the render props pattern
Utility Functionsβ
The package includes a comprehensive set of utility functions for image processing and validation:
clamp- Restricts numeric values within a specified rangecompressImage- Compresses image blobs using Compressor.jscreateImageElement- Creates HTMLImageElement from string sourcescreateUrl- Safely constructs URL instances with error handlinggenericSendFormDataRequest- Sends multipart/form-data POST requestsisFileSizeValid- Validates file sizes against limitsparseImageProcessingOptions- Merges image processing options with defaultsprocessLocalFile- Complete local file processing pipeline with validation, resize, and compressionreadFileImage- Converts blobs to data URLs or ArrayBuffersresizeImage- Resizes images while maintaining aspect ratioroundNumberToDecimal- Rounds numbers to specified decimal placesvalidateImageType- Validates files against accepted MIME types
TypeScript Typesβ
All TypeScript interfaces and types are exported, including CropModalProps, ImageProcessingOptions, ApiOptions, translation interfaces, and many utility types for comprehensive type safety.
Defaultβ
This example demonstrates the basic usage of CropModal with minimal configuration:
Workflow:
- User clicks "Open Crop Modal" to launch the cropping interface
- User uploads and crops an image within the modal
- On save, the modal closes and the cropped image appears in the avatar
- User can clear the image to reset the state
const [isOpen, setIsOpen] = React.useState(false);
const [imageUrl, setImageUrl] = React.useState(null);
const handleClose = React.useCallback(() => setIsOpen(false), []);
const handleSave = React.useCallback((newUrl) => {
setIsOpen(false);
setImageUrl(newUrl);
}, []);
return (
<Box>
<Avatar name="Default" size="96">
{imageUrl ? <AvatarImage src={imageUrl} /> : null}
</Avatar>
<CropModal
cropViewBoxOptions={{}}
dropAreaTranslations={{
uploadImageButtonLabel: 'Upload',
}}
imageUrl={imageUrl}
isOpen={isOpen}
modalWrapperTranslations={{
title: 'Default story',
subtitle: 'That`s what you get with the default/minimal configuration',
saveButtonLabel: 'Save',
cancelButtonLabel: 'Cancel',
closeModalButtonLabel: 'Close crop modal',
}}
onCancel={handleClose}
onClose={handleClose}
onSuccessfulUpload={handleSave}
/>
<Box className="flex mt-2 justify-center items-center gap-2">
<Button onClick={() => setIsOpen(true)}>Open Crop Modal</Button>
<Button isDisabled={!imageUrl} onClick={() => setImageUrl(null)}>
Clear image
</Button>
</Box>
</Box>
);
Banner Imageβ
This example demonstrates a complete end-to-end banner image workflow with advanced configuration for a 3:1 aspect ratio banner:
Advanced Workflow:
- Initial State: Empty banner area displays placeholder icon with upload button
- File Selection: User selects image through file input with MIME type validation
- Pre-processing: Image undergoes local processing (resize, compression, validation) before cropping
- Crop Modal: Opens with pre-processed image and banner-specific constraints
- Final Processing: User crops image within 3:1 aspect ratio constraints
- Result Display: Processed banner image displays in the container
- Management Options: Edit button reopens crop modal, delete button clears the image
const IMAGE_PROCESSING_OPTIONS = parseImageProcessingOptions();
const [isOpen, setIsOpen] = React.useState(false);
const [imageUrl, setImageUrl] = React.useState(null);
const [uploadedBlobUrl, setUploadedBlobUrl] = React.useState(null);
const handleClose = React.useCallback(() => setIsOpen(false), []);
const handleSave = React.useCallback((newUrl) => {
setIsOpen(false);
setImageUrl(newUrl);
setUploadedBlobUrl(null);
}, []);
const handleEditImage = React.useCallback(() => setIsOpen(true), []);
const handleDelete = React.useCallback(() => setImageUrl(null), []);
const handleFileInputChange = React.useCallback(async (files) => {
const { blob, errorMessage } = await processLocalFile(
files[0],
undefined,
IMAGE_PROCESSING_OPTIONS
);
if (!blob) {
window.alert(errorMessage);
return;
}
setIsOpen(true);
setUploadedBlobUrl(URL.createObjectURL(blob));
}, []);
return (
<Box className="w-full">
<Box className="bg-neutral-secondary rounded-8px after:rounded-8px relative flex aspect-[3/1] h-auto w-auto max-w-full items-center justify-center overflow-hidden after:pointer-events-none after:absolute after:inset-0 after:border after:border-solid after:border-[rgba(0,0,0,0.05)] after:content-['']">
{!imageUrl ? (
<SetmoreIcon />
) : (
<Image
alt="Banner"
className="absolute inset-0 h-full w-full object-cover"
src={imageUrl}
/>
)}
<Box className="absolute bottom-3 right-3 flex gap-1">
{!imageUrl && (
<UploadFileButton
accept={IMAGE_PROCESSING_OPTIONS.allowedMimeType}
onFilesChange={handleFileInputChange}
>
{({ onClick }) => (
<Button iconStart={ImageIcon} onClick={onClick} variant="neutralSecondary">
Upload banner image
</Button>
)}
</UploadFileButton>
)}
{!!imageUrl && (
<>
<Button iconStart={EditIcon} onClick={handleEditImage} variant="neutralSecondary">
Edit
</Button>
<Tooltip content="Delete" placement="bottom">
{({ triggerProps }) => (
<IconButton
aria-label="Delete Banner Image"
variant="neutralSecondary"
{...triggerProps}
icon={DeleteIcon}
onClick={handleDelete}
/>
)}
</Tooltip>
</>
)}
</Box>
</Box>
<CropModal
cropAreaTranslations={{
uploadNewButtonLabel: 'Upload new',
zoomInButtonLabel: 'Zoom in',
zoomOutButtonLabel: 'Zoom out',
}}
cropViewBoxOptions={{
width: 500,
aspectRatio: 3 / 1,
}}
imageProcessingOptions={IMAGE_PROCESSING_OPTIONS}
imageUrl={uploadedBlobUrl ?? imageUrl}
isOpen={isOpen}
modalWrapperTranslations={{
title: 'Banner image',
subtitle: '1200 x 400 px recommended Β· JPG, JPEG and PNG files only Β· Up to 5MB',
saveButtonLabel: 'Save',
cancelButtonLabel: 'Cancel',
closeModalButtonLabel: 'Close crop modal',
}}
onCancel={handleClose}
onClose={handleClose}
onSuccessfulUpload={handleSave}
/>
</Box>
);
Brand Logoβ
This example demonstrates a comprehensive brand logo management system circular cropping functionality:
Workflow:
- Initial State: Empty circular placeholder with brand icon and upload button
- File Selection: User selects logo image through file input with validation
- Pre-processing: Image undergoes local processing (resize, compression, validation) before cropping
- Circular Crop Modal: Opens with pre-processed image and circular crop constraints
- Logo Cropping: User adjusts image within circular boundaries with zoom controls
- Final Processing: Cropped circular logo is processed and displayed
- Management Options: Edit button reopens crop modal, delete button clears the logo
Brand Identity Focus: This implementation is specifically designed for brand logo management, with circular cropping that's ideal for:
- Company logos and brand marks
- Profile pictures and avatars
const IMAGE_PROCESSING_OPTIONS = parseImageProcessingOptions({
maxWidth: 1024,
maxHeight: 1024,
});
const [isOpen, setIsOpen] = React.useState(false);
const [imageUrl, setImageUrl] = React.useState(null);
const [uploadedBlobUrl, setUploadedBlobUrl] = React.useState(null);
const handleClose = React.useCallback(() => setIsOpen(false), []);
const handleSave = React.useCallback((newUrl) => {
setIsOpen(false);
setImageUrl(newUrl);
setUploadedBlobUrl(null);
}, []);
const handleEditImage = React.useCallback(() => setIsOpen(true), []);
const handleDelete = React.useCallback(() => setImageUrl(null), []);
const handleFileInputChange = React.useCallback(async (files) => {
const { blob, errorMessage } = await processLocalFile(
files[0],
undefined,
IMAGE_PROCESSING_OPTIONS
);
if (!blob) {
window.alert(errorMessage);
return;
}
setIsOpen(true);
setUploadedBlobUrl(URL.createObjectURL(blob));
}, []);
return (
<Box>
<Box className="flex gap-4">
<Box className="relative inline-flex h-20 w-20 min-w-20 items-center justify-center overflow-hidden rounded-full after:absolute after:inset-0 after:rounded-full after:border after:border-solid after:border-[rgba(0,0,0,0.05)]">
{imageUrl ? (
<Image
alt="Brand logo"
className="bg-neutral-secondary h-full w-full object-cover"
src={imageUrl}
/>
) : (
<Box className="bg-neutral-secondary flex h-full w-full items-center justify-center">
<SetmoreIcon />
</Box>
)}
</Box>
<Box className="flex flex-col">
<Text className="text-body-12 text-primary">Brand logo</Text>
<Text className="text-body-12 text-secondary">
Select a 200 x 200 px image, up to 10MB in size
</Text>
<Box className="mt-1 flex gap-1">
{imageUrl ? (
<>
<Button
iconStart={EditIcon}
onClick={handleEditImage}
variant="neutralSecondary"
>
Edit
</Button>
<Tooltip content="Delete" placement="bottom">
{({ triggerProps }) => (
<IconButton
aria-label="Delete brand logo"
variant="neutralTertiary"
{...triggerProps}
icon={DeleteIcon}
onClick={handleDelete}
/>
)}
</Tooltip>
</>
) : (
<UploadFileButton accept={IMAGE_PROCESSING_OPTIONS.allowedMimeType} onFilesChange={handleFileInputChange}>
{({ onClick }) => (
<Button iconStart={ImageIcon} onClick={onClick} variant="neutralSecondary">Upload logo</Button>
)}
</UploadFileButton>
)}
</Box>
</Box>
</Box>
<CropModal
cropAreaTranslations={{
uploadNewButtonLabel: 'Upload new',
zoomInButtonLabel: 'Zoom in',
zoomOutButtonLabel: 'Zoom out',
}}
cropViewBoxOptions={{ roundingStyle: 'circle' }}
imageProcessingOptions={IMAGE_PROCESSING_OPTIONS}
imageUrl={uploadedBlobUrl ?? imageUrl}
isOpen={isOpen}
modalWrapperTranslations={{
title: 'Brand logo',
subtitle: '200 x 200 px recommended Β· JPG, JPEG and PNG files only Β· Up to 10MB',
saveButtonLabel: 'Save',
cancelButtonLabel: 'Cancel',
closeModalButtonLabel: 'Close crop modal',
}}
onCancel={handleClose}
onClose={handleClose}
onSuccessfulUpload={handleSave}
/>
</Box>
);
Service Imageβ
This example demonstrates a comprehensive service image management system with rounded corner cropping and online upload capabilities:
Workflow:
- Initial State: Empty rounded placeholder with service icon and upload button
- File Selection: User selects service image through file input with validation
- Pre-processing: Image undergoes local processing (resize, compression, validation) before upload
- Rounded Crop Modal: Opens with pre-processed image and rounded corner constraints
- Image Cropping: User adjusts image within rounded boundaries with zoom controls
- Online Upload: Processed image is uploaded to server via configured API endpoint
- Final Processing: Server-processed image URL is returned and displayed
- Management Options: Edit button reopens crop modal, delete button clears the image
Service Image Focus: This implementation is specifically designed for service image management, with rounded corner cropping that's ideal for:
- Service catalog images
- Product thumbnails
const IMAGE_PROCESSING_OPTIONS = parseImageProcessingOptions({
maxWidth: 1024,
maxHeight: 1024
});
const API_OPTIONS = React.useMemo(() => ({
uploadUrl: 'https://example.com/upload-session',
getAccessToken: async () => 'mock-token',
}), []);
const [isOpen, setIsOpen] = React.useState(false);
const [imageUrl, setImageUrl] = React.useState(null);
const [uploadedBlobUrl, setUploadedBlobUrl] = React.useState(null);
const handleClose = React.useCallback(() => setIsOpen(false), []);
const handleSave = React.useCallback((newUrl) => {
setIsOpen(false);
setImageUrl(newUrl);
setUploadedBlobUrl(null);
}, []);
const handleEditImage = React.useCallback(() => setIsOpen(true), []);
const handleDelete = React.useCallback(() => setImageUrl(null), []);
const handleFileInputChange = React.useCallback(async (files) => {
const { blob, errorMessage } = await processLocalFile(
files[0],
undefined,
IMAGE_PROCESSING_OPTIONS
);
if (!blob) {
window.alert(errorMessage);
return;
}
setIsOpen(true);
setUploadedBlobUrl(URL.createObjectURL(blob));
}, []);
return (
<Box>
<Box className="flex items-center gap-4">
<Box className="relative inline-flex h-24 w-24 min-w-24 items-center justify-center overflow-hidden rounded-16px after:absolute after:inset-0 after:rounded-16px after:border after:border-solid after:border-[rgba(0,0,0,0.05)]">
{imageUrl ? (
<Image
src={imageUrl}
alt="Service image"
className="bg-neutral-secondary h-full w-full object-cover"
/>
) : (
<Box className="bg-neutral-secondary flex h-full w-full items-center justify-center">
<SetmoreIcon />
</Box>
)}
</Box>
<Box className="flex flex-col">
<Text className="text-body-12 text-primary">Service image</Text>
<Text className="text-body-12 text-secondary">Up to 5 MB in size</Text>
<Box className="mt-1 flex gap-1">
{imageUrl ? (
<>
<Button iconStart={EditIcon} onClick={handleEditImage} variant="neutralSecondary">
Edit
</Button>
<Tooltip content="Delete" placement="bottom">
{({ triggerProps }) => (
<IconButton aria-label="Delete service image" variant="neutralTertiary" {...triggerProps} icon={DeleteIcon} onClick={handleDelete} />
)}
</Tooltip>
</>
) : (
<UploadFileButton accept={IMAGE_PROCESSING_OPTIONS.allowedMimeType} onFilesChange={handleFileInputChange}>
{({ onClick }) => (
<Button iconStart={ImageIcon} onClick={onClick} variant="neutralSecondary">
Upload
</Button>
)}
</UploadFileButton>
)}
</Box>
</Box>
</Box>
<CropModal
apiOptions={API_OPTIONS}
cropAreaTranslations={{
uploadNewButtonLabel: 'Upload new',
zoomInButtonLabel: 'Zoom in',
zoomOutButtonLabel: 'Zoom out',
}}
cropViewBoxOptions={{ roundingStyle: 'rounded' }}
errorTranslations={{
androidFileReadError: 'Custom error message for Android file read error',
fetchIncomingImageError: 'Custom error message for fetch incoming image error',
fileSizeExceedsLimitError: 'Custom error message for file size exceeds limit error',
fileTypeMismatchError: 'Custom error message for file type mismatch error',
fileUploadError: 'Custom error message for file upload error',
localFileProcessingError: 'Custom error message for local file processing error',
}}
imageProcessingOptions={IMAGE_PROCESSING_OPTIONS}
imageUrl={uploadedBlobUrl ?? imageUrl}
isOpen={isOpen}
modalWrapperTranslations={{
title: 'Service image',
subtitle: '200 x 200 px recommended Β· JPG, JPEG and PNG files only Β· Up to 5MB',
saveButtonLabel: 'Apply',
cancelButtonLabel: 'Cancel',
closeModalButtonLabel: 'Close crop modal',
}}
onCancel={handleClose}
onClose={handleClose}
onSuccessfulUpload={handleSave}
/>
</Box>
);
Style APIβ
Our design system components include style props that allow you to easily customize different parts of each component to match your design needs.
Please refer to the Style API documentation for more insights.
CropModal partsβ
const [isOpen, setIsOpen] = React.useState(false);
const [imageUrl, setImageUrl] = React.useState(null);
const handleClose = React.useCallback(() => setIsOpen(false), []);
const handleSave = React.useCallback((newUrl) => {
setIsOpen(false);
setImageUrl(newUrl);
}, []);
return (
<Box>
<Avatar name="Default" size="96">
{imageUrl ? <AvatarImage src={imageUrl} /> : null}
</Avatar>
<CropModal
cropViewBoxOptions={{}}
dropAreaTranslations={{
uploadImageButtonLabel: 'Upload',
}}
imageUrl={imageUrl}
isOpen={isOpen}
modalWrapperTranslations={{
title: 'Default story',
subtitle: 'That`s what you get with the default/minimal configuration',
saveButtonLabel: 'Save',
cancelButtonLabel: 'Cancel',
closeModalButtonLabel: 'Close crop modal',
}}
onCancel={handleClose}
onClose={handleClose}
onSuccessfulUpload={handleSave}
className="border-4 border-secondary"
classNames={{
modalWrapper: 'z-[1202]',
footer: 'bg-[#F7F9FA] p-5 rounded-b-12px',
header: 'bg-[#FF0000] p-5 rounded-t-12px',
saveButton: 'p-5 rounded-12px',
subtitle: 'text-body-20',
title: 'text-body-30 text-positive',
}}
/>
<Box className="flex mt-2 justify-center items-center gap-2">
<Button onClick={() => setIsOpen(true)}>Open Crop Modal</Button>
<Button isDisabled={!imageUrl} onClick={() => setImageUrl(null)}>
Clear image
</Button>
</Box>
</Box>
);
| Stylable Parts | Description |
|---|---|
| root | The container that wraps the entire CropModal component. |
| modalWrapper | The overlay and container of the modal; controls z-order and layout. |
| modalHeader | The header area including the title and close button. |
| title | The title text displayed in the modal header. |
| modalContent | The body area containing the drop area, loader, or crop area. |
| modalFooter | The footer container with Save and Cancel buttons. |
| saveButton | The primary action button used to save changes. |
| cancelButton | The secondary action button used to cancel and close. |
Usage guidelinesβ
Do
- Initialize
CropModalwith properimageProcessingOptionsconstraints:maxWidth&maxHeight: Use2560for banners,1024for logos/service images (default:2560)maxFileSizeMB: Set appropriate file size limits (default:5MB)allowedMimeTypes: Restrict to expected formats (default:['image/png', 'image/jpeg', 'image/jpg'])enableResize&enableCompression: Control processing behavior (both default totrue)
- Implement comprehensive error handling with the
onErrorcallback:const handleError = (errorDetails: ErrorDetails) => { console.error(`Error in ${errorDetails.location}:`, errorDetails.message); // Log to monitoring service analytics.track('crop_modal_error', { location: errorDetails.location, message: errorDetails.message, stack: errorDetails.stack }); // Show user-friendly notification showNotification(errorDetails.message); }; - Configure custom error messages with
errorTranslations:errorTranslations={{ fileSizeExceedsLimitError: 'File too large. Please use an image under 5MB.', fileTypeMismatchError: 'Only JPG, JPEG, and PNG files are supported.', fileUploadError: 'Upload failed. Please check your connection and try again.', fetchIncomingImageError: 'Could not load the image. Please try again.', localFileProcessingError: 'Image processing failed. Please try a different file.', androidFileReadError: 'Could not read the file. Please try again.' }} - Support both offline (local processing) and online (API upload) processing modes:
- Offline mode: Omit
apiOptionsfor local-only processing with blob output - Online mode: Provide
apiOptionsfor direct cloud upload integration
- Offline mode: Omit
- Persist crop box configurations (
cropViewBoxOptions) for consistent user experience:- Save
width,aspectRatio, androundingStylein user preferences - Restore previous crop settings when reopening the modal
- Save
Donβt
- Don't ignore the
onErrorcallback β always implement error handling to track issues and provide user feedback. - Don't block the UI during image processing β the component handles loading states, but ensure your app remains responsive.
- Don't set
compressionQualitybelow0.5for photos β image artifacts become noticeable and degrade user experience. - Don't use oversized dimensions for small images:
- Avoid
maxWidth: 2560for logos/service images β use1024instead
- Avoid
- Don't ignore error locations β use
errorDetails.locationto implement specific error handling strategies:REMOTE_IMAGE_FETCHING: Network connectivity issuesPROCESS_LOCAL_FILE: File reading or format problemsRESIZE_IMAGE: Memory or processing constraintsVALIDATE_IMAGE_TYPE: Unsupported file formatsSAVE_CHANGES: Upload or API failures
Best practicesβ
Do
- Prefer
compressionQualityaround0.6for photos. - For banner images: Use default
maxWidth/maxHeight(2560) for high-quality large displays. - For logos and service images: Set
maxWidth: 1024, maxHeight: 1024since smaller images are sufficient. - Restrict
allowedMimeTypesto expected formats (defaults:['image/png', 'image/jpeg', 'image/jpg']).
Donβt
- Don't set
compressionQualitybelow0.5for photos; visible artifacts increase quickly. - Don't upsample small images by using very large
maxWidth/maxHeightβ it degrades quality. - Don't use banner-sized dimensions (
2560) for logos/service images β it wastes bandwidth and storage. - Don't accept unsupported file types; validate with
allowedMimeTypesbefore processing. - Don't rely solely on server-side compression; keep client-side behavior consistent across flows.