Zaidan

Command Palette

Search for a command to run...

GitHub106

Image Crop

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.

Installation

CLI

Manual

Copy the image-crop folder into your project.

import { ImageCropCanvas } from "./canvas";
import { useImageCrop } from "./context";
import { ImageCropProvider } from "./provider";
export type {
ImageCropCanvasProps,
ImageCropImage,
ImageCropInitialImage,
ImageCropOptions,
ImageCropProviderProps,
ImageCropResult,
} from "./types";
export { ImageCropCanvas, ImageCropProvider, useImageCrop };

Usage

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>
);
}

Examples

Here are the source code of all the examples from the preview page:

Avatar Crop

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>
);

Image Cropper

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);
}

Controlled Export

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>
);
}

API Reference

ImageCropProvider

Provides crop state, image loading, crop generation, and configuration to child components.

PropTypeDefaultDescription
aspectRationumber | null1Crop 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.
outputQualitynumber0.92Quality passed to canvas.toDataURL for JPEG/WebP outputs.
outputType"image/png" | "image/jpeg" | "image/webp""image/png"MIME type used for the generated crop.

ImageCropCanvas

Renders the current image, crop selection, drag interaction, resize handles, and keyboard nudging. It must be rendered inside ImageCropProvider.

PropTypeDefaultDescription
showResizeHandlesbooleantrueShows or hides the visible resize handles around the crop selection. The full border remains resizable either way.

useImageCrop

Returns the crop context for custom controls and persistence flows.

PropertyTypeDescription
aspectRatioAccessor<number | null>The active aspect ratio.
cropImage() => Promise<ImageCropResult | null>Generates the crop from the canvas.
displaySizeAccessor<{ height: number; width: number }>Current rendered image dimensions.
imageAccessor<ImageCropImage | null>The loaded image metadata.
isCroppingAccessor<boolean>Whether a crop is currently being generated.
optionsAccessor<ImageCropOptions>Current crop geometry.
resetCrop() => voidRe-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)) => voidUpdates crop geometry with bounds clamping.

ImageCropResult

PropertyTypeDescription
blobBlobThe generated image blob.
dataUrlstringBase64 data URL from the crop canvas.
heightnumberOutput image height.
optionsImageCropOptionsCrop geometry at the moment of export.
revoke() => voidRevokes the object URL held by url.
urlstringObject URL for previewing the generated blob.
widthnumberOutput image width.