Actions and inputs
Avatar cropper
A profile photo field where the avatar itself grows into a crop stage with no modal, the photo pans, zooms and turns with a natural feel, and the stage shrinks back into the avatar with the new crop when you save.
Live preview
Loading preview
An account settings row for "Maya Chen" with a 64px circle avatar showing initials, Upload and Remove. The demo includes two sample photos (a portrait and a wide landscape); save waits 800ms. Replay resets to initials, opens the portrait in the stage (it opens at 150%, so the photo fills the stage), zooms to 200%, drags it slightly left, rotates once and saves; a second demo uses shape "square" for a team logo.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add avatar-cropperUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { Button } from '../button/button';
import { AvatarCropper, type AvatarCropperHandle } from './avatar-cropper';
// Own drawings, made here as SVG: a head-and-shoulders portrait and a wide hill landscape.
const PORTRAIT = `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1000" viewBox="0 0 800 1000">
<defs><linearGradient id="b" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#7d97a0"/><stop offset="1" stop-color="#3e5661"/></linearGradient>
<radialGradient id="s" cx=".42" cy=".38" r=".75"><stop offset="0" stop-color="#f3cdb0"/><stop offset="1" stop-color="#d49673"/></radialGradient></defs>
<rect width="800" height="1000" fill="url(#b)"/>
<path d="M230 500c-25-230 80-350 175-350s205 120 170 350c-12 80-38 150-58 190H290c-30-55-54-115-60-190Z" fill="#7b3f24"/>
<path d="M110 1000c18-180 140-280 290-280s272 100 290 280Z" fill="#26323b"/>
<path d="M345 640h110v100c-35 30-75 30-110 0Z" fill="#cf8f6c"/>
<ellipse cx="400" cy="470" rx="148" ry="188" fill="url(#s)"/>
<ellipse cx="346" cy="468" rx="15" ry="9" fill="#3a2923"/><ellipse cx="456" cy="468" rx="15" ry="9" fill="#3a2923"/>
<path d="M318 440q28-16 56-4M428 436q28-12 56 4" stroke="#6b3a24" stroke-width="7" fill="none" stroke-linecap="round"/>
<path d="M402 480q-10 40 4 52" stroke="#bf8162" stroke-width="6" fill="none" stroke-linecap="round"/>
<path d="M362 572q40 24 80 0" stroke="#a85b50" stroke-width="9" fill="none" stroke-linecap="round"/>
<path d="M248 470c-4-170 80-262 165-262 96 0 160 90 154 230-58-78-150-112-238-82-36 12-64 48-81 114Z" fill="#934f2e"/>
</svg>`;
const LANDSCAPE = `<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900">
<defs><linearGradient id="k" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f6c99a"/><stop offset=".6" stop-color="#e9a07c"/><stop offset="1" stop-color="#b9707a"/></linearGradient></defs>
<rect width="1600" height="900" fill="url(#k)"/>
<circle cx="1120" cy="380" r="110" fill="#fde3b8"/>
<path d="M0 560 260 380l210 140 250-230 300 260 220-150 360 230v270H0Z" fill="#8a5a6e"/>
<path d="M0 680c240-90 470-110 720-60s520 40 880-40v320H0Z" fill="#5a3f58"/>
<path d="M0 800c300-50 640-60 960-20s460 20 640-10v130H0Z" fill="#3a2c40"/>
</svg>`;
const toUrl = (svg: string) => `data:image/svg+xml,${encodeURIComponent(svg)}`;
const PHOTOS = { portrait: toUrl(PORTRAIT), landscape: toUrl(LANDSCAPE) };
// The pretend upload takes 800ms; Replay steps are spaced so each motion can finish.
const UPLOAD = 800;
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const pretendUpload = () => wait(UPLOAD).then(() => undefined);
export default function AvatarCropperDemo({ replay = 0 }: { replay?: number }) {
const [photo, setPhoto] = React.useState<string | null>(null);
const [logo, setLogo] = React.useState<string | null>(null);
const cropper = React.useRef<AvatarCropperHandle>(null);
const run = React.useRef(0);
React.useEffect(() => {
if (replay === 0) return;
const mine = ++run.current;
const step = async (ms: number, action: () => unknown) => {
await wait(ms);
if (mine === run.current) await action();
};
cropper.current?.reset();
setPhoto(null);
void (async () => {
await step(300, () => cropper.current?.open(PHOTOS.portrait));
await step(900, () => cropper.current?.zoomTo(2));
await step(700, () => cropper.current?.panBy(-24, 0));
await step(600, () => cropper.current?.rotate());
await step(900, () => cropper.current?.save());
})();
return () => {
run.current += 1;
};
}, [replay]);
return (
<div className="flex w-full flex-col items-center gap-8">
<div className="flex w-full max-w-90 flex-col gap-3">
<AvatarCropper
ref={cropper}
name="Maya Chen"
value={photo}
onValueChange={setPhoto}
onSave={pretendUpload}
onRemove={() => undefined}
/>
<div className="flex flex-wrap gap-2 ps-20">
<Button size="sm" hierarchy="secondary" onClick={() => void cropper.current?.open(PHOTOS.portrait)}>
Try a portrait
</Button>
<Button
size="sm"
hierarchy="secondary"
onClick={() => void cropper.current?.open(PHOTOS.landscape)}
>
Try a landscape
</Button>
</div>
</div>
<AvatarCropper
name="Northwind Labs"
title="Team logo"
shape="square"
value={logo}
onValueChange={setLogo}
onSave={pretendUpload}
onRemove={() => undefined}
/>
</div>
);
}
API
Parts: root, avatar slot (button: initials, photo layers, hover dim with camera icon, ring), title, line (hint, help or notice, crossfading text), slot actions (Upload or Replace, Remove in a folding column), hidden file input, panel (height eases), stage (same layer as the avatar, grown), photo (pan, zoom and rotate layers), dim outside the crop + crop outline, thirds grid (only while holding), controls (zoom out, zoom slider, zoom in, zoom percent, rotate), footer (Cancel, Save as status button), keyboard help (screen reader only), status (live region, screen reader only).
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | null | undefined | Controlled photo URL; null shows initials. |
| defaultValue | string | null | null | Starting photo when uncontrolled. |
| name | string | - | Whose photo; used for the initials and the accessible names. Required. |
| shape | "circle" | "square" | "circle" | Circle for people, rounded square for a team or workspace logo. |
| size | "lg" | "xl" | "2xl" | "2xl" | Avatar size: 48, 56 or 64px (the base avatar sizes). |
| outputSize | number | 512 | Side of the saved square in px. |
| outputType | string | "image/webp" | Image type of the saved file. |
| outputQuality | number | 0.9 | Quality of the saved file, 0 to 1. |
| accept | string | "image/*" | Accepted file types. |
| maxSize | number | 10000000 | Largest file in bytes. |
| maxZoom | number | 4 | Largest zoom (1 = the photo just covers the crop). |
| title | string | "Profile photo" | Title beside the avatar. |
| hint | string | "JPG, PNG or WebP, up to 10 MB." | Line under the title at rest. |
| Event | Description |
|---|---|
| valueChange | Fires with the new photo URL after a save, or null after remove. |
| save | Called with (blob, area: { centerX, centerY, size, rotation }). Resolve with the stored URL, or nothing to show the local crop; throw to show Try again. |
| remove | When handled, a Remove action shows while there is a photo. Return a promise to show Removing. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| AC1 | hover the avatar | hover dim + camera icon | opacity | 0 → 1 | duration-sm (200)ease-out | Shows at once |
| AC2 | drag a file over the avatar (Drag over) | avatar slot | transform scale | 1 → 1.06 | duration-2xl (420)ease-throw | Stays still |
| AC3 | drag a file over the avatar (Drag over) | avatar ring | box-shadow | 1px border-primary → 1.5px focus color | duration-sm (200)ease-out | Swaps at once |
| AC4 | line text changes (hint, help, drop prompt, notice) | new line text | opacity + transform translateY + filter blur | 0, 0.35em, blur-text → 1, 0, 0 | duration-2xl (420)ease-outdelay 60ms | Swaps at once |
| AC5 | line text changes | old line text | opacity + transform translateY + filter blur | 1, 0, 0 → 0, -0.35em, blur-text | duration-sm (200)ease-out | Swaps at once |
| AC6 | file is not a photo, too large or unreadable | avatar slot | transform translateX shake | 0 → -4px, 4px, -2px, 0 | duration-xl (380)ease-exit | Stays still |
| AC7 | file is not a photo, too large or unreadable | line | color | text-tertiary → text-error-primary | duration-lg (320)ease-out | Swaps at once |
| AC8 | open a photo (browse, drop, paste) (Editing) | panel | height | 0 → stage + controls height | duration-4xl (520)ease-out | Resizes at once |
| AC9 | open a photo (Editing) | stage | transform translate + scale, clip-path (grows out of the avatar; the clip starts as the crop outline) | on the avatar, crop outline → full stage, radius-3xl (20px) | duration-4xl (520)ease-out | Shows at once |
| AC10 | open a photo (Editing) | controls | opacity + transform translateY + filter blur | 0, -distance-nudge, blur-text → 1, 0, 0 | duration-2xl (420)ease-outdelay 220ms | Shows at once |
| AC11 | open a photo, or close the stage | slot actions | opacity + filter blur | 1, 0 → 0, blur-swap | duration-lg (320)ease-out | Hides at once |
| AC12 | open another photo while editing | photo | opacity + filter blur | 0, 6px → 1, 0 | duration-xl (380)ease-out | Shows at once |
| AC13 | press and hold the photo | thirds grid | opacity | 0 → 1 | duration-md (260)ease-out | Shows at once |
| AC14 | drag, pinch, wheel or trackpad pinch | photo layers | transform translate, scale (follows the input; resists more past an edge or the zoom limits) | current crop → input position | 0ease-linear | Moves at once |
| AC15 | release with speed | photo pan layer | transform translate (keeps the release speed and slows down) | release point → rest point | duration-4xl (520)ease-out | Moves at once |
| AC16 | release out of bounds, Arrow keys, plus or minus, zoom buttons or slider | photo layers | transform translate, scale (spring, no overshoot, so the crop never shows a gap) | current → inside the bounds or the new step | duration-lg (320)ease-settle | Moves at once |
| AC17 | rotate button or R | photo rotate layer | transform rotate (quarter turn) | n x 90deg → (n + 1) x 90deg | duration-4xl (520)ease-throw | Swaps at once |
| AC18 | zoom settles | zoom percent | number roll | old percent → new percent | duration-5xl (640)ease-out | Swaps at once |
| AC19 | Save or Enter | save button | status button motion (Saving, check draws, Saved) | Save → Saved | duration-xl (380)ease-out | Swaps at once |
| AC20 | 520ms after Saved, or Cancel or Escape (close) | controls | opacity + filter blur | 1, 0 → 0, blur-text | duration-xs (160)ease-exit | Hides at once |
| AC21 | close | stage | transform translate + scale, clip-path (shrinks back into the avatar with the framed crop) | full stage → on the avatar, crop outline | duration-4xl (520)ease-out | Hides at once |
| AC22 | close | panel | height | stage + controls height → 0 | duration-4xl (520)ease-out | Resizes at once |
| AC23 | Cancel or Escape | stage | opacity (hands over to the old photo near the end) | 1 → 0 | duration-md (260)ease-lineardelay 220ms | Hides at once |
| AC24 | Cancel, or remove the photo | avatar photo or initials | opacity + transform scale + filter blur (dissolve swap) | 0, 0.9, blur-text → 1, 1, 0 | duration-2xl (420)ease-outdelay 200ms | Swaps at once |
| AC25 | photo added or removed | Remove action column | grid-template-columns + opacity | 0fr, 0 → 1fr, 1 | duration-lg (320)ease-out | Resizes at once |
Accessibility
- The avatar is a button named "Change Maya Chen's photo" (or "Add a photo for Maya Chen"), described by the line; it opens the file picker and also takes a dropped or pasted photo.
- The stage is a focusable group (aria-roledescription "crop area", named "Crop Maya Chen's photo") described by a hidden help line: "Use the arrow keys to move the photo, plus and minus to zoom, R to rotate, Enter to save and Escape to cancel."
- Opening by hand moves focus to the stage; closing returns focus to the avatar.
- The zoom slider is a native range input named "Zoom" with aria-valuetext "150%"; zoom out, zoom in and rotate are icon buttons with names ("Zoom out", "Zoom in", "Rotate a quarter turn").
- A polite live region reads each step: "Photo opened. Drag to move it. Pinch, scroll or use the slider to zoom. Then save.", "Saving.", "Photo saved.", "Could not save the photo. Try again.", "Canceled.", "Photo removed."
- The slot actions are inert while editing; the Remove column is inert while there is no photo.
- Control buttons are 28px, Cancel (Button sm) and Save (Status button md) 36px, one height for the row; the avatar is 64px by default; touch-action none on the stage.
- Reduced motion: the stage opens and closes at once, the photo jumps to each position, the turn swaps, numbers and labels swap; dragging still follows the pointer.