Profile photo
Upload, crop to a circle and save a profile photo in three steps.
Built with
Profile photo
Shown on your profile, in the org chart and next to your comments.
- UploadDone
- CropIn progress
- FinishUp next
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.
profile-photo.tsx
"use client";
import { useEffect, useState } from "react";
import { LuArrowLeft, LuArrowRight, LuCircle, LuImage, LuSquare } from "react-icons/lu";
import {
Alert,
Button,
Dropzone,
IMAGE_FILTERS,
ImageCropper,
Segment,
SegmentedControl,
Select,
SelectOption,
Step,
composeImageFilter,
cropImage,
type CropArea,
} from "@nexera-ui/react";
/** A portrait on a teal wall, 1200 × 1200. Replace it with the photo people upload. */
const PHOTO = `data:image/svg+xml,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200"><rect width="1200" height="1200" fill="cadetblue"/>' +
'<circle cx="600" cy="500" r="190" fill="burlywood"/><path d="M380 470c0-170 440-170 440 0-60-90-380-90-440 0z" fill="saddlebrown"/>' +
'<path d="M260 1200c0-260 160-400 340-400s340 140 340 400z" fill="darkslateblue"/></svg>',
)}`;
const STEPS = ["Upload", "Crop", "Finish"] as const;
const RATIOS = [
{ id: "free", label: "Free", ratio: null },
{ id: "1:1", label: "1:1", ratio: 1 },
{ id: "4:5", label: "4:5", ratio: 4 / 5 },
] as const;
/** Profile photo: upload a photo, crop it to a circle and save it, in three steps. */
export default function ProfilePhoto() {
const [step, setStep] = useState(2);
const [photo, setPhoto] = useState(PHOTO);
const [crop, setCrop] = useState<CropArea | null>(null);
const [ratioId, setRatioId] = useState("1:1");
const [shape, setShape] = useState<"rect" | "circle">("circle");
const [presetId, setPresetId] = useState("original");
const [format, setFormat] = useState<string>("image/png");
const [preview, setPreview] = useState<string | null>(null);
const [message, setMessage] = useState<{ tone: "danger" | "success"; text: string } | null>(null);
const filter = composeImageFilter(IMAGE_FILTERS.find((p) => p.id === presetId)?.filter ?? "none");
// The finish step shows the crop at up to 320 px; the filter is previewed with CSS.
useEffect(() => {
if (step !== 3 || !crop) return;
let url = "";
void cropImage(photo, crop, { shape, maxSize: 320 }).then((blob) => {
url = URL.createObjectURL(blob);
setPreview(url);
});
return () => {
if (url) URL.revokeObjectURL(url);
};
}, [step, crop, shape, photo]);
const go = (next: number) => {
setStep(next);
setMessage(null);
};
const use = (src: string) => {
setPhoto(src);
setCrop(null);
go(2);
};
const save = async () => {
if (!crop) return;
const blob = await cropImage(photo, crop, { filter, shape, type: format });
setMessage({
tone: "success",
text: `Profile photo updated (${String(Math.round(blob.size / 1024))} KB).`,
});
};
return (
<div className="@container bg-page px-4 py-8 @3xl:px-8">
<section
aria-labelledby="photo-title"
className="mx-auto grid max-w-xl gap-5 rounded-xl border border-default bg-surface p-5 shadow-sm"
>
<header className="grid gap-1">
<h2 id="photo-title" className="text-heading-h4 text-primary">
Profile photo
</h2>
<p className="text-body-small text-secondary">
Shown on your profile, in the org chart and next to your comments.
</p>
</header>
<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 or WebP, up to 10 MB"
acceptedFileTypes={["image/png", "image/jpeg", "image/webp"]}
onSelect={([file]) => {
if (file) use(URL.createObjectURL(file));
}}
onReject={() => {
setMessage({ tone: "danger", text: "Choose a PNG, JPG or WebP photo." });
}}
/>
<Button
variant="ghost"
size="sm"
leadingIcon={<LuImage />}
className="justify-self-center"
onPress={() => {
use(PHOTO);
}}
>
Use sample photo
</Button>
</div>
) : null}
{step === 2 ? (
<div className="grid gap-3">
<div className="flex flex-wrap justify-between gap-2">
<SegmentedControl
aria-label="Aspect ratio"
selectedKeys={[ratioId]}
onSelectionChange={(keys) => {
const id = String([...keys][0] ?? ratioId);
setRatioId(id);
if (id !== "1:1") setShape("rect");
}}
>
{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}
aspectRatio={RATIOS.find((r) => r.id === ratioId)?.ratio ?? null}
shape={shape}
handles="dot"
className="h-72 rounded-lg bg-media-bg"
{...(crop ? { defaultCrop: crop } : {})}
onCropChange={setCrop}
onError={() => {
setMessage({
tone: "danger",
text: "This photo couldn’t be opened. Try another one.",
});
}}
/>
</div>
) : null}
{step === 3 ? (
<div className="grid gap-5 @lg:grid-cols-[10rem_minmax(0,1fr)] @lg:items-start">
<div className="mx-auto grid size-40 place-items-center rounded-lg bg-sunken">
{preview ? (
<img
src={preview}
alt="Cropped preview"
style={{ filter }}
className={`size-32 object-contain ${shape === "circle" ? "rounded-full" : "rounded-md"}`}
/>
) : null}
</div>
<div className="grid gap-4">
<div role="group" aria-label="Filter" className="flex flex-wrap gap-2">
{IMAGE_FILTERS.slice(0, 6).map((p) => (
<Button
key={p.id}
size="sm"
variant={p.id === presetId ? "primary" : "secondary"}
aria-pressed={p.id === presetId}
onPress={() => {
setPresetId(p.id);
}}
>
{p.label}
</Button>
))}
</div>
<Select
label="File format"
size="sm"
value={format}
onChange={(key) => {
if (key !== null) setFormat(String(key));
}}
>
<SelectOption id="image/png" label="PNG" />
<SelectOption id="image/jpeg" label="JPEG" />
<SelectOption id="image/webp" label="WebP" />
</Select>
</div>
</div>
) : null}
{message ? <Alert tone={message.tone} title={message.text} /> : null}
<footer 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}
<span className="flex-1" />
{step < 3 ? (
<Button
size="sm"
trailingIcon={<LuArrowRight />}
isDisabled={step === 2 && !crop}
onPress={() => {
go(step + 1);
}}
>
Next
</Button>
) : (
<Button size="sm" isDisabled={!crop} onPress={() => void save()}>
Save photo
</Button>
)}
</footer>
</section>
</div>
);
}
More settings and preferences blocks
- Notification preferencesChoose which alerts arrive by email, push or not at all.
- AppearanceTheme, density and accent colour.
- SecurityPassword, two-step verification and active sessions.
- Team members and rolesInvite, change roles and remove members.
- Billing and planCurrent plan, payment method and invoices.
- IntegrationsConnect and configure other apps.
- Delete accountA clear warning and a confirmation step.