Photo editor
A full-bleed dark editor with crop, filter and adjust tools in a bottom tray.
Built with
- Alert
- Button
- FileTrigger
- FilterChip
- IMAGE_FILTERS
- IconButton
- ImageCropper
- Slider
- Tab
- TabList
- TabPanel
- Tabs
Post to #lahore-office
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.
photo-editor.tsx
"use client";
import { useState } from "react";
import { LuCheck, LuCrop, LuSlidersHorizontal, LuSparkles, LuUpload } from "react-icons/lu";
import {
Alert,
Button,
FileTrigger,
FilterChip,
IMAGE_FILTERS,
IconButton,
ImageCropper,
Slider,
Tab,
TabList,
TabPanel,
Tabs,
composeImageFilter,
cropImage,
type CropArea,
} from "@nexera-ui/react";
/** Team lunch on the office roof, 1600 × 1067. Replace it with the photo people pick. */
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="steelblue"/><stop offset="1" stop-color="lightblue"/></linearGradient></defs>' +
'<rect width="1600" height="1067" fill="url(#s)"/><circle cx="1300" cy="220" r="90" fill="lightyellow"/>' +
'<path d="M0 640h260V420h160v220h120V300h200v340h180V460h140v180h200V380h180v260h160v427H0Z" fill="slategray"/>' +
'<rect y="760" width="1600" height="307" fill="sienna"/><rect x="420" y="700" width="760" height="40" rx="20" fill="peru"/>' +
'<circle cx="560" cy="640" r="44" fill="goldenrod"/><circle cx="800" cy="630" r="44" fill="indianred"/><circle cx="1040" cy="640" r="44" fill="seagreen"/></svg>',
)}`;
const RATIOS = [
{ id: "free", label: "Free", ratio: null },
{ id: "square", label: "1:1", ratio: 1 },
{ id: "portrait", label: "4:5", ratio: 4 / 5 },
{ id: "story", label: "9:16", ratio: 9 / 16 },
{ id: "wide", label: "16:9", ratio: 16 / 9 },
] as const;
const TOOLS = [
{ key: "brightness", label: "Brightness" },
{ key: "contrast", label: "Contrast" },
{ key: "saturation", label: "Saturation" },
] as const;
const signed = (n: number) => (n > 0 ? `+${String(n)}` : String(n));
/** Photo editor: a full-bleed dark editor for phones, with crop, filter and adjust tools in a bottom tray. */
export default function PhotoEditor() {
const [photo, setPhoto] = useState({ src: PHOTO, name: "team-lunch.svg" });
const [crop, setCrop] = useState<CropArea | null>(null);
const [ratioId, setRatioId] = useState("portrait");
const [presetId, setPresetId] = useState("warm");
const [adjust, setAdjust] = useState({ brightness: 0, contrast: 0, saturation: 0 });
const [tool, setTool] = useState<(typeof TOOLS)[number]["key"]>("brightness");
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState<string | null>(null);
const preset = IMAGE_FILTERS.find((p) => p.id === presetId);
const filter = composeImageFilter(preset?.filter ?? "none", adjust);
const open = (file: File | undefined) => {
if (!file) return;
if (!file.type.startsWith("image/")) {
setError(`“${file.name}” isn’t an image. Choose a photo.`);
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: "image/jpeg", maxSize: 2048 });
setSaved(`Photo saved · ${String(Math.max(1, Math.round(blob.size / 1024)))} KB JPEG`);
};
return (
<div className="@container bg-page px-4 py-8 @3xl:px-8">
<section
aria-labelledby="photo-editor-title"
data-theme="dark"
className="mx-auto grid max-w-md overflow-hidden rounded-xl border border-default bg-media-bg text-primary shadow-lg"
>
<header className="flex items-center gap-3 p-3">
<FileTrigger
acceptedFileTypes={["image/*"]}
onSelect={(files) => {
open(files?.[0]);
}}
>
<IconButton
variant="secondary"
icon={<LuUpload />}
aria-label="Choose another photo"
className="rounded-full"
/>
</FileTrigger>
<div className="grid min-w-0 flex-1 text-center">
<h2 id="photo-editor-title" className="truncate text-body-strong">
Post to #lahore-office
</h2>
<p className="text-body-small text-muted tabular-nums">
{crop ? `${String(Math.round(crop.width))} × ${String(Math.round(crop.height))}` : ""}
</p>
</div>
<Button
leadingIcon={<LuCheck />}
className="rounded-full"
isDisabled={!crop}
onPress={() => void save()}
>
Done
</Button>
</header>
<ImageCropper
src={photo.src}
alt="Team lunch on the office roof"
aspectRatio={RATIOS.find((r) => r.id === ratioId)?.ratio ?? null}
filter={filter}
handles="bracket"
grid="always"
className="h-96 bg-media-bg"
onCropChange={setCrop}
onFileDrop={open}
onError={() => {
setError("This photo couldn’t be opened. Try another one.");
}}
/>
{/* The tab bar comes first for React Aria and shows last, under the tray. */}
<Tabs defaultSelectedKey="filters" 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-28 items-center px-3 pt-3 pb-2">
<TabPanel id="crop">
<div
role="group"
aria-label="Aspect ratio"
className="flex gap-2 overflow-x-auto p-0.5"
>
{RATIOS.map((r) => (
<FilterChip
key={r.id}
label={r.label}
isSelected={r.id === ratioId}
onChange={() => {
setRatioId(r.id);
}}
/>
))}
</div>
</TabPanel>
<TabPanel id="filters">
<div role="group" aria-label="Filter" className="flex gap-2.5 overflow-x-auto p-1">
{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 w-16 flex-none gap-1 rounded-md text-body-caption text-muted 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">
<div
role="group"
aria-label="Adjustment"
className="flex gap-2 overflow-x-auto p-0.5"
>
{TOOLS.map((t) => (
<FilterChip
key={t.key}
label={`${t.label} ${signed(adjust[t.key])}`}
isSelected={t.key === tool}
onChange={() => {
setTool(t.key);
}}
/>
))}
</div>
<Slider
label={TOOLS.find((t) => t.key === tool)?.label}
minValue={-50}
maxValue={50}
value={adjust[tool]}
onChange={(value: number) => {
setAdjust({ ...adjust, [tool]: value });
}}
/>
</TabPanel>
</div>
</Tabs>
{error ? <Alert tone="danger" title={error} className="m-3" /> : null}
{saved ? <Alert tone="success" title={saved} className="m-3" /> : null}
</section>
</div>
);
}