Image editor
Crop, filter and export a cover image beside an inspector.
Built with
- Alert
- Button
- FileTrigger
- IMAGE_FILTERS
- ImageCropper
- Select
- SelectOption
- Slider
- Tab
- TabList
- TabPanel
- Tabs
Cover image
Q4 town hall · Announcement draft
town-hall-cover.svg
Drag to move it, or drag a handle to resize. Arrow keys move it, Alt with arrow keys resizes it, and Shift makes bigger steps.
Aspect ratio
The banner shows at 1500 × 500 px on the intranet home page.
Output
image-editor.tsx
"use client";
import { useState } from "react";
import {
LuCircleCheck,
LuCrop,
LuDownload,
LuRotateCcw,
LuSlidersHorizontal,
LuSparkles,
LuUpload,
} from "react-icons/lu";
import {
Alert,
Button,
FileTrigger,
IMAGE_FILTERS,
ImageCropper,
Select,
SelectOption,
Slider,
Tab,
TabList,
TabPanel,
Tabs,
composeImageFilter,
cropImage,
type CropArea,
} from "@nexera-ui/react";
/** A lake at dawn, 1600 × 1067. Replace it with the image your people upload. */
const PHOTO = `data:image/svg+xml,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1067"><defs><linearGradient id="s" x2="0" y2="1"><stop offset="0" stop-color="midnightblue"/><stop offset=".5" stop-color="mediumpurple"/><stop offset="1" stop-color="peachpuff"/></linearGradient></defs>' +
'<rect width="1600" height="700" fill="url(#s)"/><circle cx="1060" cy="600" r="70" fill="lightyellow"/>' +
'<path d="M0 600 240 500 420 590 640 480 860 600 1080 520 1340 610 1600 540V700H0Z" fill="darkslateblue"/>' +
'<rect y="700" width="1600" height="367" fill="slateblue"/><path d="M0 1067V930c240-50 470 0 640 137z" fill="midnightblue"/></svg>',
)}`;
const RATIOS = [
{ id: "free", label: "Free", ratio: null },
{ id: "banner", label: "3:1 banner", ratio: 3 },
{ id: "wide", label: "16:9", ratio: 16 / 9 },
{ id: "square", label: "1:1", ratio: 1 },
] as const;
const ADJUST = [
{ key: "brightness", label: "Brightness" },
{ key: "contrast", label: "Contrast" },
{ key: "saturation", label: "Saturation" },
] as const;
const NONE = { brightness: 0, contrast: 0, saturation: 0 };
/** Image editor: crop and filter the cover image of a company announcement, then export it. */
export default function ImageEditor() {
const [photo, setPhoto] = useState({ src: PHOTO, name: "town-hall-cover.svg" });
const [crop, setCrop] = useState<CropArea | null>(null);
const [ratioId, setRatioId] = useState("banner");
const [presetId, setPresetId] = useState("original");
const [adjust, setAdjust] = useState(NONE);
const [format, setFormat] = useState<string>("image/jpeg");
const [resets, setResets] = useState(0);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState<{ url: string; name: string; info: string } | null>(null);
const preset = IMAGE_FILTERS.find((p) => p.id === presetId);
const filter = composeImageFilter(preset?.filter ?? "none", adjust);
const size = crop ? `${Math.round(crop.width)} × ${Math.round(crop.height)} px` : "";
const open = (file: File | undefined) => {
if (!file) return;
if (!file.type.startsWith("image/")) {
setError(`“${file.name}” isn’t an image. Choose a PNG, JPG or WebP file.`);
return;
}
setError(null);
setSaved(null);
setPhoto({ src: URL.createObjectURL(file), name: file.name });
};
const save = async () => {
if (!crop) return;
const blob = await cropImage(photo.src, crop, { filter, type: format });
const ext = blob.type === "image/jpeg" ? "jpg" : (blob.type.split("/")[1] ?? "png");
const kb = Math.max(1, Math.round(blob.size / 1024));
setSaved({
url: URL.createObjectURL(blob),
name: `${photo.name.replace(/\.[^.]+$/, "")}-cover.${ext}`,
info: `${size} · ${String(kb)} KB`,
});
};
return (
<div className="@container bg-page px-4 py-8 @3xl:px-8">
<section aria-labelledby="editor-title" className="mx-auto grid max-w-5xl gap-4">
<header className="grid gap-1">
<h2 id="editor-title" className="text-heading-h3 text-primary">
Cover image
</h2>
<p className="text-body-small text-secondary">Q4 town hall · Announcement draft</p>
</header>
<div className="overflow-hidden rounded-xl border border-default bg-surface shadow-sm">
<div className="flex items-center gap-3 border-b border-default p-3">
<FileTrigger
acceptedFileTypes={["image/*"]}
onSelect={(files) => {
open(files?.[0]);
}}
>
<Button variant="secondary" size="sm" leadingIcon={<LuUpload />}>
Upload
</Button>
</FileTrigger>
<p className="min-w-0 flex-1 truncate text-body-strong text-primary">{photo.name}</p>
<Button
variant="ghost"
size="sm"
leadingIcon={<LuRotateCcw />}
onPress={() => {
setPresetId("original");
setAdjust(NONE);
setResets((n) => n + 1);
}}
>
Reset
</Button>
</div>
<div className="grid @3xl:grid-cols-[minmax(0,1fr)_18rem]">
<ImageCropper
key={resets}
src={photo.src}
aspectRatio={RATIOS.find((r) => r.id === ratioId)?.ratio ?? null}
filter={filter}
className="h-72 @3xl:h-[26rem]"
onCropChange={setCrop}
onFileDrop={open}
onError={() => {
setError("This image couldn’t be opened. Try another file.");
}}
/>
<Tabs
defaultSelectedKey="crop"
className="border-t border-default p-4 @3xl:border-t-0 @3xl:border-l"
>
<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-2 pt-4">
<p className="text-component-label text-muted">Aspect ratio</p>
<div role="group" aria-label="Aspect ratio" className="grid grid-cols-2 gap-2">
{RATIOS.map((r) => (
<Button
key={r.id}
size="sm"
variant={r.id === ratioId ? "primary" : "secondary"}
aria-pressed={r.id === ratioId}
onPress={() => {
setRatioId(r.id);
}}
>
{r.label}
</Button>
))}
</div>
<p className="text-body-small text-muted">
The banner shows at 1500 × 500 px on the intranet home page.
</p>
</TabPanel>
<TabPanel id="filters" className="pt-4">
<div
role="group"
aria-label="Filter"
className="grid grid-cols-4 gap-2 @3xl:grid-cols-3"
>
{IMAGE_FILTERS.map((p) => (
<button
key={p.id}
type="button"
aria-pressed={p.id === presetId}
onClick={() => {
setPresetId(p.id);
}}
className="group m-0 grid border-0 bg-transparent p-0 gap-1 rounded-md text-body-caption text-secondary outline-hidden focus-visible:focus-ring aria-pressed:text-primary"
>
<img
src={photo.src}
alt=""
style={{ filter: p.filter }}
className="aspect-square w-full rounded-md object-cover outline-2 outline-offset-2 outline-transparent group-aria-pressed:outline-brand-primary"
/>
{p.label}
</button>
))}
</div>
</TabPanel>
<TabPanel id="adjust" className="grid gap-3 pt-4">
{ADJUST.map(({ key, label }) => (
<Slider
key={key}
label={label}
minValue={-50}
maxValue={50}
value={adjust[key]}
onChange={(value: number) => {
setAdjust({ ...adjust, [key]: value });
}}
/>
))}
</TabPanel>
</Tabs>
</div>
<div className="flex flex-wrap items-center gap-3 border-t border-default p-3">
<p className="min-w-0 flex-1 text-body-small text-secondary tabular-nums">
Output {size}
</p>
<Select
aria-label="File format"
size="sm"
className="w-28"
value={format}
onChange={(key) => {
if (key !== null) setFormat(String(key));
}}
>
<SelectOption id="image/jpeg" label="JPEG" />
<SelectOption id="image/png" label="PNG" />
<SelectOption id="image/webp" label="WebP" />
</Select>
<Button
size="sm"
leadingIcon={<LuDownload />}
isDisabled={!crop}
onPress={() => void save()}
>
Save image
</Button>
</div>
</div>
{error ? <Alert tone="danger" title={error} /> : null}
{saved ? (
<div
role="status"
className="flex flex-wrap items-center gap-3 rounded-lg border border-default bg-surface p-3"
>
<LuCircleCheck aria-hidden className="size-5 text-status-success-fg" />
<p className="min-w-0 flex-1 text-body-default text-primary">
Saved {saved.name} <span className="text-secondary tabular-nums">· {saved.info}</span>
</p>
<a
href={saved.url}
download={saved.name}
className="text-body-small text-brand-text underline"
>
Download
</a>
</div>
) : null}
</section>
</div>
);
}