Search for a command to run...
Composable primitives for building image crop flows. The block ships only the provider, canvas, hook, and types; upload controls, dialogs, action buttons, previews, and persistence are intentionally composed in userland.
npx shadcn@latest add @zaidan/image-croppnpx shadcn add @zaidan/image-cropyarn dlx shadcn@latest add @zaidan/image-cropbunx shadcn@latest add @zaidan/image-cropCopy the image-crop folder into your project.
1import { ImageCropCanvas } from "./canvas";2import { useImageCrop } from "./context";3import { ImageCropProvider } from "./provider";4
5export type {6 ImageCropCanvasProps,7 ImageCropImage,8 ImageCropInitialImage,9 ImageCropOptions,10 ImageCropProviderProps,11 ImageCropResult,12} from "./types";13export { ImageCropCanvas, ImageCropProvider, useImageCrop };import { ImageCropCanvas, ImageCropProvider, useImageCrop,} from "~/components/blocks/image-crop";function CropDialog() { return ( <ImageCropProvider defaultImage={{ src: "/avatar.png", name: "avatar.png" }} onCrop={(result) => { // Upload result.blob, preview result.url, or download result.dataUrl. }} > <ImageCropCanvas /> <CropActions /> </ImageCropProvider> );}
function CropActions() { const crop = useImageCrop();
return ( <button type="button" onClick={() => void crop.cropImage()}> Save crop </button> );}Here are the source code of all the examples from the preview page:
import { ImageCropCanvas, ImageCropProvider, useImageCrop,} from "~/components/blocks/image-crop"; setDialogOpen(open); };
const handleCrop = (result: ImageCropResult) => { const nextSource = dialogImage();
cropResult()?.revoke(); setCropResult(result); setAvatarSrc(result.url); setSourceImage((previous) => { if (previous !== nextSource) { previous.revoke?.(); }
return nextSource; }); setDialogOpen(false); };
onCleanup(() => { cropResult()?.revoke(); sourceImage().revoke?.(); cleanupDialogDraft(); });
return ( <Example title="Avatar crop" class="items-center justify-center overflow-hidden p-0 sm:p-0" containerClass="max-w-4xl" > <div class="flex min-h-130 w-full items-center justify-center bg-muted/20 p-6 sm:p-10"> <div class="relative flex items-center justify-center"> <button aria-label="Crop profile avatar" class="group relative rounded-full outline-none transition-transform hover:scale-[1.02] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" onClick={openCurrentCrop} type="button" > <Avatar class="size-36 overflow-hidden rounded-full after:rounded-full"> <AvatarImage alt="Profile avatar" class="rounded-full" src={avatarSrc()} /> <AvatarFallback class="rounded-full">CN</AvatarFallback> </Avatar> </button> </div>
<Dialog open={dialogOpen()} onOpenChange={handleDialogOpenChange}> <Show when={dialogOpen()}> <DialogContent class="max-h-[calc(100vh-2rem)] max-w-[min(560px,calc(100vw-2rem))] gap-0 overflow-hidden p-0" showCloseButton={false} > <DialogHeader class="sr-only"> <DialogTitle>Crop avatar</DialogTitle> <DialogDescription> Save a square crop for profile images and account menus. </DialogDescription> </DialogHeader> <ImageCropProvider defaultImage={dialogImage()} onCrop={handleCrop}> <div class="overflow-hidden bg-background" data-slot="image-crop"> <div class="p-6"> <ImageCropCanvas class="min-h-80 bg-background sm:min-h-115" /> </div> <AvatarCropActions /> </div> </ImageCropProvider> </DialogContent> </Show> </Dialog> </div> </Example> );}
function AvatarCropActions() { const crop = useImageCrop(); const disabled = () => !crop.image() || crop.isCropping();
return ( <DialogFooter class="border-t p-4 sm:justify-center"> <DialogClose as={Button} type="button" variant="outline"> Cancel </DialogClose> <Button disabled={disabled()} onClick={() => void crop.cropImage()} type="button"> <Crop class="size-4" /> {crop.isCropping() ? "Cropping..." : "Crop"} </Button> </DialogFooter> );}
function StudioCropExample() { const [result, setResult] = createSignal<ImageCropResult | null>(null);
const clearResult = () => { result()?.revoke(); setResult(null); };
onCleanup(clearResult);
return ( <Example title="Image cropper" class="items-stretch overflow-hidden p-0 sm:p-0" containerClass="max-w-5xl" > <ImageCropProvider aspectRatio={null} onCrop={(nextResult) => { result()?.revoke(); setResult(nextResult); }} > <StudioCropSurface clearResult={clearResult} result={result} /> </ImageCropProvider> </Example> );import { ImageCropCanvas, ImageCropProvider, useImageCrop,} from "~/components/blocks/image-crop";function StudioCropSurface(props: { clearResult: () => void; result: () => ImageCropResult | null;}) { const crop = useImageCrop(); const [showResizeHandles, setShowResizeHandles] = createSignal(true); let inputRef: HTMLInputElement | undefined;
const handleFiles = (fileList: FileList | null) => { const file = fileList?.[0];
if (!file) { return; }
props.clearResult(); void crop.setImageFromFile(file); };
const openFilePicker = () => inputRef?.click();
const loadSampleImage = () => { props.clearResult(); void crop.setImageFromSource(demoLandscape); };
return ( <div class="flex min-h-170 w-full min-w-0 flex-col overflow-hidden bg-background xl:flex-row"> <input ref={(element) => { inputRef = element; }} accept="image/png,image/jpeg,image/gif,image/webp" class="sr-only" onChange={(event) => { handleFiles(event.currentTarget.files); event.currentTarget.value = ""; }} type="file" /> <div class="flex min-h-105 min-w-0 flex-1 items-center justify-center bg-muted/20"> <Show when={crop.image()} fallback={ <StudioUpload onBrowse={openFilePicker} onFiles={handleFiles} onUseSample={loadSampleImage} /> } > <ImageCropCanvas class="min-h-130 bg-transparent" showResizeHandles={showResizeHandles()} /> </Show> </div> <aside class="flex w-full shrink-0 flex-col gap-4 border-t bg-background p-4 xl:w-80 xl:border-t-0 xl:border-l"> <StudioCropControls onShowResizeHandlesChange={setShowResizeHandles} showResizeHandles={showResizeHandles} /> <StudioCropActions onBrowse={openFilePicker} result={props.result} /> </aside> </div> );}
function StudioUpload( props: ComponentProps<"section"> & { onBrowse: () => void; onFiles: (fileList: FileList | null) => void; onUseSample: () => void; },) { const [local, others] = splitProps(props, ["class", "onBrowse", "onFiles", "onUseSample"]); const [isDragging, setIsDragging] = createSignal(false);
return ( <section aria-label="Image upload dropzone" class={cn( "m-6 flex min-h-105 w-full max-w-3xl flex-col items-center justify-center gap-5 border-2 border-border border-dashed bg-background p-8 text-center transition-colors", isDragging() && "border-primary bg-primary/5", local.class, )} onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { setIsDragging(false); } }} onDragOver={(event) => { event.preventDefault(); setIsDragging(true); }} onDrop={(event) => { event.preventDefault(); setIsDragging(false); local.onFiles(event.dataTransfer?.files ?? null); }} {...others} > <div class="flex size-14 items-center justify-center rounded-full border bg-background text-muted-foreground shadow-sm"> <Upload class="size-6" /> </div> <div class="max-w-md space-y-1"> <h3 class="font-medium text-base">Upload an image to start cropping</h3> <p class="text-muted-foreground text-sm"> Drag and drop an image here, or select a file from your device. </p> </div> <div class="flex flex-wrap items-center justify-center gap-2"> <Button onClick={local.onBrowse} type="button"> <ImageIcon class="size-4" /> Select image </Button> <Button onClick={local.onUseSample} type="button" variant="secondary"> Use sample </Button> </div> </section> );}
function StudioCropControls(props: { onShowResizeHandlesChange: (checked: boolean) => void; showResizeHandles: () => boolean;}) { const crop = useImageCrop(); const disabled = () => !crop.image();
return ( <Card data-disabled={disabled() ? "" : undefined} class={cn(disabled() && "pointer-events-none opacity-50")} > <CardHeader> <CardTitle>Crop options</CardTitle> <CardDescription> {crop.options().width} x {crop.options().height} crop from {crop.displaySize().width} x{" "} {crop.displaySize().height} preview pixels </CardDescription> </CardHeader> <CardContent class="space-y-4"> <div class="grid grid-cols-2 gap-3"> <StudioNumberField label="Width" optionKey="width" /> <StudioNumberField label="Height" optionKey="height" /> <StudioNumberField label="X" optionKey="x" /> <StudioNumberField label="Y" optionKey="y" /> </div> <div class="flex items-center justify-between gap-3 rounded-md border bg-muted/20 p-3"> <Label class="min-w-0 flex-1 leading-snug" for="studio-crop-original"> Crop original size </Label> <Checkbox checked={crop.options().original} disabled={disabled()} id="studio-crop-original" onChange={(checked) => crop.setOptions((previous) => ({ ...previous, original: Boolean(checked) })) } /> </div> <div class="flex items-center justify-between gap-3 rounded-md border bg-muted/20 p-3"> <Label class="min-w-0 flex-1 leading-snug" for="studio-crop-handles"> Show resize handles </Label> <Checkbox checked={props.showResizeHandles()} disabled={disabled()} id="studio-crop-handles" onChange={(checked) => props.onShowResizeHandlesChange(Boolean(checked))} /> </div> </CardContent> </Card> );}
function StudioNumberField(props: { label: string; optionKey: NumberOption }) { const crop = useImageCrop(); const inputId = () => `studio-crop-${props.optionKey}`; const updateOption = (value: number) => { const roundedValue = roundPixel(Number.isFinite(value) ? value : 0);
crop.setOptions((previous) => { const next = { ...previous }; const currentAspectRatio = crop.aspectRatio();
if (currentAspectRatio && props.optionKey === "width") { next.width = roundedValue; next.height = roundDimension(roundedValue / currentAspectRatio); } else if (currentAspectRatio && props.optionKey === "height") { next.height = roundedValue; next.width = roundDimension(roundedValue * currentAspectRatio); } else if (props.optionKey === "width") { next.width = roundedValue; } else if (props.optionKey === "height") { next.height = roundedValue; } else { next[props.optionKey] = roundedValue; }
return next; }); };
return ( <div class="space-y-2"> <Label for={inputId()}>{props.label}</Label> <Input disabled={!crop.image()} id={inputId()} inputMode="numeric" min={props.optionKey === "width" || props.optionKey === "height" ? 1 : 0} onInput={(event) => updateOption(event.currentTarget.valueAsNumber)} type="number" value={crop.options()[props.optionKey]} /> </div> );}
function StudioCropActions(props: { onBrowse: () => void; result: () => ImageCropResult | null }) { const crop = useImageCrop(); const disabled = () => !crop.image() || crop.isCropping();
const downloadCrop = async () => { const result = await crop.cropImage();
if (!result) { return; }
downloadBlobUrl(result.url, "cropped-image.png"); };
return ( <div class="grid gap-2"> <Button disabled={disabled()} onClick={() => void downloadCrop()} type="button"> <Download class="size-4" /> {crop.isCropping() ? "Cropping..." : "Download crop"} </Button> <div class="grid grid-cols-2 gap-2"> <Button disabled={!crop.image()} onClick={crop.resetCrop} type="button" variant="secondary"> <RotateCcw class="size-4" /> Reset </Button> <Button disabled={!crop.image()} onClick={props.onBrowse} type="button" variant="secondary"> <RefreshCcw class="size-4" /> Replace </Button> </div> <Show when={props.result()}> {(result) => ( <div class="mt-2 rounded-md border bg-muted/20 p-3"> <img alt="Cropped preview" class="aspect-video w-full rounded-sm object-cover" src={result().url} /> <p class="mt-2 text-muted-foreground text-xs"> {result().width} x {result().height} output pixels </p> </div> )} </Show> </div> );}
function roundPixel(value: number) { return Math.max(0, Math.round(value));}
function roundDimension(value: number) { return Math.max(1, roundPixel(value));}
function downloadBlobUrl(url: string, fileName: string) { const link = document.createElement("a"); link.href = url; link.download = fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link);}Use onCrop when the cropped blob should be uploaded, previewed, or handled by application code.
import { createSignal, onCleanup, Show } from "solid-js";import { ImageCropCanvas, ImageCropProvider, type ImageCropResult, useImageCrop,} from "~/components/blocks/image-crop";
function AvatarCropper() { const [preview, setPreview] = createSignal<ImageCropResult | null>(null);
onCleanup(() => preview()?.revoke());
return ( <ImageCropProvider defaultImage={{ src: "/avatar.png", name: "avatar.png" }} onCrop={(result) => { preview()?.revoke(); setPreview(result); }} > <ImageCropCanvas /> <SaveCropButton /> <Show when={preview()}> {(result) => <img src={result().url} alt="Cropped avatar preview" />} </Show> </ImageCropProvider> );}
function SaveCropButton() { const crop = useImageCrop();
return ( <button type="button" onClick={() => void crop.cropImage()}> Save avatar </button> );}Provides crop state, image loading, crop generation, and configuration to child components.
| Prop | Type | Default | Description |
|---|---|---|---|
aspectRatio | number | null | 1 | Crop aspect ratio. The default locks the crop to a square for avatars. Use null for freeform crops. |
defaultImage | { src: string; crossOrigin?: "anonymous" | "use-credentials"; name?: string; type?: string } | - | Optional image loaded when the provider mounts. External URLs use anonymous CORS by default for canvas export. |
onCrop | (result: ImageCropResult) => void | - | Called after the canvas crop has been generated. |
outputQuality | number | 0.92 | Quality passed to canvas.toDataURL for JPEG/WebP outputs. |
outputType | "image/png" | "image/jpeg" | "image/webp" | "image/png" | MIME type used for the generated crop. |
Renders the current image, crop selection, drag interaction, resize handles, and keyboard nudging. It must be rendered inside ImageCropProvider.
| Prop | Type | Default | Description |
|---|---|---|---|
showResizeHandles | boolean | true | Shows or hides the visible resize handles around the crop selection. The full border remains resizable either way. |
Returns the crop context for custom controls and persistence flows.
| Property | Type | Description |
|---|---|---|
aspectRatio | Accessor<number | null> | The active aspect ratio. |
cropImage | () => Promise<ImageCropResult | null> | Generates the crop from the canvas. |
displaySize | Accessor<{ height: number; width: number }> | Current rendered image dimensions. |
image | Accessor<ImageCropImage | null> | The loaded image metadata. |
isCropping | Accessor<boolean> | Whether a crop is currently being generated. |
options | Accessor<ImageCropOptions> | Current crop geometry. |
resetCrop | () => void | Re-centers the crop using the current image and aspect ratio. |
setImageFromFile | (file: File) => Promise<void> | Loads an uploaded file. |
setImageFromSource | (source: ImageCropInitialImage) => Promise<void> | Loads an image source object. |
setOptions | (value: ImageCropOptions | ((previous: ImageCropOptions) => ImageCropOptions)) => void | Updates crop geometry with bounds clamping. |
| Property | Type | Description |
|---|---|---|
blob | Blob | The generated image blob. |
dataUrl | string | Base64 data URL from the crop canvas. |
height | number | Output image height. |
options | ImageCropOptions | Crop geometry at the moment of export. |
revoke | () => void | Revokes the object URL held by url. |
url | string | Object URL for previewing the generated blob. |
width | number | Output image width. |