Image Crop
Drag, resize, then save a square crop.
import { Crop } from "lucide-solid";import { createSignal, onCleanup, Show } from "solid-js";
import { ImageCropCanvas, ImageCropProvider, type ImageCropResult, useImageCrop,} from "@/registry/kobalte/blocks/image-crop";import { Button } from "~/components/ui/button";
const sampleImage = { name: "portrait.svg", src: `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="960" height="640" viewBox="0 0 960 640"> <defs> <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"> <stop offset="0" stop-color="#bae6fd"/> <stop offset="1" stop-color="#fef3c7"/> </linearGradient> </defs> <rect width="960" height="640" fill="url(#bg)"/> <circle cx="700" cy="150" r="70" fill="#f59e0b" opacity="0.8"/> <path d="M0 420C160 340 320 400 480 340C660 272 800 320 960 260V640H0Z" fill="#0f172a" opacity="0.25"/> <path d="M0 500C200 440 380 500 560 450C740 400 860 440 960 410V640H0Z" fill="#0f172a" opacity="0.35"/></svg>`)}`, type: "image/svg+xml",};
export default function ImageCropDemo() { const [result, setResult] = createSignal<ImageCropResult | null>(null);
const handleCrop = (nextResult: ImageCropResult) => { result()?.revoke(); setResult(nextResult); };
onCleanup(() => result()?.revoke());
return ( <ImageCropProvider defaultImage={sampleImage} onCrop={handleCrop}> <div class="w-full max-w-2xl overflow-hidden rounded-xl border bg-background"> <ImageCropCanvas class="min-h-80" /> <div class="flex items-center justify-between gap-4 border-t p-4"> <Show when={result()} fallback={ <p class="text-muted-foreground text-sm">Drag, resize, then save a square crop.</p> } > {(cropped) => ( <div class="flex items-center gap-3"> <img alt="Cropped preview" class="size-12 rounded-full border object-cover" src={cropped().url} /> <p class="text-muted-foreground text-xs"> {cropped().width} x {cropped().height} output pixels </p> </div> )} </Show> <CropButton /> </div> </div> </ImageCropProvider> );}
function CropButton() { const crop = useImageCrop();
return ( <Button disabled={!crop.image() || crop.isCropping()} onClick={() => void crop.cropImage()} type="button" > <Crop class="size-4" /> {crop.isCropping() ? "Cropping..." : "Crop"} </Button> );}Image Crop is a set of composable Solid primitives for building crop flows. ImageCropProvider owns the image, the crop geometry, and canvas export. ImageCropCanvas renders the image with a draggable, resizable, keyboard-accessible selection. useImageCrop exposes the same state to your own controls.
The block intentionally ships only the provider, canvas, hook, and types. Upload inputs, dialogs, action buttons, previews, and persistence stay in userland, so the installable surface remains small and your product composition stays free.
Installation
Usage
import { ImageCropCanvas, ImageCropProvider, useImageCrop,} from "~/components/blocks/image-crop";<ImageCropProvider defaultImage={{ src: "/avatar.png", name: "avatar.png" }} onCrop={(result) => { // Upload result.blob, preview result.url, or store result.dataUrl. }}> <ImageCropCanvas /> <CropActions /></ImageCropProvider>Controls read the crop state through useImageCrop from anywhere inside the provider.
function CropActions() { const crop = useImageCrop();
return ( <button type="button" disabled={!crop.image() || crop.isCropping()} onClick={() => void crop.cropImage()} > {crop.isCropping() ? "Cropping..." : "Save crop"} </button> );}Composition
ImageCropProvider├── ImageCropCanvas└── Your controls (useImageCrop)ImageCropProvideris the headless root. It loads images, clamps crop geometry, scales the stage to the viewport, and exports the crop through an offscreen canvas.ImageCropCanvasis the interactive stage. It measures itself with aResizeObserver, renders the image at display scale, and hosts the draggable and resizable selection.useImageCropreturns the context value for custom controls: upload buttons, geometry inputs, crop actions, and result previews.
The selection is a focusable button: arrow keys nudge the crop by one display pixel, and holding Shift nudges by ten.
Examples
Aspect Ratio
aspectRatio locks the selection shape. It defaults to 1 for square avatar crops; pass any positive ratio such as 16 / 9, or null for a freeform selection. The ratio applies to pointer resizing and to every setOptions update. Call resetCrop after changing the ratio to re-center the selection with the new shape.
import { createSignal, For } from "solid-js";
import { ImageCropCanvas, ImageCropProvider, useImageCrop,} from "@/registry/kobalte/blocks/image-crop";import { Button } from "~/components/ui/button";
const sampleImage = { name: "landscape.svg", src: `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="720" viewBox="0 0 1080 720"> <defs> <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"> <stop offset="0" stop-color="#c7d2fe"/> <stop offset="1" stop-color="#fce7f3"/> </linearGradient> </defs> <rect width="1080" height="720" fill="url(#bg)"/> <circle cx="820" cy="170" r="80" fill="#f97316" opacity="0.75"/> <path d="M0 470C180 390 360 450 540 380C740 306 900 360 1080 300V720H0Z" fill="#1e1b4b" opacity="0.3"/></svg>`)}`, type: "image/svg+xml",};
const ratios = [ { label: "Square", value: 1 }, { label: "4:3", value: 4 / 3 }, { label: "16:9", value: 16 / 9 }, { label: "Free", value: null },] as const;
export default function ImageCropAspectRatio() { const [aspectRatio, setAspectRatio] = createSignal<number | null>(1);
return ( <ImageCropProvider aspectRatio={aspectRatio()} defaultImage={sampleImage}> <div class="w-full max-w-2xl overflow-hidden rounded-xl border bg-background"> <ImageCropCanvas class="min-h-80" /> <RatioControls aspectRatio={aspectRatio} onAspectRatioChange={setAspectRatio} /> </div> </ImageCropProvider> );}
function RatioControls(props: { aspectRatio: () => number | null; onAspectRatioChange: (value: number | null) => void;}) { const crop = useImageCrop();
const selectRatio = (value: number | null) => { props.onAspectRatioChange(value); crop.resetCrop(); };
return ( <div class="flex flex-wrap items-center gap-2 border-t p-4"> <For each={ratios}> {(ratio) => ( <Button onClick={() => selectRatio(ratio.value)} size="sm" type="button" variant={props.aspectRatio() === ratio.value ? "default" : "outline"} > {ratio.label} </Button> )} </For> </div> );}Loading Images
Load images three ways: declaratively with defaultImage when the provider mounts, from an upload with setImageFromFile, or from any URL-like source with setImageFromSource. Files are read through object URLs that the provider revokes when the image is replaced or the provider unmounts. Non-image files are ignored by setImageFromFile.
Pick a file from your device or load the bundled sample image.
import { Image as ImageIcon, RefreshCcw } from "lucide-solid";import { Show } from "solid-js";
import { ImageCropCanvas, ImageCropProvider, useImageCrop,} from "@/registry/kobalte/blocks/image-crop";import { Button } from "~/components/ui/button";
const sampleImage = { name: "sample.svg", src: `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="680" viewBox="0 0 1024 680"> <defs> <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"> <stop offset="0" stop-color="#a7f3d0"/> <stop offset="1" stop-color="#e0f2fe"/> </linearGradient> </defs> <rect width="1024" height="680" fill="url(#bg)"/> <circle cx="260" cy="200" r="90" fill="#10b981" opacity="0.5"/> <rect x="520" y="240" width="320" height="220" rx="24" fill="#0f172a" opacity="0.2"/></svg>`)}`, type: "image/svg+xml",};
export default function ImageCropUpload() { return ( <ImageCropProvider> <div class="w-full max-w-2xl overflow-hidden rounded-xl border bg-background"> <UploadSurface /> </div> </ImageCropProvider> );}
function UploadSurface() { const crop = useImageCrop(); let inputRef: HTMLInputElement | undefined;
const handleFiles = (fileList: FileList | null) => { const file = fileList?.[0];
if (!file) { return; }
void crop.setImageFromFile(file); };
return ( <> <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" /> <Show when={crop.image()} fallback={ <div class="flex min-h-80 flex-col items-center justify-center gap-4 p-8 text-center"> <p class="max-w-sm text-muted-foreground text-sm"> Pick a file from your device or load the bundled sample image. </p> <div class="flex flex-wrap items-center justify-center gap-2"> <Button onClick={() => inputRef?.click()} type="button"> <ImageIcon class="size-4" /> Select image </Button> <Button onClick={() => void crop.setImageFromSource(sampleImage)} type="button" variant="secondary" > Use sample </Button> </div> </div> } > <ImageCropCanvas class="min-h-80" /> <div class="flex items-center justify-between gap-4 border-t p-4"> <p class="truncate text-muted-foreground text-sm">{crop.image()?.name}</p> <Button onClick={() => inputRef?.click()} size="sm" type="button" variant="outline"> <RefreshCcw class="size-4" /> Replace </Button> </div> </Show> </> );}Because export draws the image onto a canvas, remote sources must be same-origin or CORS-readable; blob and data URLs always work.
Controlled Crop Geometry
options exposes the crop rectangle in display pixels, and setOptions updates it with automatic clamping to the stage bounds and the active aspect ratio. Use the functional form to derive the next geometry from the previous one. resetCrop restores the centered default selection.
120 x 120 at (0, 0)
import { RotateCcw } from "lucide-solid";
import { ImageCropCanvas, ImageCropProvider, useImageCrop,} from "@/registry/kobalte/blocks/image-crop";import { Button } from "~/components/ui/button";
const sampleImage = { name: "grid.svg", src: `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="960" height="640" viewBox="0 0 960 640"> <defs> <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"> <stop offset="0" stop-color="#fde68a"/> <stop offset="1" stop-color="#fca5a5"/> </linearGradient> </defs> <rect width="960" height="640" fill="url(#bg)"/> <circle cx="480" cy="320" r="120" fill="#7c2d12" opacity="0.3"/> <rect x="120" y="120" width="200" height="140" rx="20" fill="#7c2d12" opacity="0.2"/> <rect x="640" y="380" width="200" height="140" rx="20" fill="#7c2d12" opacity="0.2"/></svg>`)}`, type: "image/svg+xml",};
export default function ImageCropControlled() { return ( <ImageCropProvider defaultImage={sampleImage}> <div class="w-full max-w-2xl overflow-hidden rounded-xl border bg-background"> <ImageCropCanvas class="min-h-80" /> <GeometryControls /> </div> </ImageCropProvider> );}
function GeometryControls() { const crop = useImageCrop();
const nudge = (deltaX: number, deltaY: number) => { crop.setOptions((previous) => ({ ...previous, x: previous.x + deltaX, y: previous.y + deltaY, })); };
const center = () => { crop.setOptions((previous) => ({ ...previous, x: Math.floor((crop.displaySize().width - previous.width) / 2), y: Math.floor((crop.displaySize().height - previous.height) / 2), })); };
return ( <div class="flex flex-wrap items-center justify-between gap-3 border-t p-4"> <p class="font-mono text-muted-foreground text-xs"> {crop.options().width} x {crop.options().height} at ({crop.options().x}, {crop.options().y}) </p> <div class="flex flex-wrap items-center gap-2"> <Button onClick={() => nudge(-20, 0)} size="sm" type="button" variant="outline"> Left </Button> <Button onClick={() => nudge(20, 0)} size="sm" type="button" variant="outline"> Right </Button> <Button onClick={center} size="sm" type="button" variant="outline"> Center </Button> <Button onClick={crop.resetCrop} size="sm" type="button" variant="secondary"> <RotateCcw class="size-4" /> Reset </Button> </div> </div> );}Output Handling
cropImage draws the selected region to a canvas and resolves an ImageCropResult with a blob, a dataUrl, and an object url for previews. The same result is passed to onCrop. outputType and outputQuality control the encoding, and the original flag on the crop options switches between source-resolution output and display-resolution output.
No crop generated yet.
import { Download } from "lucide-solid";import { createSignal, For, onCleanup, Show } from "solid-js";
import { ImageCropCanvas, ImageCropProvider, type ImageCropResult, useImageCrop,} from "@/registry/kobalte/blocks/image-crop";import { Button } from "~/components/ui/button";import { Checkbox } from "~/components/ui/checkbox";import { Label } from "~/components/ui/label";
type OutputType = "image/png" | "image/jpeg" | "image/webp";
const sampleImage = { name: "sunset.svg", src: `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="620" viewBox="0 0 1080 620"> <defs> <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"> <stop offset="0" stop-color="#fbcfe8"/> <stop offset="1" stop-color="#fed7aa"/> </linearGradient> </defs> <rect width="1080" height="620" fill="url(#bg)"/> <circle cx="540" cy="420" r="130" fill="#ea580c" opacity="0.65"/> <rect y="480" width="1080" height="140" fill="#7c2d12" opacity="0.35"/></svg>`)}`, type: "image/svg+xml",};
const outputTypes: { label: string; value: OutputType }[] = [ { label: "PNG", value: "image/png" }, { label: "JPEG", value: "image/jpeg" }, { label: "WebP", value: "image/webp" },];
export default function ImageCropOutput() { const [outputType, setOutputType] = createSignal<OutputType>("image/png"); const [result, setResult] = createSignal<ImageCropResult | null>(null);
const handleCrop = (nextResult: ImageCropResult) => { result()?.revoke(); setResult(nextResult); };
onCleanup(() => result()?.revoke());
return ( <ImageCropProvider defaultImage={sampleImage} onCrop={handleCrop} outputQuality={0.85} outputType={outputType()} > <div class="w-full max-w-2xl overflow-hidden rounded-xl border bg-background"> <ImageCropCanvas class="min-h-80" /> <OutputControls onOutputTypeChange={setOutputType} outputType={outputType} result={result} /> </div> </ImageCropProvider> );}
function OutputControls(props: { onOutputTypeChange: (value: OutputType) => void; outputType: () => OutputType; result: () => ImageCropResult | null;}) { const crop = useImageCrop();
return ( <div class="space-y-4 border-t p-4"> <div class="flex flex-wrap items-center justify-between gap-3"> <div class="flex items-center gap-2"> <For each={outputTypes}> {(type) => ( <Button onClick={() => props.onOutputTypeChange(type.value)} size="sm" type="button" variant={props.outputType() === type.value ? "default" : "outline"} > {type.label} </Button> )} </For> </div> <div class="flex items-center gap-2"> <Label for="image-crop-output-original">Original resolution</Label> <Checkbox checked={crop.options().original} id="image-crop-output-original" onChange={(checked) => crop.setOptions((previous) => ({ ...previous, original: Boolean(checked) })) } /> </div> </div> <div class="flex items-center justify-between gap-4"> <Show when={props.result()} fallback={<p class="text-muted-foreground text-sm">No crop generated yet.</p>} > {(cropped) => ( <p class="text-muted-foreground text-xs"> {cropped().width} x {cropped().height} pixels, {cropped().blob.type},{" "} {Math.round(cropped().blob.size / 1024)} KB </p> )} </Show> <Button disabled={!crop.image() || crop.isCropping()} onClick={() => void crop.cropImage()} type="button" > <Download class="size-4" /> {crop.isCropping() ? "Cropping..." : "Generate crop"} </Button> </div> </div> );}Every result holds an object URL. Call result.revoke() once the preview or upload is done to release it.
Accessibility
The crop selection is rendered as a button labelled "Crop area". It participates in the tab order, shows a visible focus ring, and supports keyboard nudging with the arrow keys (Shift for larger steps). Resize handles are pointer affordances and stay hidden from assistive technology.
API Reference
ImageCropProvider
Provides crop state, image loading, crop generation, and configuration to child components.
ImageCropCanvas
Renders the current image, crop selection, drag interaction, resize handles, and keyboard nudging. It accepts all div props and must be rendered inside ImageCropProvider. Nothing renders until an image is loaded.
Data attributes for styling: data-slot="image-crop-canvas" on the viewport, data-slot="image-crop-stage" on the scaled image wrapper, and data-slot="image-crop-selection" on the selection button.
useImageCrop
Returns the crop context for custom controls and persistence flows. Throws when called outside ImageCropProvider.
ImageCropOptions
The crop rectangle, expressed in display pixels of the scaled stage.
ImageCropInitialImage
Source object accepted by defaultImage and setImageFromSource.
ImageCropResult
Resolved by cropImage and passed to onCrop.