Lunia DS

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.

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-cropper

Usage

The demo above, as code.

demo.tsx
'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).

PropTypeDefaultDescription
valuestring | nullundefinedControlled photo URL; null shows initials.
defaultValuestring | nullnullStarting photo when uncontrolled.
namestring-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).
outputSizenumber512Side of the saved square in px.
outputTypestring"image/webp"Image type of the saved file.
outputQualitynumber0.9Quality of the saved file, 0 to 1.
acceptstring"image/*"Accepted file types.
maxSizenumber10000000Largest file in bytes.
maxZoomnumber4Largest zoom (1 = the photo just covers the crop).
titlestring"Profile photo"Title beside the avatar.
hintstring"JPG, PNG or WebP, up to 10 MB."Line under the title at rest.
EventDescription
valueChangeFires with the new photo URL after a save, or null after remove.
saveCalled with (blob, area: { centerX, centerY, size, rotation }). Resolve with the stored URL, or nothing to show the local crop; throw to show Try again.
removeWhen 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
AC1hover the avatarhover dim + camera iconopacity0 → 1duration-sm (200)ease-outShows at once
AC2drag a file over the avatar (Drag over)avatar slottransform scale1 → 1.06duration-2xl (420)ease-throwStays still
AC3drag a file over the avatar (Drag over)avatar ringbox-shadow1px border-primary → 1.5px focus colorduration-sm (200)ease-outSwaps at once
AC4line text changes (hint, help, drop prompt, notice)new line textopacity + transform translateY + filter blur0, 0.35em, blur-text → 1, 0, 0duration-2xl (420)ease-outdelay 60msSwaps at once
AC5line text changesold line textopacity + transform translateY + filter blur1, 0, 0 → 0, -0.35em, blur-textduration-sm (200)ease-outSwaps at once
AC6file is not a photo, too large or unreadableavatar slottransform translateX shake0 → -4px, 4px, -2px, 0duration-xl (380)ease-exitStays still
AC7file is not a photo, too large or unreadablelinecolortext-tertiary → text-error-primaryduration-lg (320)ease-outSwaps at once
AC8open a photo (browse, drop, paste) (Editing)panelheight0 → stage + controls heightduration-4xl (520)ease-outResizes at once
AC9open a photo (Editing)stagetransform 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-outShows at once
AC10open a photo (Editing)controlsopacity + transform translateY + filter blur0, -distance-nudge, blur-text → 1, 0, 0duration-2xl (420)ease-outdelay 220msShows at once
AC11open a photo, or close the stageslot actionsopacity + filter blur1, 0 → 0, blur-swapduration-lg (320)ease-outHides at once
AC12open another photo while editingphotoopacity + filter blur0, 6px → 1, 0duration-xl (380)ease-outShows at once
AC13press and hold the photothirds gridopacity0 → 1duration-md (260)ease-outShows at once
AC14drag, pinch, wheel or trackpad pinchphoto layerstransform translate, scale (follows the input; resists more past an edge or the zoom limits)current crop → input position0ease-linearMoves at once
AC15release with speedphoto pan layertransform translate (keeps the release speed and slows down)release point → rest pointduration-4xl (520)ease-outMoves at once
AC16release out of bounds, Arrow keys, plus or minus, zoom buttons or sliderphoto layerstransform translate, scale (spring, no overshoot, so the crop never shows a gap)current → inside the bounds or the new stepduration-lg (320)ease-settleMoves at once
AC17rotate button or Rphoto rotate layertransform rotate (quarter turn)n x 90deg → (n + 1) x 90degduration-4xl (520)ease-throwSwaps at once
AC18zoom settleszoom percentnumber rollold percent → new percentduration-5xl (640)ease-outSwaps at once
AC19Save or Entersave buttonstatus button motion (Saving, check draws, Saved)Save → Savedduration-xl (380)ease-outSwaps at once
AC20520ms after Saved, or Cancel or Escape (close)controlsopacity + filter blur1, 0 → 0, blur-textduration-xs (160)ease-exitHides at once
AC21closestagetransform translate + scale, clip-path (shrinks back into the avatar with the framed crop)full stage → on the avatar, crop outlineduration-4xl (520)ease-outHides at once
AC22closepanelheightstage + controls height → 0duration-4xl (520)ease-outResizes at once
AC23Cancel or Escapestageopacity (hands over to the old photo near the end)1 → 0duration-md (260)ease-lineardelay 220msHides at once
AC24Cancel, or remove the photoavatar photo or initialsopacity + transform scale + filter blur (dissolve swap)0, 0.9, blur-text → 1, 1, 0duration-2xl (420)ease-outdelay 200msSwaps at once
AC25photo added or removedRemove action columngrid-template-columns + opacity0fr, 0 → 1fr, 1duration-lg (320)ease-outResizes 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.