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 range
  • compressImage - Compresses image blobs using Compressor.js
  • createImageElement - Creates HTMLImageElement from string sources
  • createUrl - Safely constructs URL instances with error handling
  • genericSendFormDataRequest - Sends multipart/form-data POST requests
  • isFileSizeValid - Validates file sizes against limits
  • parseImageProcessingOptions - Merges image processing options with defaults
  • processLocalFile - Complete local file processing pipeline with validation, resize, and compression
  • readFileImage - Converts blobs to data URLs or ArrayBuffers
  • resizeImage - Resizes images while maintaining aspect ratio
  • roundNumberToDecimal - Rounds numbers to specified decimal places
  • validateImageType - 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:

  1. User clicks "Open Crop Modal" to launch the cropping interface
  2. User uploads and crops an image within the modal
  3. On save, the modal closes and the cropped image appears in the avatar
  4. 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>
);

This example demonstrates a complete end-to-end banner image workflow with advanced configuration for a 3:1 aspect ratio banner:

Advanced Workflow:

  1. Initial State: Empty banner area displays placeholder icon with upload button
  2. File Selection: User selects image through file input with MIME type validation
  3. Pre-processing: Image undergoes local processing (resize, compression, validation) before cropping
  4. Crop Modal: Opens with pre-processed image and banner-specific constraints
  5. Final Processing: User crops image within 3:1 aspect ratio constraints
  6. Result Display: Processed banner image displays in the container
  7. 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>
);

This example demonstrates a comprehensive brand logo management system circular cropping functionality:

Workflow:

  1. Initial State: Empty circular placeholder with brand icon and upload button
  2. File Selection: User selects logo image through file input with validation
  3. Pre-processing: Image undergoes local processing (resize, compression, validation) before cropping
  4. Circular Crop Modal: Opens with pre-processed image and circular crop constraints
  5. Logo Cropping: User adjusts image within circular boundaries with zoom controls
  6. Final Processing: Cropped circular logo is processed and displayed
  7. 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
Brand logoSelect a 200 x 200 px image, up to 10MB in size
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:

  1. Initial State: Empty rounded placeholder with service icon and upload button
  2. File Selection: User selects service image through file input with validation
  3. Pre-processing: Image undergoes local processing (resize, compression, validation) before upload
  4. Rounded Crop Modal: Opens with pre-processed image and rounded corner constraints
  5. Image Cropping: User adjusts image within rounded boundaries with zoom controls
  6. Online Upload: Processed image is uploaded to server via configured API endpoint
  7. Final Processing: Server-processed image URL is returned and displayed
  8. 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
Service imageUp to 5 MB in size
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 PartsDescription
rootThe container that wraps the entire CropModal component.
modalWrapperThe overlay and container of the modal; controls z-order and layout.
modalHeaderThe header area including the title and close button.
titleThe title text displayed in the modal header.
modalContentThe body area containing the drop area, loader, or crop area.
modalFooterThe footer container with Save and Cancel buttons.
saveButtonThe primary action button used to save changes.
cancelButtonThe secondary action button used to cancel and close.

Usage guidelines​

Do

  1. Initialize CropModal with proper imageProcessingOptions constraints:
    • maxWidth & maxHeight: Use 2560 for banners, 1024 for 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 to true)
  2. Implement comprehensive error handling with the onError callback:
    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);
    };
    
  3. 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.'
    }}
    
  4. Support both offline (local processing) and online (API upload) processing modes:
    • Offline mode: Omit apiOptions for local-only processing with blob output
    • Online mode: Provide apiOptions for direct cloud upload integration
  5. Persist crop box configurations (cropViewBoxOptions) for consistent user experience:
    • Save width, aspectRatio, and roundingStyle in user preferences
    • Restore previous crop settings when reopening the modal

Don’t

  1. Don't ignore the onError callback β€” always implement error handling to track issues and provide user feedback.
  2. Don't block the UI during image processing β€” the component handles loading states, but ensure your app remains responsive.
  3. Don't set compressionQuality below 0.5 for photos β€” image artifacts become noticeable and degrade user experience.
  4. Don't use oversized dimensions for small images:
    • Avoid maxWidth: 2560 for logos/service images β€” use 1024 instead
  5. Don't ignore error locations β€” use errorDetails.location to implement specific error handling strategies:
    • REMOTE_IMAGE_FETCHING: Network connectivity issues
    • PROCESS_LOCAL_FILE: File reading or format problems
    • RESIZE_IMAGE: Memory or processing constraints
    • VALIDATE_IMAGE_TYPE: Unsupported file formats
    • SAVE_CHANGES: Upload or API failures

Best practices​

Do

  1. Prefer compressionQuality around 0.6 for photos.
  2. For banner images: Use default maxWidth/maxHeight (2560) for high-quality large displays.
  3. For logos and service images: Set maxWidth: 1024, maxHeight: 1024 since smaller images are sufficient.
  4. Restrict allowedMimeTypes to expected formats (defaults: ['image/png', 'image/jpeg', 'image/jpg']).

Don’t

  1. Don't set compressionQuality below 0.5 for photos; visible artifacts increase quickly.
  2. Don't upsample small images by using very large maxWidth/maxHeight β€” it degrades quality.
  3. Don't use banner-sized dimensions (2560) for logos/service images β€” it wastes bandwidth and storage.
  4. Don't accept unsupported file types; validate with allowedMimeTypes before processing.
  5. Don't rely solely on server-side compression; keep client-side behavior consistent across flows.