Image Crop
Interactive image cropping component for React and Next.js applications. Built with TypeScript support, Tailwind CSS styling, and shadcn/ui design featuring drag-resize controls, aspect ratios, and circular cropping.
Powered by
'use client';import { ImageCrop, ImageCropApply, ImageCropContent, ImageCropReset,} from '@/components/ui/shadcn-io/image-crop';import { Button } from '@/components/ui/button';import { Input } from '@/components/ui/input';import { XIcon } from 'lucide-react';import Image from 'next/image';import { type ChangeEvent, useState } from 'react';const Example = () => { const [selectedFile, setSelectedFile] = useState<File | null>(null); const [croppedImage, setCroppedImage] = useState<string | null>(null); const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => { const file = event.target.files?.[0]; if (file) { setSelectedFile(file); setCroppedImage(null); } }; const handleReset = () => { setSelectedFile(null); setCroppedImage(null); }; if (!selectedFile) { return ( <Input accept="image/*" className="w-fit" onChange={handleFileChange} type="file" /> ); } if (croppedImage) { return ( <div className="space-y-4"> <Image alt="Cropped" height={100} src={croppedImage} unoptimized width={100} /> <Button onClick={handleReset} size="icon" type="button" variant="ghost"> <XIcon className="size-4" /> </Button> </div> ); } return ( <div className="space-y-4"> <ImageCrop aspect={1} file={selectedFile} maxImageSize={1024 * 1024} // 1MB onChange={console.log} onComplete={console.log} onCrop={setCroppedImage} > <ImageCropContent className="max-w-md" /> <div className="flex items-center gap-2"> <ImageCropApply /> <ImageCropReset /> <Button onClick={handleReset} size="icon" type="button" variant="ghost" > <XIcon className="size-4" /> </Button> </div> </ImageCrop> </div> );};export default Example;
'use client';import { Button } from '@/components/ui/button';import { CropIcon, RotateCcwIcon } from 'lucide-react';import { Slot } from 'radix-ui';import { type ComponentProps, type CSSProperties, createContext, type MouseEvent, type ReactNode, type RefObject, type SyntheticEvent, useCallback, useContext, useEffect, useRef, useState,} from 'react';import ReactCrop, { centerCrop, makeAspectCrop, type PercentCrop, type PixelCrop, type ReactCropProps,} from 'react-image-crop';import { cn } from '@/lib/utils';import 'react-image-crop/dist/ReactCrop.css';const centerAspectCrop = ( mediaWidth: number, mediaHeight: number, aspect: number | undefined): PercentCrop => centerCrop( aspect ? makeAspectCrop( { unit: '%', width: 90, }, aspect, mediaWidth, mediaHeight ) : { x: 0, y: 0, width: 90, height: 90, unit: '%' }, mediaWidth, mediaHeight );const getCroppedPngImage = async ( imageSrc: HTMLImageElement, scaleFactor: number, pixelCrop: PixelCrop, maxImageSize: number): Promise<string> => { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('Context is null, this should never happen.'); } const scaleX = imageSrc.naturalWidth / imageSrc.width; const scaleY = imageSrc.naturalHeight / imageSrc.height; ctx.imageSmoothingEnabled = false; canvas.width = pixelCrop.width; canvas.height = pixelCrop.height; ctx.drawImage( imageSrc, pixelCrop.x * scaleX, pixelCrop.y * scaleY, pixelCrop.width * scaleX, pixelCrop.height * scaleY, 0, 0, canvas.width, canvas.height ); const croppedImageUrl = canvas.toDataURL('image/png'); const response = await fetch(croppedImageUrl); const blob = await response.blob(); if (blob.size > maxImageSize) { return await getCroppedPngImage( imageSrc, scaleFactor * 0.9, pixelCrop, maxImageSize ); } return croppedImageUrl;};type ImageCropContextType = { file: File; maxImageSize: number; imgSrc: string; crop: PercentCrop | undefined; completedCrop: PixelCrop | null; imgRef: RefObject<HTMLImageElement | null>; onCrop?: (croppedImage: string) => void; reactCropProps: Omit<ReactCropProps, 'onChange' | 'onComplete' | 'children'>; handleChange: (pixelCrop: PixelCrop, percentCrop: PercentCrop) => void; handleComplete: ( pixelCrop: PixelCrop, percentCrop: PercentCrop ) => Promise<void>; onImageLoad: (e: SyntheticEvent<HTMLImageElement>) => void; applyCrop: () => Promise<void>; resetCrop: () => void;};const ImageCropContext = createContext<ImageCropContextType | null>(null);const useImageCrop = () => { const context = useContext(ImageCropContext); if (!context) { throw new Error('ImageCrop components must be used within ImageCrop'); } return context;};export type ImageCropProps = { file: File; maxImageSize?: number; onCrop?: (croppedImage: string) => void; children: ReactNode; onChange?: ReactCropProps['onChange']; onComplete?: ReactCropProps['onComplete'];} & Omit<ReactCropProps, 'onChange' | 'onComplete' | 'children'>;export const ImageCrop = ({ file, maxImageSize = 1024 * 1024 * 5, onCrop, children, onChange, onComplete, ...reactCropProps}: ImageCropProps) => { const imgRef = useRef<HTMLImageElement | null>(null); const [imgSrc, setImgSrc] = useState<string>(''); const [crop, setCrop] = useState<PercentCrop>(); const [completedCrop, setCompletedCrop] = useState<PixelCrop | null>(null); const [initialCrop, setInitialCrop] = useState<PercentCrop>(); useEffect(() => { const reader = new FileReader(); reader.addEventListener('load', () => setImgSrc(reader.result?.toString() || '') ); reader.readAsDataURL(file); }, [file]); const onImageLoad = useCallback( (e: SyntheticEvent<HTMLImageElement>) => { const { width, height } = e.currentTarget; const newCrop = centerAspectCrop(width, height, reactCropProps.aspect); setCrop(newCrop); setInitialCrop(newCrop); }, [reactCropProps.aspect] ); const handleChange = (pixelCrop: PixelCrop, percentCrop: PercentCrop) => { setCrop(percentCrop); onChange?.(pixelCrop, percentCrop); }; // biome-ignore lint/suspicious/useAwait: "onComplete is async" const handleComplete = async ( pixelCrop: PixelCrop, percentCrop: PercentCrop ) => { setCompletedCrop(pixelCrop); onComplete?.(pixelCrop, percentCrop); }; const applyCrop = async () => { if (!(imgRef.current && completedCrop)) { return; } const croppedImage = await getCroppedPngImage( imgRef.current, 1, completedCrop, maxImageSize ); onCrop?.(croppedImage); }; const resetCrop = () => { if (initialCrop) { setCrop(initialCrop); setCompletedCrop(null); } }; const contextValue: ImageCropContextType = { file, maxImageSize, imgSrc, crop, completedCrop, imgRef, onCrop, reactCropProps, handleChange, handleComplete, onImageLoad, applyCrop, resetCrop, }; return ( <ImageCropContext.Provider value={contextValue}> {children} </ImageCropContext.Provider> );};export type ImageCropContentProps = { style?: CSSProperties; className?: string;};export const ImageCropContent = ({ style, className,}: ImageCropContentProps) => { const { imgSrc, crop, handleChange, handleComplete, onImageLoad, imgRef, reactCropProps, } = useImageCrop(); const shadcnStyle = { '--rc-border-color': 'var(--color-border)', '--rc-focus-color': 'var(--color-primary)', } as CSSProperties; return ( <ReactCrop className={cn('max-h-[277px] max-w-full', className)} crop={crop} onChange={handleChange} onComplete={handleComplete} style={{ ...shadcnStyle, ...style }} {...reactCropProps} > {imgSrc && ( <img alt="crop" className="size-full" onLoad={onImageLoad} ref={imgRef} src={imgSrc} /> )} </ReactCrop> );};export type ImageCropApplyProps = ComponentProps<'button'> & { asChild?: boolean;};export const ImageCropApply = ({ asChild = false, children, onClick, ...props}: ImageCropApplyProps) => { const { applyCrop } = useImageCrop(); const handleClick = async (e: MouseEvent<HTMLButtonElement>) => { await applyCrop(); onClick?.(e); }; if (asChild) { return ( <Slot.Root onClick={handleClick} {...props}> {children} </Slot.Root> ); } return ( <Button onClick={handleClick} size="icon" variant="ghost" {...props}> {children ?? <CropIcon className="size-4" />} </Button> );};export type ImageCropResetProps = ComponentProps<'button'> & { asChild?: boolean;};export const ImageCropReset = ({ asChild = false, children, onClick, ...props}: ImageCropResetProps) => { const { resetCrop } = useImageCrop(); const handleClick = (e: MouseEvent<HTMLButtonElement>) => { resetCrop(); onClick?.(e); }; if (asChild) { return ( <Slot.Root onClick={handleClick} {...props}> {children} </Slot.Root> ); } return ( <Button onClick={handleClick} size="icon" variant="ghost" {...props}> {children ?? <RotateCcwIcon className="size-4" />} </Button> );};// Keep the original Cropper component for backward compatibilityexport type CropperProps = Omit<ReactCropProps, 'onChange'> & { file: File; maxImageSize?: number; onCrop?: (croppedImage: string) => void; onChange?: ReactCropProps['onChange'];};export const Cropper = ({ onChange, onComplete, onCrop, style, className, file, maxImageSize, ...props}: CropperProps) => ( <ImageCrop file={file} maxImageSize={maxImageSize} onChange={onChange} onComplete={onComplete} onCrop={onCrop} {...props} > <ImageCropContent className={className} style={style} /> </ImageCrop>);
Installation
npx shadcn@latest add https://www.shadcn.io/registry/image-crop.json
npx shadcn@latest add https://www.shadcn.io/registry/image-crop.json
pnpm dlx shadcn@latest add https://www.shadcn.io/registry/image-crop.json
bunx shadcn@latest add https://www.shadcn.io/registry/image-crop.json
Features
- Interactive cropping - Drag-and-resize controls with touch support using react-image-crop for React apps
- Aspect ratios - Fixed ratios or free-form cropping with custom dimensions using TypeScript validation
- Circular crops - Perfect for avatars and profile pictures with rounded output using JavaScript processing
- Image compression - Automatic scaling and size optimization with configurable quality settings using Tailwind CSS
- Data URL output - PNG format for immediate upload or display in Next.js applications
- Responsive design - Adapts to container size with mobile-friendly touch controls using shadcn/ui styling
- Open source - Free image cropping component with transparency preview support
Examples
Custom buttons
'use client';import { ImageCrop, ImageCropApply, ImageCropContent, ImageCropReset,} from '@/components/ui/shadcn-io/image-crop';import { Button } from '@/components/ui/button';import { Input } from '@/components/ui/input';import Image from 'next/image';import { type ChangeEvent, useState } from 'react';const Example = () => { const [selectedFile, setSelectedFile] = useState<File | null>(null); const [croppedImage, setCroppedImage] = useState<string | null>(null); const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => { const file = event.target.files?.[0]; if (file) { setSelectedFile(file); setCroppedImage(null); } }; const handleReset = () => { setSelectedFile(null); setCroppedImage(null); }; if (!selectedFile) { return ( <Input accept="image/*" className="w-fit" onChange={handleFileChange} type="file" /> ); } if (croppedImage) { return ( <div className="space-y-4"> <Image alt="Cropped" height={100} src={croppedImage} unoptimized width={100} /> <Button onClick={handleReset} size="sm" type="button" variant="outline"> Start Over </Button> </div> ); } return ( <div className="space-y-4"> <ImageCrop aspect={1} file={selectedFile} maxImageSize={1024 * 1024} // 1MB onChange={console.log} onComplete={console.log} onCrop={setCroppedImage} > <ImageCropContent className="max-w-md" /> <div className="flex items-center gap-2"> <ImageCropApply asChild> <Button size="sm" variant="outline"> Apply Crop </Button> </ImageCropApply> <ImageCropReset asChild> <Button size="sm" variant="outline"> Reset </Button> </ImageCropReset> <Button onClick={handleReset} size="sm" type="button" variant="outline" > Start Over </Button> </div> </ImageCrop> </div> );};export default Example;
'use client';import { Button } from '@/components/ui/button';import { CropIcon, RotateCcwIcon } from 'lucide-react';import { Slot } from 'radix-ui';import { type ComponentProps, type CSSProperties, createContext, type MouseEvent, type ReactNode, type RefObject, type SyntheticEvent, useCallback, useContext, useEffect, useRef, useState,} from 'react';import ReactCrop, { centerCrop, makeAspectCrop, type PercentCrop, type PixelCrop, type ReactCropProps,} from 'react-image-crop';import { cn } from '@/lib/utils';import 'react-image-crop/dist/ReactCrop.css';const centerAspectCrop = ( mediaWidth: number, mediaHeight: number, aspect: number | undefined): PercentCrop => centerCrop( aspect ? makeAspectCrop( { unit: '%', width: 90, }, aspect, mediaWidth, mediaHeight ) : { x: 0, y: 0, width: 90, height: 90, unit: '%' }, mediaWidth, mediaHeight );const getCroppedPngImage = async ( imageSrc: HTMLImageElement, scaleFactor: number, pixelCrop: PixelCrop, maxImageSize: number): Promise<string> => { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('Context is null, this should never happen.'); } const scaleX = imageSrc.naturalWidth / imageSrc.width; const scaleY = imageSrc.naturalHeight / imageSrc.height; ctx.imageSmoothingEnabled = false; canvas.width = pixelCrop.width; canvas.height = pixelCrop.height; ctx.drawImage( imageSrc, pixelCrop.x * scaleX, pixelCrop.y * scaleY, pixelCrop.width * scaleX, pixelCrop.height * scaleY, 0, 0, canvas.width, canvas.height ); const croppedImageUrl = canvas.toDataURL('image/png'); const response = await fetch(croppedImageUrl); const blob = await response.blob(); if (blob.size > maxImageSize) { return await getCroppedPngImage( imageSrc, scaleFactor * 0.9, pixelCrop, maxImageSize ); } return croppedImageUrl;};type ImageCropContextType = { file: File; maxImageSize: number; imgSrc: string; crop: PercentCrop | undefined; completedCrop: PixelCrop | null; imgRef: RefObject<HTMLImageElement | null>; onCrop?: (croppedImage: string) => void; reactCropProps: Omit<ReactCropProps, 'onChange' | 'onComplete' | 'children'>; handleChange: (pixelCrop: PixelCrop, percentCrop: PercentCrop) => void; handleComplete: ( pixelCrop: PixelCrop, percentCrop: PercentCrop ) => Promise<void>; onImageLoad: (e: SyntheticEvent<HTMLImageElement>) => void; applyCrop: () => Promise<void>; resetCrop: () => void;};const ImageCropContext = createContext<ImageCropContextType | null>(null);const useImageCrop = () => { const context = useContext(ImageCropContext); if (!context) { throw new Error('ImageCrop components must be used within ImageCrop'); } return context;};export type ImageCropProps = { file: File; maxImageSize?: number; onCrop?: (croppedImage: string) => void; children: ReactNode; onChange?: ReactCropProps['onChange']; onComplete?: ReactCropProps['onComplete'];} & Omit<ReactCropProps, 'onChange' | 'onComplete' | 'children'>;export const ImageCrop = ({ file, maxImageSize = 1024 * 1024 * 5, onCrop, children, onChange, onComplete, ...reactCropProps}: ImageCropProps) => { const imgRef = useRef<HTMLImageElement | null>(null); const [imgSrc, setImgSrc] = useState<string>(''); const [crop, setCrop] = useState<PercentCrop>(); const [completedCrop, setCompletedCrop] = useState<PixelCrop | null>(null); const [initialCrop, setInitialCrop] = useState<PercentCrop>(); useEffect(() => { const reader = new FileReader(); reader.addEventListener('load', () => setImgSrc(reader.result?.toString() || '') ); reader.readAsDataURL(file); }, [file]); const onImageLoad = useCallback( (e: SyntheticEvent<HTMLImageElement>) => { const { width, height } = e.currentTarget; const newCrop = centerAspectCrop(width, height, reactCropProps.aspect); setCrop(newCrop); setInitialCrop(newCrop); }, [reactCropProps.aspect] ); const handleChange = (pixelCrop: PixelCrop, percentCrop: PercentCrop) => { setCrop(percentCrop); onChange?.(pixelCrop, percentCrop); }; // biome-ignore lint/suspicious/useAwait: "onComplete is async" const handleComplete = async ( pixelCrop: PixelCrop, percentCrop: PercentCrop ) => { setCompletedCrop(pixelCrop); onComplete?.(pixelCrop, percentCrop); }; const applyCrop = async () => { if (!(imgRef.current && completedCrop)) { return; } const croppedImage = await getCroppedPngImage( imgRef.current, 1, completedCrop, maxImageSize ); onCrop?.(croppedImage); }; const resetCrop = () => { if (initialCrop) { setCrop(initialCrop); setCompletedCrop(null); } }; const contextValue: ImageCropContextType = { file, maxImageSize, imgSrc, crop, completedCrop, imgRef, onCrop, reactCropProps, handleChange, handleComplete, onImageLoad, applyCrop, resetCrop, }; return ( <ImageCropContext.Provider value={contextValue}> {children} </ImageCropContext.Provider> );};export type ImageCropContentProps = { style?: CSSProperties; className?: string;};export const ImageCropContent = ({ style, className,}: ImageCropContentProps) => { const { imgSrc, crop, handleChange, handleComplete, onImageLoad, imgRef, reactCropProps, } = useImageCrop(); const shadcnStyle = { '--rc-border-color': 'var(--color-border)', '--rc-focus-color': 'var(--color-primary)', } as CSSProperties; return ( <ReactCrop className={cn('max-h-[277px] max-w-full', className)} crop={crop} onChange={handleChange} onComplete={handleComplete} style={{ ...shadcnStyle, ...style }} {...reactCropProps} > {imgSrc && ( <img alt="crop" className="size-full" onLoad={onImageLoad} ref={imgRef} src={imgSrc} /> )} </ReactCrop> );};export type ImageCropApplyProps = ComponentProps<'button'> & { asChild?: boolean;};export const ImageCropApply = ({ asChild = false, children, onClick, ...props}: ImageCropApplyProps) => { const { applyCrop } = useImageCrop(); const handleClick = async (e: MouseEvent<HTMLButtonElement>) => { await applyCrop(); onClick?.(e); }; if (asChild) { return ( <Slot.Root onClick={handleClick} {...props}> {children} </Slot.Root> ); } return ( <Button onClick={handleClick} size="icon" variant="ghost" {...props}> {children ?? <CropIcon className="size-4" />} </Button> );};export type ImageCropResetProps = ComponentProps<'button'> & { asChild?: boolean;};export const ImageCropReset = ({ asChild = false, children, onClick, ...props}: ImageCropResetProps) => { const { resetCrop } = useImageCrop(); const handleClick = (e: MouseEvent<HTMLButtonElement>) => { resetCrop(); onClick?.(e); }; if (asChild) { return ( <Slot.Root onClick={handleClick} {...props}> {children} </Slot.Root> ); } return ( <Button onClick={handleClick} size="icon" variant="ghost" {...props}> {children ?? <RotateCcwIcon className="size-4" />} </Button> );};// Keep the original Cropper component for backward compatibilityexport type CropperProps = Omit<ReactCropProps, 'onChange'> & { file: File; maxImageSize?: number; onCrop?: (croppedImage: string) => void; onChange?: ReactCropProps['onChange'];};export const Cropper = ({ onChange, onComplete, onCrop, style, className, file, maxImageSize, ...props}: CropperProps) => ( <ImageCrop file={file} maxImageSize={maxImageSize} onChange={onChange} onComplete={onComplete} onCrop={onCrop} {...props} > <ImageCropContent className={className} style={style} /> </ImageCrop>);
Circular crop
'use client';import { ImageCrop, ImageCropApply, ImageCropContent, ImageCropReset,} from '@/components/ui/shadcn-io/image-crop';import { Button } from '@/components/ui/button';import { Input } from '@/components/ui/input';import { XIcon } from 'lucide-react';import Image from 'next/image';import { type ChangeEvent, useState } from 'react';const Example = () => { const [selectedFile, setSelectedFile] = useState<File | null>(null); const [croppedImage, setCroppedImage] = useState<string | null>(null); const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => { const file = event.target.files?.[0]; if (file) { setSelectedFile(file); setCroppedImage(null); } }; const handleReset = () => { setSelectedFile(null); setCroppedImage(null); }; if (!selectedFile) { return ( <Input accept="image/*" className="w-fit" onChange={handleFileChange} type="file" /> ); } if (croppedImage) { return ( <div className="space-y-4"> <Image alt="Cropped" className="overflow-hidden rounded-full" height={100} src={croppedImage} unoptimized width={100} /> <Button onClick={handleReset} size="icon" type="button" variant="ghost"> <XIcon className="size-4" /> </Button> </div> ); } return ( <div className="space-y-4"> <ImageCrop aspect={1} circularCrop file={selectedFile} maxImageSize={1024 * 1024} // 1MB onChange={console.log} onComplete={console.log} onCrop={setCroppedImage} > <ImageCropContent className="max-w-md" /> <div className="flex items-center gap-2"> <ImageCropApply /> <ImageCropReset /> <Button onClick={handleReset} size="icon" type="button" variant="ghost" > <XIcon className="size-4" /> </Button> </div> </ImageCrop> </div> );};export default Example;
'use client';import { Button } from '@/components/ui/button';import { CropIcon, RotateCcwIcon } from 'lucide-react';import { Slot } from 'radix-ui';import { type ComponentProps, type CSSProperties, createContext, type MouseEvent, type ReactNode, type RefObject, type SyntheticEvent, useCallback, useContext, useEffect, useRef, useState,} from 'react';import ReactCrop, { centerCrop, makeAspectCrop, type PercentCrop, type PixelCrop, type ReactCropProps,} from 'react-image-crop';import { cn } from '@/lib/utils';import 'react-image-crop/dist/ReactCrop.css';const centerAspectCrop = ( mediaWidth: number, mediaHeight: number, aspect: number | undefined): PercentCrop => centerCrop( aspect ? makeAspectCrop( { unit: '%', width: 90, }, aspect, mediaWidth, mediaHeight ) : { x: 0, y: 0, width: 90, height: 90, unit: '%' }, mediaWidth, mediaHeight );const getCroppedPngImage = async ( imageSrc: HTMLImageElement, scaleFactor: number, pixelCrop: PixelCrop, maxImageSize: number): Promise<string> => { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('Context is null, this should never happen.'); } const scaleX = imageSrc.naturalWidth / imageSrc.width; const scaleY = imageSrc.naturalHeight / imageSrc.height; ctx.imageSmoothingEnabled = false; canvas.width = pixelCrop.width; canvas.height = pixelCrop.height; ctx.drawImage( imageSrc, pixelCrop.x * scaleX, pixelCrop.y * scaleY, pixelCrop.width * scaleX, pixelCrop.height * scaleY, 0, 0, canvas.width, canvas.height ); const croppedImageUrl = canvas.toDataURL('image/png'); const response = await fetch(croppedImageUrl); const blob = await response.blob(); if (blob.size > maxImageSize) { return await getCroppedPngImage( imageSrc, scaleFactor * 0.9, pixelCrop, maxImageSize ); } return croppedImageUrl;};type ImageCropContextType = { file: File; maxImageSize: number; imgSrc: string; crop: PercentCrop | undefined; completedCrop: PixelCrop | null; imgRef: RefObject<HTMLImageElement | null>; onCrop?: (croppedImage: string) => void; reactCropProps: Omit<ReactCropProps, 'onChange' | 'onComplete' | 'children'>; handleChange: (pixelCrop: PixelCrop, percentCrop: PercentCrop) => void; handleComplete: ( pixelCrop: PixelCrop, percentCrop: PercentCrop ) => Promise<void>; onImageLoad: (e: SyntheticEvent<HTMLImageElement>) => void; applyCrop: () => Promise<void>; resetCrop: () => void;};const ImageCropContext = createContext<ImageCropContextType | null>(null);const useImageCrop = () => { const context = useContext(ImageCropContext); if (!context) { throw new Error('ImageCrop components must be used within ImageCrop'); } return context;};export type ImageCropProps = { file: File; maxImageSize?: number; onCrop?: (croppedImage: string) => void; children: ReactNode; onChange?: ReactCropProps['onChange']; onComplete?: ReactCropProps['onComplete'];} & Omit<ReactCropProps, 'onChange' | 'onComplete' | 'children'>;export const ImageCrop = ({ file, maxImageSize = 1024 * 1024 * 5, onCrop, children, onChange, onComplete, ...reactCropProps}: ImageCropProps) => { const imgRef = useRef<HTMLImageElement | null>(null); const [imgSrc, setImgSrc] = useState<string>(''); const [crop, setCrop] = useState<PercentCrop>(); const [completedCrop, setCompletedCrop] = useState<PixelCrop | null>(null); const [initialCrop, setInitialCrop] = useState<PercentCrop>(); useEffect(() => { const reader = new FileReader(); reader.addEventListener('load', () => setImgSrc(reader.result?.toString() || '') ); reader.readAsDataURL(file); }, [file]); const onImageLoad = useCallback( (e: SyntheticEvent<HTMLImageElement>) => { const { width, height } = e.currentTarget; const newCrop = centerAspectCrop(width, height, reactCropProps.aspect); setCrop(newCrop); setInitialCrop(newCrop); }, [reactCropProps.aspect] ); const handleChange = (pixelCrop: PixelCrop, percentCrop: PercentCrop) => { setCrop(percentCrop); onChange?.(pixelCrop, percentCrop); }; // biome-ignore lint/suspicious/useAwait: "onComplete is async" const handleComplete = async ( pixelCrop: PixelCrop, percentCrop: PercentCrop ) => { setCompletedCrop(pixelCrop); onComplete?.(pixelCrop, percentCrop); }; const applyCrop = async () => { if (!(imgRef.current && completedCrop)) { return; } const croppedImage = await getCroppedPngImage( imgRef.current, 1, completedCrop, maxImageSize ); onCrop?.(croppedImage); }; const resetCrop = () => { if (initialCrop) { setCrop(initialCrop); setCompletedCrop(null); } }; const contextValue: ImageCropContextType = { file, maxImageSize, imgSrc, crop, completedCrop, imgRef, onCrop, reactCropProps, handleChange, handleComplete, onImageLoad, applyCrop, resetCrop, }; return ( <ImageCropContext.Provider value={contextValue}> {children} </ImageCropContext.Provider> );};export type ImageCropContentProps = { style?: CSSProperties; className?: string;};export const ImageCropContent = ({ style, className,}: ImageCropContentProps) => { const { imgSrc, crop, handleChange, handleComplete, onImageLoad, imgRef, reactCropProps, } = useImageCrop(); const shadcnStyle = { '--rc-border-color': 'var(--color-border)', '--rc-focus-color': 'var(--color-primary)', } as CSSProperties; return ( <ReactCrop className={cn('max-h-[277px] max-w-full', className)} crop={crop} onChange={handleChange} onComplete={handleComplete} style={{ ...shadcnStyle, ...style }} {...reactCropProps} > {imgSrc && ( <img alt="crop" className="size-full" onLoad={onImageLoad} ref={imgRef} src={imgSrc} /> )} </ReactCrop> );};export type ImageCropApplyProps = ComponentProps<'button'> & { asChild?: boolean;};export const ImageCropApply = ({ asChild = false, children, onClick, ...props}: ImageCropApplyProps) => { const { applyCrop } = useImageCrop(); const handleClick = async (e: MouseEvent<HTMLButtonElement>) => { await applyCrop(); onClick?.(e); }; if (asChild) { return ( <Slot.Root onClick={handleClick} {...props}> {children} </Slot.Root> ); } return ( <Button onClick={handleClick} size="icon" variant="ghost" {...props}> {children ?? <CropIcon className="size-4" />} </Button> );};export type ImageCropResetProps = ComponentProps<'button'> & { asChild?: boolean;};export const ImageCropReset = ({ asChild = false, children, onClick, ...props}: ImageCropResetProps) => { const { resetCrop } = useImageCrop(); const handleClick = (e: MouseEvent<HTMLButtonElement>) => { resetCrop(); onClick?.(e); }; if (asChild) { return ( <Slot.Root onClick={handleClick} {...props}> {children} </Slot.Root> ); } return ( <Button onClick={handleClick} size="icon" variant="ghost" {...props}> {children ?? <RotateCcwIcon className="size-4" />} </Button> );};// Keep the original Cropper component for backward compatibilityexport type CropperProps = Omit<ReactCropProps, 'onChange'> & { file: File; maxImageSize?: number; onCrop?: (croppedImage: string) => void; onChange?: ReactCropProps['onChange'];};export const Cropper = ({ onChange, onComplete, onCrop, style, className, file, maxImageSize, ...props}: CropperProps) => ( <ImageCrop file={file} maxImageSize={maxImageSize} onChange={onChange} onComplete={onComplete} onCrop={onCrop} {...props} > <ImageCropContent className={className} style={style} /> </ImageCrop>);
Use Cases
- Profile pictures - Avatar cropping with circular output for user accounts
- Content management - Image editing tools for blogs and media libraries
- E-commerce - Product image cropping for consistent display formats
- Social media - Post image preparation with aspect ratio constraints
Implementation
Built on react-image-crop with canvas processing. Returns PNG data URLs. Supports async image loading. Use with file upload components for complete workflows.
Minimal Tiptap
Rich text editor built with Tiptap providing essential formatting tools and markdown support. Perfect for React applications requiring content editing with Next.js integration and TypeScript support.
Image Zoom
Click-to-zoom image component for React and Next.js applications. Built with TypeScript support, Tailwind CSS styling, and shadcn/ui design featuring smooth transitions, backdrop blur, and mobile-friendly interactions.