Image Cropper
- Needs review
- RTL
Shows an image with a crop frame people drag to move and resize, free or locked to an aspect ratio, in a rectangle or a
circle, with a live CSS filter preview. No Figma frame: built from the approved Studio, Darkroom and Guided samples, which
are its stories. The component only edits the frame; picking files, filter controls and saving stay in your app. Read the frame from
onCropChange and export it with cropImage(src, crop, { filter, shape, type }), which returns a Blob. Keyboard (WCAG 2.1.1): the frame is one tab stop; arrow keys move it, Alt + arrow keys resize it, Shift makes 10× steps.
The instructions are its accessible description. Resizing by dragging is also possible from the keyboard, so no action
depends on dragging alone (2.5.7). Handles have a 36 px hit area. Consumer duties: an upload button (FileTrigger or Dropzone) next to the stage, a visible output size if it matters,
file type and size checks, and an error message for onError.
import { ImageCropper } from "@nexera-ui/react";- 4
- examples
- 18
- props
- 4
- live controls
- 1
- platform
- 0
- WCAG criteria
- 3
- blocks use it
Try every prop. Copy the code.
Change the props and the code updates. Check light and dark, LTR and RTL, and phone width.
import { ImageCropper } from "@nexera-ui/react";
<ImageCropper />Examples 3
The same examples as Storybook, rendered live. Open Code to copy one.
Studio
Sample A, Studio: an editor with the image beside an inspector. Crop has nine ratios and the exact frame in pixels, Filters has eight looks, Adjust has three sliders, and the footer picks the file format with a Select and saves. Upload, or drop an image on the stage, to edit your own.
import { useState } from "react";
import { FileTrigger, type Key } from "react-aria-components";
import {
Alert,
Button,
FileTrigger,
IMAGE_FILTERS,
ImageCropper,
Select,
SelectOption,
Slider,
Tab,
TabList,
TabPanel,
Tabs,
composeImageFilter,
cropImage,
} from "@nexera-ui/react";
import {
LuCircleCheck,
LuCloudUpload,
LuCrop,
LuDownload,
LuRotateCcw,
LuSlidersHorizontal,
LuSparkles,
} from "react-icons/lu";
const SAMPLE_PHOTO = `data:image/svg+xml,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1067" viewBox="0 0 1600 1067"><defs>' +
'<linearGradient id="sky" x2="0" y2="1"><stop offset="0" stop-color="midnightblue"/><stop offset=".5" stop-color="mediumpurple"/><stop offset=".85" stop-color="lightsalmon"/><stop offset="1" stop-color="peachpuff"/></linearGradient>' +
'<linearGradient id="lake" x2="0" y2="1"><stop offset="0" stop-color="darksalmon"/><stop offset=".4" stop-color="slateblue"/><stop offset="1" stop-color="midnightblue"/></linearGradient>' +
'<radialGradient id="glow"><stop offset="0" stop-color="lightyellow"/><stop offset=".2" stop-color="moccasin" stop-opacity=".9"/><stop offset="1" stop-color="lightsalmon" stop-opacity="0"/></radialGradient></defs>' +
'<rect width="1600" height="700" fill="url(#sky)"/><circle cx="1060" cy="600" r="420" fill="url(#glow)"/><circle cx="1060" cy="600" r="64" fill="lightyellow"/>' +
'<path d="M0 560 180 440 320 520 520 380 700 500 900 410 1100 520 1300 430 1600 540V700H0Z" fill="mediumpurple" opacity=".8"/>' +
'<path d="M0 620 240 520 420 600 640 500 860 610 1080 540 1340 620 1600 560V700H0Z" fill="darkslateblue"/>' +
'<path d="M0 670 300 610 560 660 820 600 1120 670 1400 620 1600 660V700H0Z" fill="midnightblue"/>' +
'<rect y="700" width="1600" height="367" fill="url(#lake)"/>' +
'<path d="M950 714h220v5H950zM975 744h170v5H975zM1000 776h120v4h-120zM1025 808h70v4h-70z" fill="lightyellow" opacity=".6"/>' +
'<path d="M110 480 50 690h120zM110 580 30 820h160zM110 690 10 960h200zM300 620 250 790h100zM300 700 230 920h140zM1480 560 1420 760h120zM1480 660 1400 900h160z" fill="black" opacity=".85"/>' +
'<path d="M0 1067V930c240-50 470 0 640 137zM1600 1067V960c-160-30-300 20-420 107z" fill="black" opacity=".85"/></svg>',
)}`;
const SAMPLE_NAME = "lake-at-dawn.svg";
const FORMATS = [
{ id: "image/png", label: "PNG" },
{ id: "image/jpeg", label: "JPEG" },
{ id: "image/webp", label: "WebP" },
] as const;
const NO_ADJUSTMENTS = { brightness: 0, contrast: 0, saturation: 0 };
const ADJUSTMENTS = [
{ key: "brightness", label: "Brightness" },
{ key: "contrast", label: "Contrast" },
{ key: "saturation", label: "Saturation" },
] as const;
const pixels = (crop: CropArea | null) =>
crop ? `${String(Math.round(crop.width))} × ${String(Math.round(crop.height))} px` : "";
function usePhoto() {
const [photo, setPhoto] = useState({ src: SAMPLE_PHOTO, name: SAMPLE_NAME });
const [error, setError] = useState<string | null>(null);
const open = (file: File) => {
if (!file.type.startsWith("image/")) {
setError(`“${file.name}” isn’t an image. Choose a PNG, JPG, WebP or GIF file.`);
return;
}
if (file.size > 20 * 1024 * 1024) {
setError(`“${file.name}” is larger than 20 MB. Choose a smaller image.`);
return;
}
setError(null);
const src = URL.createObjectURL(file);
setPhoto((old) => {
if (old.src.startsWith("blob:")) URL.revokeObjectURL(old.src);
return { src, name: file.name };
});
};
const pickSample = () => {
setError(null);
setPhoto({ src: SAMPLE_PHOTO, name: SAMPLE_NAME });
};
return { photo, error, setError, open, pickSample };
}
interface SavedImage {
url: string;
name: string;
details: string;
}
async function saveImage(
photo: { src: string; name: string },
crop: CropArea,
options: CropImageOptions,
): Promise<SavedImage> {
const blob = await cropImage(photo.src, crop, options);
const extension = blob.type === "image/jpeg" ? "jpg" : (blob.type.split("/")[1] ?? "png");
const kb = Math.max(1, Math.round(blob.size / 1024));
return {
url: URL.createObjectURL(blob),
name: `${photo.name.replace(/\.[^.]+$/, "")}-edited.${extension}`,
details: `${pixels(crop)} · ${String(kb)} KB · ${extension.toUpperCase()}`,
};
}
function SavedImageCard({ image }: { image: SavedImage }) {
return (
<div
role="status"
className="flex flex-wrap items-center gap-3 rounded-lg border border-default bg-surface p-3"
>
<img src={image.url} alt="" className="size-14 rounded-sm bg-sunken object-contain" />
<div className="grid min-w-0 flex-1 gap-0.5">
<p className="m-0 flex items-center gap-1.5 text-body-strong break-all text-primary">
<LuCircleCheck className="size-4 shrink-0 text-status-success-fg" />
Saved {image.name}
</p>
<p className="m-0 text-body-small text-secondary tabular-nums">{image.details}</p>
</div>
<a
href={image.url}
download={image.name}
className="rounded-sm text-body-small text-brand-text underline outline-hidden focus-visible:focus-ring"
>
Download
</a>
</div>
);
}
function FilterTiles({
src,
selected,
onSelect,
className,
}: {
src: string;
selected: string;
onSelect: (id: string) => void;
className: string;
}) {
return (
<div role="group" aria-label="Filter" className={className}>
{IMAGE_FILTERS.map((preset) => (
<button
key={preset.id}
type="button"
aria-pressed={preset.id === selected}
onClick={() => {
onSelect(preset.id);
}}
className="group m-0 grid border-0 bg-transparent p-0 min-w-0 cursor-pointer gap-1.5 rounded-md text-center text-body-caption text-secondary outline-hidden focus-visible:focus-ring aria-pressed:text-primary"
>
<img
src={src}
alt=""
style={{ filter: preset.filter }}
className="aspect-square w-full rounded-md object-cover outline-2 outline-offset-2 outline-transparent group-hover:outline-strong group-aria-pressed:outline-brand-primary"
/>
{preset.label}
</button>
))}
</div>
);
}
function AdjustmentSliders({
value,
onChange,
}: {
value: Required<ImageAdjustments>;
onChange: (value: Required<ImageAdjustments>) => void;
}) {
return (
<div className="grid gap-3">
{ADJUSTMENTS.map(({ key, label }) => (
<Slider
key={key}
label={label}
minValue={-50}
maxValue={50}
value={value[key]}
onChange={(next: number) => {
onChange({ ...value, [key]: next });
}}
/>
))}
</div>
);
}
const STUDIO_RATIOS = [
{ id: "free", label: "Free", ratio: null },
{ id: "original", label: "Original", ratio: 3 / 2 },
{ id: "1:1", label: "1:1", ratio: 1 },
{ id: "4:3", label: "4:3", ratio: 4 / 3 },
{ id: "3:2", label: "3:2", ratio: 3 / 2 },
{ id: "16:9", label: "16:9", ratio: 16 / 9 },
{ id: "3:4", label: "3:4", ratio: 3 / 4 },
{ id: "4:5", label: "4:5", ratio: 4 / 5 },
{ id: "9:16", label: "9:16", ratio: 9 / 16 },
] as const;
function RatioShape({ ratio }: { ratio: number | null }) {
const width = ratio === null ? 18 : ratio >= 1 ? 24 : 24 * ratio;
const height = ratio === null ? 18 : ratio >= 1 ? 24 / ratio : 24;
return (
<span className="grid h-6 place-items-center" aria-hidden="true">
<span
className={`block rounded-[3px] border-[1.5px] border-current ${ratio === null ? "border-dashed" : ""}`}
style={{ width, height }}
/>
</span>
);
}
export function Studio() {
const { photo, error, setError, open } = usePhoto();
const [size, setSize] = useState<ImageSize | null>(null);
const [crop, setCrop] = useState<CropArea | null>(null);
const [ratioId, setRatioId] = useState<string>("free");
const [presetId, setPresetId] = useState("original");
const [adjust, setAdjust] = useState(NO_ADJUSTMENTS);
const [format, setFormat] = useState<Key>("image/png");
const [resets, setResets] = useState(0);
const [saved, setSaved] = useState<SavedImage | null>(null);
const preset = IMAGE_FILTERS.find((p) => p.id === presetId) ?? IMAGE_FILTERS[0];
const filter = composeImageFilter(preset?.filter ?? "none", adjust);
const ratio =
ratioId === "original" && size
? size.width / size.height
: (STUDIO_RATIOS.find((r) => r.id === ratioId)?.ratio ?? null);
return (
<div className="@container overflow-hidden rounded-xl border border-default bg-surface shadow-md">
<div className="flex items-center gap-3 border-b border-default p-3">
<FileTrigger
acceptedFileTypes={["image/*"]}
onSelect={(files) => {
const file = files?.[0];
if (file) open(file);
}}
>
<Button variant="secondary" size="sm" leadingIcon={<LuCloudUpload />}>
Upload
</Button>
</FileTrigger>
<p className="m-0 flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2.5">
<span className="truncate text-body-strong text-primary">{photo.name}</span>
{size ? (
<span className="text-body-small text-muted tabular-nums">
{size.width} × {size.height}
</span>
) : null}
</p>
<Button
variant="ghost"
size="sm"
leadingIcon={<LuRotateCcw />}
onPress={() => {
setRatioId("free");
setPresetId("original");
setAdjust(NO_ADJUSTMENTS);
setResets((n) => n + 1);
setSaved(null);
}}
>
Reset
</Button>
</div>
<div className="grid @3xl:grid-cols-[minmax(0,1fr)_18.75rem]">
<ImageCropper
key={resets}
src={photo.src}
aspectRatio={ratio}
filter={filter}
handles="square"
className="h-[21rem] @3xl:h-auto @3xl:min-h-[28.75rem]"
onLoad={setSize}
onCropChange={setCrop}
onFileDrop={open}
onError={() => {
setError("This image couldn’t be opened. Try another file.");
}}
/>
<div className="grid content-start gap-4 border-t border-default p-4 @3xl:border-t-0 @3xl:border-l">
<Tabs defaultSelectedKey="crop">
<TabList aria-label="Edit mode" variant="enclosed">
<Tab id="crop" label="Crop" icon={<LuCrop />} size="sm" />
<Tab id="filters" label="Filters" icon={<LuSparkles />} size="sm" />
<Tab id="adjust" label="Adjust" icon={<LuSlidersHorizontal />} size="sm" />
</TabList>
<TabPanel id="crop" className="grid gap-3.5 pt-4">
<p className="m-0 text-component-label text-muted">Aspect ratio</p>
<div role="group" aria-label="Aspect ratio" className="grid grid-cols-3 gap-2">
{STUDIO_RATIOS.map((r) => (
<button
key={r.id}
type="button"
aria-pressed={r.id === ratioId}
onClick={() => {
setRatioId(r.id);
}}
className="grid cursor-pointer justify-items-center gap-1 rounded-md border border-default bg-surface px-1 py-2 text-body-caption text-secondary outline-hidden hover:bg-hover focus-visible:focus-ring aria-pressed:border-brand-primary aria-pressed:bg-brand-subtle aria-pressed:text-brand-text"
>
<RatioShape ratio={r.ratio} />
{r.label}
</button>
))}
</div>
<dl className="m-0 grid grid-cols-2 gap-2">
{(
[
["Width", crop?.width],
["Height", crop?.height],
["Left", crop?.x],
["Top", crop?.y],
] as const
).map(([term, value]) => (
<div key={term} className="rounded-md bg-sunken px-2.5 py-2">
<dt className="text-body-micro text-muted uppercase">{term}</dt>
<dd className="m-0 font-mono text-body-small text-primary tabular-nums">
{Math.round(value ?? 0)}
</dd>
</div>
))}
</dl>
<p className="m-0 text-body-small text-muted">
Drag the frame to move it, drag a handle to resize. Arrow keys nudge the frame; hold
Shift for bigger steps.
</p>
</TabPanel>
<TabPanel id="filters" className="pt-4">
<FilterTiles
src={photo.src}
selected={presetId}
onSelect={setPresetId}
className="grid grid-cols-3 gap-x-2.5 gap-y-3"
/>
</TabPanel>
<TabPanel id="adjust" className="pt-4">
<AdjustmentSliders value={adjust} onChange={setAdjust} />
</TabPanel>
</Tabs>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 border-t border-default p-3">
<p className="m-0 min-w-0 flex-1 text-body-small">
<span className="text-muted">Output</span>{" "}
<span className="font-mono whitespace-nowrap text-primary tabular-nums">
{pixels(crop)}
</span>
</p>
<Select
aria-label="File format"
size="sm"
className="w-28"
value={format}
onChange={(key) => {
if (key !== null) setFormat(key);
}}
>
{FORMATS.map((f) => (
<SelectOption key={f.id} id={f.id} label={f.label} />
))}
</Select>
<Button
size="sm"
leadingIcon={<LuDownload />}
isDisabled={!crop}
onPress={() => {
if (crop) void saveImage(photo, crop, { filter, type: String(format) }).then(setSaved);
}}
>
Save image
</Button>
</div>
{error || saved ? (
<div className="grid gap-3 px-3 pb-3">
{error ? <Alert tone="danger" title={error} /> : null}
{saved ? <SavedImageCard image={saved} /> : null}
</div>
) : null}
</div>
);
}
Darkroom
Sample B, Darkroom: the photo fills a dark stage (a data-theme="dark" wrapper, so every Nexera part inside turns dark) with bracket handles and a thirds grid. One tool shows at a time in the tray, picked from the tab bar at the bottom: ratio chips, a filter strip, or one slider for the chosen adjustment. Save exports a JPEG.
import { useState } from "react";
import { FileTrigger } from "react-aria-components";
import {
Alert,
Button,
FileTrigger,
FilterChip,
IMAGE_FILTERS,
IconButton,
ImageCropper,
Slider,
Tab,
TabList,
TabPanel,
Tabs,
composeImageFilter,
cropImage,
} from "@nexera-ui/react";
import {
LuCheck,
LuCircleCheck,
LuCloudUpload,
LuCrop,
LuSlidersHorizontal,
LuSparkles,
} from "react-icons/lu";
const SAMPLE_PHOTO = `data:image/svg+xml,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1067" viewBox="0 0 1600 1067"><defs>' +
'<linearGradient id="sky" x2="0" y2="1"><stop offset="0" stop-color="midnightblue"/><stop offset=".5" stop-color="mediumpurple"/><stop offset=".85" stop-color="lightsalmon"/><stop offset="1" stop-color="peachpuff"/></linearGradient>' +
'<linearGradient id="lake" x2="0" y2="1"><stop offset="0" stop-color="darksalmon"/><stop offset=".4" stop-color="slateblue"/><stop offset="1" stop-color="midnightblue"/></linearGradient>' +
'<radialGradient id="glow"><stop offset="0" stop-color="lightyellow"/><stop offset=".2" stop-color="moccasin" stop-opacity=".9"/><stop offset="1" stop-color="lightsalmon" stop-opacity="0"/></radialGradient></defs>' +
'<rect width="1600" height="700" fill="url(#sky)"/><circle cx="1060" cy="600" r="420" fill="url(#glow)"/><circle cx="1060" cy="600" r="64" fill="lightyellow"/>' +
'<path d="M0 560 180 440 320 520 520 380 700 500 900 410 1100 520 1300 430 1600 540V700H0Z" fill="mediumpurple" opacity=".8"/>' +
'<path d="M0 620 240 520 420 600 640 500 860 610 1080 540 1340 620 1600 560V700H0Z" fill="darkslateblue"/>' +
'<path d="M0 670 300 610 560 660 820 600 1120 670 1400 620 1600 660V700H0Z" fill="midnightblue"/>' +
'<rect y="700" width="1600" height="367" fill="url(#lake)"/>' +
'<path d="M950 714h220v5H950zM975 744h170v5H975zM1000 776h120v4h-120zM1025 808h70v4h-70z" fill="lightyellow" opacity=".6"/>' +
'<path d="M110 480 50 690h120zM110 580 30 820h160zM110 690 10 960h200zM300 620 250 790h100zM300 700 230 920h140zM1480 560 1420 760h120zM1480 660 1400 900h160z" fill="black" opacity=".85"/>' +
'<path d="M0 1067V930c240-50 470 0 640 137zM1600 1067V960c-160-30-300 20-420 107z" fill="black" opacity=".85"/></svg>',
)}`;
const SAMPLE_NAME = "lake-at-dawn.svg";
const NO_ADJUSTMENTS = { brightness: 0, contrast: 0, saturation: 0 };
const ADJUSTMENTS = [
{ key: "brightness", label: "Brightness" },
{ key: "contrast", label: "Contrast" },
{ key: "saturation", label: "Saturation" },
] as const;
const signed = (value: number) => (value > 0 ? `+${String(value)}` : String(value));
const pixels = (crop: CropArea | null) =>
crop ? `${String(Math.round(crop.width))} × ${String(Math.round(crop.height))} px` : "";
function usePhoto() {
const [photo, setPhoto] = useState({ src: SAMPLE_PHOTO, name: SAMPLE_NAME });
const [error, setError] = useState<string | null>(null);
const open = (file: File) => {
if (!file.type.startsWith("image/")) {
setError(`“${file.name}” isn’t an image. Choose a PNG, JPG, WebP or GIF file.`);
return;
}
if (file.size > 20 * 1024 * 1024) {
setError(`“${file.name}” is larger than 20 MB. Choose a smaller image.`);
return;
}
setError(null);
const src = URL.createObjectURL(file);
setPhoto((old) => {
if (old.src.startsWith("blob:")) URL.revokeObjectURL(old.src);
return { src, name: file.name };
});
};
const pickSample = () => {
setError(null);
setPhoto({ src: SAMPLE_PHOTO, name: SAMPLE_NAME });
};
return { photo, error, setError, open, pickSample };
}
interface SavedImage {
url: string;
name: string;
details: string;
}
async function saveImage(
photo: { src: string; name: string },
crop: CropArea,
options: CropImageOptions,
): Promise<SavedImage> {
const blob = await cropImage(photo.src, crop, options);
const extension = blob.type === "image/jpeg" ? "jpg" : (blob.type.split("/")[1] ?? "png");
const kb = Math.max(1, Math.round(blob.size / 1024));
return {
url: URL.createObjectURL(blob),
name: `${photo.name.replace(/\.[^.]+$/, "")}-edited.${extension}`,
details: `${pixels(crop)} · ${String(kb)} KB · ${extension.toUpperCase()}`,
};
}
function SavedImageCard({ image }: { image: SavedImage }) {
return (
<div
role="status"
className="flex flex-wrap items-center gap-3 rounded-lg border border-default bg-surface p-3"
>
<img src={image.url} alt="" className="size-14 rounded-sm bg-sunken object-contain" />
<div className="grid min-w-0 flex-1 gap-0.5">
<p className="m-0 flex items-center gap-1.5 text-body-strong break-all text-primary">
<LuCircleCheck className="size-4 shrink-0 text-status-success-fg" />
Saved {image.name}
</p>
<p className="m-0 text-body-small text-secondary tabular-nums">{image.details}</p>
</div>
<a
href={image.url}
download={image.name}
className="rounded-sm text-body-small text-brand-text underline outline-hidden focus-visible:focus-ring"
>
Download
</a>
</div>
);
}
function FilterTiles({
src,
selected,
onSelect,
className,
}: {
src: string;
selected: string;
onSelect: (id: string) => void;
className: string;
}) {
return (
<div role="group" aria-label="Filter" className={className}>
{IMAGE_FILTERS.map((preset) => (
<button
key={preset.id}
type="button"
aria-pressed={preset.id === selected}
onClick={() => {
onSelect(preset.id);
}}
className="group m-0 grid border-0 bg-transparent p-0 min-w-0 cursor-pointer gap-1.5 rounded-md text-center text-body-caption text-secondary outline-hidden focus-visible:focus-ring aria-pressed:text-primary"
>
<img
src={src}
alt=""
style={{ filter: preset.filter }}
className="aspect-square w-full rounded-md object-cover outline-2 outline-offset-2 outline-transparent group-hover:outline-strong group-aria-pressed:outline-brand-primary"
/>
{preset.label}
</button>
))}
</div>
);
}
const DARKROOM_RATIOS = [
{ id: "free", label: "Free", ratio: null },
{ id: "original", label: "Original", ratio: null },
{ id: "1:1", label: "1:1", ratio: 1 },
{ id: "4:5", label: "4:5", ratio: 4 / 5 },
{ id: "16:9", label: "16:9", ratio: 16 / 9 },
{ id: "9:16", label: "9:16", ratio: 9 / 16 },
] as const;
export function Darkroom() {
const { photo, error, setError, open } = usePhoto();
const [size, setSize] = useState<ImageSize | null>(null);
const [crop, setCrop] = useState<CropArea | null>(null);
const [ratioId, setRatioId] = useState<string>("free");
const [presetId, setPresetId] = useState("original");
const [adjust, setAdjust] = useState(NO_ADJUSTMENTS);
const [tool, setTool] = useState<(typeof ADJUSTMENTS)[number]["key"]>("brightness");
const [saved, setSaved] = useState<SavedImage | null>(null);
const preset = IMAGE_FILTERS.find((p) => p.id === presetId);
const filter = composeImageFilter(preset?.filter ?? "none", adjust);
const ratio =
ratioId === "original" && size
? size.width / size.height
: (DARKROOM_RATIOS.find((r) => r.id === ratioId)?.ratio ?? null);
const toolLabel = ADJUSTMENTS.find((a) => a.key === tool)?.label ?? "";
return (
<div
data-theme="dark"
className="mx-auto max-w-[48.75rem] overflow-hidden rounded-xl border border-default bg-media-bg text-primary shadow-md"
>
<div className="flex items-center gap-3 p-3">
<FileTrigger
acceptedFileTypes={["image/*"]}
onSelect={(files) => {
const file = files?.[0];
if (file) open(file);
}}
>
<IconButton
variant="secondary"
size="md"
icon={<LuCloudUpload />}
aria-label="Upload a new image"
className="rounded-full"
/>
</FileTrigger>
<p className="m-0 grid min-w-0 flex-1 text-center">
<span className="truncate text-body-strong">{photo.name}</span>
<span className="text-body-small text-muted tabular-nums">{pixels(crop)}</span>
</p>
<Button
leadingIcon={<LuCheck />}
className="rounded-full"
isDisabled={!crop}
onPress={() => {
if (crop) void saveImage(photo, crop, { filter, type: "image/jpeg" }).then(setSaved);
}}
>
Save
</Button>
</div>
<ImageCropper
src={photo.src}
aspectRatio={ratio}
filter={filter}
handles="bracket"
grid="always"
className="h-80 bg-media-bg sm:h-[26.25rem]"
onLoad={setSize}
onCropChange={setCrop}
onFileDrop={open}
onError={() => {
setError("This image couldn’t be opened. Try another file.");
}}
/>
{/* The tab bar comes first for React Aria and shows last, under the tray. */}
<Tabs defaultSelectedKey="crop" className="flex flex-col">
<TabList
aria-label="Edit mode"
className="order-last border-t border-default *:flex-1 *:justify-center"
>
<Tab id="crop" label="Crop" icon={<LuCrop />} />
<Tab id="filters" label="Filters" icon={<LuSparkles />} />
<Tab id="adjust" label="Adjust" icon={<LuSlidersHorizontal />} />
</TabList>
<div className="grid min-h-26 items-center px-3.5 pt-3.5 pb-2.5">
<TabPanel id="crop">
<div
role="group"
aria-label="Aspect ratio"
className="flex gap-2 overflow-x-auto p-0.5 [justify-content:safe_center]"
>
{DARKROOM_RATIOS.map((r) => (
<FilterChip
key={r.id}
label={r.label}
isSelected={r.id === ratioId}
onChange={() => {
setRatioId(r.id);
}}
/>
))}
</div>
</TabPanel>
<TabPanel id="filters">
<FilterTiles
src={photo.src}
selected={presetId}
onSelect={setPresetId}
className="flex gap-2.5 overflow-x-auto p-1 [justify-content:safe_center] *:w-16.5 *:flex-none"
/>
</TabPanel>
<TabPanel id="adjust" className="grid gap-3">
<div
role="group"
aria-label="Adjustment"
className="flex gap-2 overflow-x-auto p-0.5 [justify-content:safe_center]"
>
{ADJUSTMENTS.map((a) => (
<FilterChip
key={a.key}
label={`${a.label} ${signed(adjust[a.key])}`}
isSelected={a.key === tool}
onChange={() => {
setTool(a.key);
}}
/>
))}
</div>
<Slider
label={toolLabel}
minValue={-50}
maxValue={50}
value={adjust[tool]}
onChange={(next: number) => {
setAdjust({ ...adjust, [tool]: next });
}}
className="mx-auto w-full max-w-md"
/>
</TabPanel>
</div>
</Tabs>
{error || saved ? (
<div className="grid gap-3 px-3.5 pb-3.5">
{error ? <Alert tone="danger" title={error} /> : null}
{saved ? <SavedImageCard image={saved} /> : null}
</div>
) : null}
</div>
);
}
Guided
Sample C, Guided: a compact card in three steps (Step). Upload with a Dropzone, crop in a circle or square frame with SegmentedControls, then finish with a preview, filters, adjustments and the file format, and save. It opens on the crop step with the sample photo; Back returns to the upload step.
import { useEffect, useState } from "react";
import { type Key } from "react-aria-components";
import {
Alert,
Button,
Dropzone,
IMAGE_FILTERS,
ImageCropper,
Segment,
SegmentedControl,
Select,
SelectOption,
Slider,
Step,
composeImageFilter,
cropImage,
} from "@nexera-ui/react";
import {
LuArrowLeft,
LuArrowRight,
LuCircle,
LuCircleCheck,
LuDownload,
LuImage,
LuSquare,
} from "react-icons/lu";
const SAMPLE_PHOTO = `data:image/svg+xml,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1067" viewBox="0 0 1600 1067"><defs>' +
'<linearGradient id="sky" x2="0" y2="1"><stop offset="0" stop-color="midnightblue"/><stop offset=".5" stop-color="mediumpurple"/><stop offset=".85" stop-color="lightsalmon"/><stop offset="1" stop-color="peachpuff"/></linearGradient>' +
'<linearGradient id="lake" x2="0" y2="1"><stop offset="0" stop-color="darksalmon"/><stop offset=".4" stop-color="slateblue"/><stop offset="1" stop-color="midnightblue"/></linearGradient>' +
'<radialGradient id="glow"><stop offset="0" stop-color="lightyellow"/><stop offset=".2" stop-color="moccasin" stop-opacity=".9"/><stop offset="1" stop-color="lightsalmon" stop-opacity="0"/></radialGradient></defs>' +
'<rect width="1600" height="700" fill="url(#sky)"/><circle cx="1060" cy="600" r="420" fill="url(#glow)"/><circle cx="1060" cy="600" r="64" fill="lightyellow"/>' +
'<path d="M0 560 180 440 320 520 520 380 700 500 900 410 1100 520 1300 430 1600 540V700H0Z" fill="mediumpurple" opacity=".8"/>' +
'<path d="M0 620 240 520 420 600 640 500 860 610 1080 540 1340 620 1600 560V700H0Z" fill="darkslateblue"/>' +
'<path d="M0 670 300 610 560 660 820 600 1120 670 1400 620 1600 660V700H0Z" fill="midnightblue"/>' +
'<rect y="700" width="1600" height="367" fill="url(#lake)"/>' +
'<path d="M950 714h220v5H950zM975 744h170v5H975zM1000 776h120v4h-120zM1025 808h70v4h-70z" fill="lightyellow" opacity=".6"/>' +
'<path d="M110 480 50 690h120zM110 580 30 820h160zM110 690 10 960h200zM300 620 250 790h100zM300 700 230 920h140zM1480 560 1420 760h120zM1480 660 1400 900h160z" fill="black" opacity=".85"/>' +
'<path d="M0 1067V930c240-50 470 0 640 137zM1600 1067V960c-160-30-300 20-420 107z" fill="black" opacity=".85"/></svg>',
)}`;
const SAMPLE_NAME = "lake-at-dawn.svg";
const FORMATS = [
{ id: "image/png", label: "PNG" },
{ id: "image/jpeg", label: "JPEG" },
{ id: "image/webp", label: "WebP" },
] as const;
const NO_ADJUSTMENTS = { brightness: 0, contrast: 0, saturation: 0 };
const ADJUSTMENTS = [
{ key: "brightness", label: "Brightness" },
{ key: "contrast", label: "Contrast" },
{ key: "saturation", label: "Saturation" },
] as const;
const pixels = (crop: CropArea | null) =>
crop ? `${String(Math.round(crop.width))} × ${String(Math.round(crop.height))} px` : "";
function usePhoto() {
const [photo, setPhoto] = useState({ src: SAMPLE_PHOTO, name: SAMPLE_NAME });
const [error, setError] = useState<string | null>(null);
const open = (file: File) => {
if (!file.type.startsWith("image/")) {
setError(`“${file.name}” isn’t an image. Choose a PNG, JPG, WebP or GIF file.`);
return;
}
if (file.size > 20 * 1024 * 1024) {
setError(`“${file.name}” is larger than 20 MB. Choose a smaller image.`);
return;
}
setError(null);
const src = URL.createObjectURL(file);
setPhoto((old) => {
if (old.src.startsWith("blob:")) URL.revokeObjectURL(old.src);
return { src, name: file.name };
});
};
const pickSample = () => {
setError(null);
setPhoto({ src: SAMPLE_PHOTO, name: SAMPLE_NAME });
};
return { photo, error, setError, open, pickSample };
}
interface SavedImage {
url: string;
name: string;
details: string;
}
async function saveImage(
photo: { src: string; name: string },
crop: CropArea,
options: CropImageOptions,
): Promise<SavedImage> {
const blob = await cropImage(photo.src, crop, options);
const extension = blob.type === "image/jpeg" ? "jpg" : (blob.type.split("/")[1] ?? "png");
const kb = Math.max(1, Math.round(blob.size / 1024));
return {
url: URL.createObjectURL(blob),
name: `${photo.name.replace(/\.[^.]+$/, "")}-edited.${extension}`,
details: `${pixels(crop)} · ${String(kb)} KB · ${extension.toUpperCase()}`,
};
}
function SavedImageCard({ image }: { image: SavedImage }) {
return (
<div
role="status"
className="flex flex-wrap items-center gap-3 rounded-lg border border-default bg-surface p-3"
>
<img src={image.url} alt="" className="size-14 rounded-sm bg-sunken object-contain" />
<div className="grid min-w-0 flex-1 gap-0.5">
<p className="m-0 flex items-center gap-1.5 text-body-strong break-all text-primary">
<LuCircleCheck className="size-4 shrink-0 text-status-success-fg" />
Saved {image.name}
</p>
<p className="m-0 text-body-small text-secondary tabular-nums">{image.details}</p>
</div>
<a
href={image.url}
download={image.name}
className="rounded-sm text-body-small text-brand-text underline outline-hidden focus-visible:focus-ring"
>
Download
</a>
</div>
);
}
function FilterTiles({
src,
selected,
onSelect,
className,
}: {
src: string;
selected: string;
onSelect: (id: string) => void;
className: string;
}) {
return (
<div role="group" aria-label="Filter" className={className}>
{IMAGE_FILTERS.map((preset) => (
<button
key={preset.id}
type="button"
aria-pressed={preset.id === selected}
onClick={() => {
onSelect(preset.id);
}}
className="group m-0 grid border-0 bg-transparent p-0 min-w-0 cursor-pointer gap-1.5 rounded-md text-center text-body-caption text-secondary outline-hidden focus-visible:focus-ring aria-pressed:text-primary"
>
<img
src={src}
alt=""
style={{ filter: preset.filter }}
className="aspect-square w-full rounded-md object-cover outline-2 outline-offset-2 outline-transparent group-hover:outline-strong group-aria-pressed:outline-brand-primary"
/>
{preset.label}
</button>
))}
</div>
);
}
function AdjustmentSliders({
value,
onChange,
}: {
value: Required<ImageAdjustments>;
onChange: (value: Required<ImageAdjustments>) => void;
}) {
return (
<div className="grid gap-3">
{ADJUSTMENTS.map(({ key, label }) => (
<Slider
key={key}
label={label}
minValue={-50}
maxValue={50}
value={value[key]}
onChange={(next: number) => {
onChange({ ...value, [key]: next });
}}
/>
))}
</div>
);
}
const GUIDED_RATIOS = [
{ id: "free", label: "Free", ratio: null },
{ id: "1:1", label: "1:1", ratio: 1 },
{ id: "4:5", label: "4:5", ratio: 4 / 5 },
{ id: "16:9", label: "16:9", ratio: 16 / 9 },
] as const;
const STEPS = ["Upload", "Crop", "Finish"] as const;
export function Guided() {
const { photo, error, setError, open, pickSample } = usePhoto();
const [step, setStep] = useState(2);
const [crop, setCrop] = useState<CropArea | null>(null);
const [ratioId, setRatioId] = useState<string>("1:1");
const [shape, setShape] = useState<"rect" | "circle">("circle");
const [presetId, setPresetId] = useState("original");
const [adjust, setAdjust] = useState(NO_ADJUSTMENTS);
const [format, setFormat] = useState<Key>("image/png");
const [preview, setPreview] = useState<string | null>(null);
const [saved, setSaved] = useState<SavedImage | null>(null);
const preset = IMAGE_FILTERS.find((p) => p.id === presetId);
const filter = composeImageFilter(preset?.filter ?? "none", adjust);
const ratio = GUIDED_RATIOS.find((r) => r.id === ratioId)?.ratio ?? null;
// The finish step previews the crop at up to 480 px; the filter is applied live with CSS.
useEffect(() => {
if (step !== 3 || !crop) return;
let url = "";
void cropImage(photo.src, crop, { shape, maxSize: 480 }).then((blob) => {
url = URL.createObjectURL(blob);
setPreview(url);
});
return () => {
if (url) URL.revokeObjectURL(url);
};
}, [step, crop, shape, photo.src]);
const go = (next: number) => {
setStep(next);
setSaved(null);
setError(null);
};
const picked = (file: File) => {
setCrop(null);
open(file);
go(2);
};
return (
<div className="mx-auto grid max-w-[38.75rem] gap-5 rounded-xl border border-default bg-surface p-5 shadow-md">
<div className="grid gap-0.5">
<h3 className="m-0 text-heading-h4 text-primary">Profile photo</h3>
<p className="m-0 text-body-small text-muted">
Shown on your profile and next to your comments.
</p>
</div>
<ol aria-label="Photo steps" className="m-0 flex list-none p-0">
{STEPS.map((label, i) => (
<Step
key={label}
label={label}
status={i + 1 < step ? "complete" : i + 1 === step ? "current" : "upcoming"}
/>
))}
</ol>
{step === 1 ? (
<div className="grid gap-3">
<Dropzone
title="Drag a photo here"
description="PNG, JPG, WebP or GIF, up to 20 MB"
acceptedFileTypes={["image/png", "image/jpeg", "image/webp", "image/gif"]}
onSelect={([file]) => {
if (file) picked(file);
}}
onReject={([file]) => {
if (file) open(file);
}}
/>
<Button
variant="ghost"
size="sm"
leadingIcon={<LuImage />}
className="justify-self-center"
onPress={() => {
setCrop(null);
pickSample();
go(2);
}}
>
Use sample photo
</Button>
</div>
) : null}
{step === 2 ? (
<div className="grid gap-3.5">
<div className="flex flex-wrap justify-between gap-2.5">
<SegmentedControl
aria-label="Aspect ratio"
selectedKeys={[ratioId]}
onSelectionChange={(keys) => {
const id = String([...keys][0] ?? ratioId);
setRatioId(id);
if (id !== "1:1") setShape("rect");
}}
>
{GUIDED_RATIOS.map((r) => (
<Segment key={r.id} id={r.id} label={r.label} />
))}
</SegmentedControl>
<SegmentedControl
aria-label="Frame shape"
selectedKeys={[shape]}
onSelectionChange={(keys) => {
const next = [...keys][0] === "circle" ? "circle" : "rect";
setShape(next);
if (next === "circle") setRatioId("1:1");
}}
>
<Segment id="rect" label="Square" icon={<LuSquare />} />
<Segment id="circle" label="Circle" icon={<LuCircle />} />
</SegmentedControl>
</div>
<ImageCropper
src={photo.src}
aspectRatio={ratio}
shape={shape}
handles="dot"
className="h-70 rounded-lg bg-media-bg sm:h-[21.25rem]"
{...(crop ? { defaultCrop: crop } : {})}
onCropChange={setCrop}
onError={() => {
setError("This image couldn’t be opened. Try another file.");
}}
/>
<p className="m-0 text-body-small text-muted">
Drag the frame to move it, drag a handle to resize. Arrow keys nudge the frame.
</p>
</div>
) : null}
{step === 3 ? (
<div className="grid gap-5 sm:grid-cols-[15rem_minmax(0,1fr)] sm:items-start">
<div className="mx-auto grid aspect-square w-full max-w-60 place-items-center overflow-hidden rounded-lg bg-sunken">
{preview ? (
<img
src={preview}
alt="Cropped result"
style={{ filter }}
className={`max-h-[88%] max-w-[88%] ${shape === "circle" ? "rounded-full" : ""}`}
/>
) : null}
</div>
<div className="grid min-w-0 gap-4">
{preview ? (
<FilterTiles
src={preview}
selected={presetId}
onSelect={setPresetId}
className="grid grid-cols-4 gap-x-2 gap-y-2.5"
/>
) : null}
<AdjustmentSliders value={adjust} onChange={setAdjust} />
<Select
label="File format"
size="sm"
value={format}
onChange={(key) => {
if (key !== null) setFormat(key);
}}
>
{FORMATS.map((f) => (
<SelectOption key={f.id} id={f.id} label={f.label} />
))}
</Select>
</div>
</div>
) : null}
{error ? <Alert tone="danger" title={error} /> : null}
{saved ? <SavedImageCard image={saved} /> : null}
<div className="flex items-center gap-3 border-t border-default pt-4">
{step > 1 ? (
<Button
variant="secondary"
size="sm"
leadingIcon={<LuArrowLeft />}
onPress={() => {
go(step - 1);
}}
>
Back
</Button>
) : null}
<p className="m-0 min-w-0 flex-1 text-right font-mono text-body-small text-muted tabular-nums">
{step > 1 ? pixels(crop) : ""}
</p>
{step < 3 ? (
<Button
size="sm"
trailingIcon={<LuArrowRight />}
isDisabled={step === 2 && !crop}
onPress={() => {
go(step + 1);
}}
>
Next
</Button>
) : (
<Button
size="sm"
leadingIcon={<LuDownload />}
isDisabled={!crop}
onPress={() => {
if (crop)
void saveImage(photo, crop, { filter, shape, type: String(format) }).then(setSaved);
}}
>
Save photo
</Button>
)}
</div>
</div>
);
}
Props 18
Press "Try it" on a card to load that prop into the playground.
18 props shown
src*Nexerastring | null | undefinedThe image: an object URL from a picked file (URL.createObjectURL(file)), a data: URL, or a same-origin URL. Images from
other origins need CORS headers, or cropImage cannot export them. null shows fallback.
altNexerastringText alternative of the image. Leave empty when the surrounding UI already names it (a file name, a heading).
aspectRatioNexeranumber | nullWidth divided by height of the frame, for example 1, 16 / 9 or 4 / 5. null lets people drag each edge freely.
Changing it re-fits the frame. Only corner handles show while a ratio is set.
shapeNexera"rect" | "circle"Frame shape. "circle" shows a round frame for avatars; pair it with aspectRatio={1} and pass the same shape to
cropImage.
handlesNexera"square" | "bracket" | "dot"Look of the resize handles: square (Studio sample), bracket (Darkroom, for dark stages) or dot (Guided).
gridNexera"dragging" | "always" | "never"When the rule-of-thirds grid shows inside the frame.
filterNexerastringCSS filter previewed on the image, for example a preset from IMAGE_FILTERS combined with composeImageFilter. Pass
the same value to cropImage so the saved file matches.
defaultCropNexeraCropAreaStarting frame in natural image pixels, for example the crop saved last time. Otherwise the frame is fitted and centred.
onCropChangeNexera(crop: CropArea) => voidCalled with the frame in natural image pixels after every move, resize, ratio change or new image.
onLoadNexera(size: ImageSize) => voidCalled with the natural size once the image has loaded.
onErrorNexera() => voidCalled when the image cannot be loaded or decoded. Show an error and let people pick another file.
onFileDropNexera(file: File) => voidCalled with the first file dropped on the stage; also turns the stage into a drop target. Check the file type and size yourself. Dropping is an enhancement: always offer a button that opens the file picker too (WCAG 2.5.7).
labelNexerastringAccessible name of the crop frame. Translate it.
instructionsNexerastringHow to use the frame without a pointer, read after its name. Translate it.
dropLabelNexerastringMessage over the stage while a file is dragged over it (onFileDrop). Translate it.
fallbackNexeraReactNodeContent shown while there is no src, such as an empty state with an upload button.
classNameNexerastringExtra classes for the root (the stage), merged last. Set the height here (default h-80); bg-media-bg makes it dark.
styleNexeraCSSPropertiesInline style of the root.
* Required. React Aria props shown are the ones most apps use; the component accepts the rest of its React Aria props too.
Accessible by default.
Built on React Aria, and covered by the WCAG 2.2 evidence generated on every build.
| Key | Action |
|---|---|
| Tab | Moves focus to the crop frame (one tab stop) |
| Arrow keys | Move the frame one screen pixel |
| Alt + arrow keys | Resize the frame from its bottom-right corner; with a ratio, both sides follow |
| Shift | Makes each step 10 pixels, for moving or resizing |
- The frame is a focusable group named by `label` and described by `instructions`, so screen reader users hear how to move and resize it. Translate both.
- Everything a drag does can be done from the keyboard (WCAG 2.1.1, 2.5.7). Handles have a 36 px hit area.
- Dropping a file on the stage is an extra. Always offer a button that opens the file picker too (FileTrigger or Dropzone).
- The crop is reported in natural image pixels, so it does not depend on the size the image is shown at.
- cropImage bakes in the same filter the preview shows, also in Safari, where it applies the colour maths to the pixels.
This component relies on the shared primitives' accessibility tests; no criteria are tagged to it directly yet.
Fits any width.
Nexera components respond to the space they are given. Drag the corner of the frame, or pick a width.
Styling hooks
Pass className to add Tailwind classes (merged last). State is exposed as data attributes, so you can style it with variants like data-pressed:.
<ImageCropper className="shadow-sm" />Do and don't
DoShow the output size and let people pick the format with a Select.
Don'tHide the result's size or save at screen resolution.
DoUse the circle frame with aspectRatio 1 for avatars, and pass the same shape to cropImage.
Don'tCrop a circle on screen but save a square with corners people never saw.
DoCheck the file type and size before showing it, and say how to fix a bad file.
Don'tPass any dropped file straight to src.
Used in blocks
Related components
- CarouselA carousel: slides in a scroll-snap strip with Previous / Next, indicators and, when you opt in, automatic rotation with a visible pause button.
- CarouselControlsPrevious / Next buttons for a carousel, and optionally a button that stops and starts automatic rotation.
- CarouselIndicatorShows which slide of a carousel is current, and lets people jump to a slide.
- ImageA picture in a rounded frame with a fixed aspect ratio: Figma `State=Loading` (a `bg/sunken` frame with a shimmer), `Loaded` and `Error` ("Image unavailable") follow the load lifecycle of the `<img>`; they are not props.
- ImageTileA square picture in a gallery or picker, with overlay actions on hover and a selected state.
- VideoControlsThe control bar of a video: scrubber, play / pause, back, mute and volume, time, captions, settings, picture in picture and full screen, over a dark gradient.
- VideoPlayerA video with a poster, Nexera controls and captions.