Avatar cropperNew

The profile photo field without a modal: the avatar itself grows into the crop, and shrinks back with the face you framed.

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/avatar-cropper.json

Overview

Changing a profile photo usually means a modal with a crop box and a slider, and a result that pops in when it closes. Here the avatar is the crop. Choose, drop or paste a photo and the circle grows into the stage, the rest of the photo fading in dimmed around it. The photo moves like one you hold: it follows the finger exactly, resists past an edge the way iOS Photos does and springs back, so the crop never shows a gap, and it keeps the speed you let go with. Pinch, scroll or the slider zoom about where you point, and the zoom rolls to its new value when you let go. Rotate throws a quarter turn with a slight overshoot and the photo settles inside its new bounds. Save goes Saving, Saved, then the stage shrinks back into the circle carrying the face you framed: the same layer all the way, so the new avatar is already there when it lands.

States

idle
The avatar with Upload or Replace, and Remove when there's a photo. Hovering dims it and shows a camera; dragging a file over it scales it to 1.06 with a slight overshoot and thickens its ring.
open
The avatar's circle grows into a stage up to 320px in 560ms: it starts as exactly the avatar (the crop circle scaled onto it) and widens its clip to the stage's rounded square while the space below eases open. The dim and the controls fade in 160–220ms later.
hold
Dragging moves the photo one to one; past an edge it gives less the further it goes (iOS's 0.55 resistance) and the thirds appear inside the crop. Pinch and scroll zoom about the fingers or pointer, from the fit to 4x.
release
The photo carries its release speed and slows down; anything outside the bounds springs back critically damped, keeping that speed. The zoom then rolls to its new percent.
rotate
A quarter turn clockwise in 560ms with a slight overshoot; the photo springs inside the new bounds while it turns.
save
Save → Saving → Saved (StatusButton), then the stage shrinks back into the avatar's circle with the crop in it. The new photo is decoded before it lands, so the hand-over is invisible. A failed save shakes the button and reads Try again; the stage stays.
cancel
Escape or Cancel shrinks the stage back and fades it over the last 45% while the old photo (or the initials) fades back in.
remove
Remove → Removing, then the photo dissolves into the initials through a blur.

Usage

usage.tsx
import { AvatarCropper } from "@/components/avatar-cropper";<AvatarCropper  name={user.name}  defaultValue={user.avatarUrl}  onSave={async (photo) => {    const { url } = await uploadAvatar(photo);    return url;  }}/>

Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.

Works with

Upload the crop to your storage

onSave gets a square WebP (512px by default) and where the crop sits in the original, in its own pixels, in case you keep originals and crop on the server. Return the stored URL and the avatar shows it; throw and the button reads Try again.

example.tsx
"use client";import { AvatarCropper } from "@/components/avatar-cropper";export function ProfilePhoto({ user }: { user: { name: string; avatarUrl: string | null } }) {  return (    <AvatarCropper      name={user.name}      defaultValue={user.avatarUrl}      onSave={async (photo, area) => {        const body = new FormData();        body.append("photo", photo, "avatar.webp");        body.append("area", JSON.stringify(area));        const res = await fetch("/api/me/avatar", { method: "PUT", body });        if (!res.ok) throw new Error("Upload failed");        const { url } = await res.json();        return url;      }}      onRemove={async () => {        await fetch("/api/me/avatar", { method: "DELETE" });      }}    />  );}

Props

PropTypeDescription
namestringWhose photo: the initials when there's none, and the accessible names.
onSave(photo: Blob, area: CropArea) => Promise<string | void>Receives the cropped square and { centerX, centerY, size, rotation } in the original's pixels. Resolve with the stored URL (or nothing to show the crop itself); throw to show the error.
valuestring | nullControlled photo URL.
defaultValuestring | nullStarting photo URL when uncontrolled.
onValueChange(url: string | null) => voidAfter a save lands or a removal.
onRemove() => Promise<void> | voidShows Remove while there's a photo.
shape"circle" | "square"default "circle""square" crops a rounded square, for workspace and team logos.
sizenumberdefault 64The avatar's size in px.
outputSizenumberdefault 512Side of the saved square in px.
outputTypestringdefault "image/webp"Any type canvas.toBlob encodes, with outputQuality (0.9).
maxSizenumberdefault 10_000_000Largest file in bytes; bigger ones are refused with a shake and the limit.
maxZoomnumberdefault 4How far past the fit it zooms.
titlestringdefault "Profile photo"Beside the avatar.
hintstringUnder the title; it gives way to how to crop while editing.
refRef<AvatarCropperHandle>open(file | blob | url) and browse(), e.g. for a photo from your own picker or a camera.

Accessibility and motion

  • The avatar is a button: Enter or Space opens the file picker, and paste takes a photo from the clipboard while focus is anywhere in the field. Dropping a file on the avatar or the stage opens it.
  • The stage is focusable: arrow keys move the photo (Shift for bigger steps), plus and minus zoom, R rotates, Enter saves and Escape cancels. The zoom slider is a native range input; the zoom is announced when it settles, never while it moves.
  • One frame loop with fixed 4ms steps writes transforms to three nested layers (move, zoom, turn) and sleeps once everything is still; React only hears about it then. The layers are promoted while they move and released after, so the photo is redrawn sharp at its new size.
  • Only the stage takes touch (touch-action: none); the page itself is never locked. Wheel and trackpad pinch zoom about the pointer and don't scroll the page while over the photo.
  • The crop is drawn from the original at full resolution with the same transform you see, so what lands in the circle is what you saved.
  • Reduced motion opens and closes in place and the photo goes straight to its bounds instead of springing.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs Status button, Number roll, Text morph.

Source

components/avatar-cropper.tsx
"use client";import * as React from "react";import { Camera, Minus, Plus, RotateCw } from "lucide-react";import { NumberRoll } from "./number-roll";import { StatusButton, type ActionStatus } from "./status-button";import { useCropGesture, type CropArea, type CropState } from "./use-crop-gesture";/* ───────────────────────────────────────────────────────── * AVATAR CROPPER: the profile photo field, without a modal * *   open      choose, drop or paste a photo and the avatar *             itself grows into the crop stage: its circle is *             the crop, the rest of the photo fades in, dimmed, *             around it *   hold      drag, pinch, scroll; past an edge the photo *             resists and springs back, so the crop never shows *             a gap; it keeps the speed you let go with *   rotate    a quarter turn, thrown with a slight overshoot *   save      Save → Saving → Saved, then the stage shrinks *             back into the circle with the face you framed: *             the same layer, never a swap *   cancel    shrinks back and hands over to the old photo *   remove    the photo dissolves into initials * ───────────────────────────────────────────────────────── */export type { CropArea } from "./use-crop-gesture";export interface AvatarCropperHandle {  /** Opens a photo in the stage: a file, a blob or a URL. */  open: (photo: File | Blob | string) => void;  /** Opens the file picker. */  browse: () => void;}export interface AvatarCropperProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title" | "defaultValue" | "onChange"> {  /** The photo shown now (a URL), or null for initials. */  value?: string | null;  defaultValue?: string | null;  onValueChange?: (url: string | null) => void;  /** Whose photo: used for the initials and the accessible names. */  name: string;  /** Receives the cropped square. Resolve with the stored photo's URL (or nothing to show the crop itself); throw to show the error. */  onSave: (photo: Blob, area: CropArea) => Promise<string | void> | string | void;  /** Shows Remove while there's a photo. */  onRemove?: () => Promise<void> | void;  /** "square" for a workspace or team logo (rounded square). */  shape?: "circle" | "square";  /** The avatar's size, px. */  size?: number;  /** Side of the saved square, px. */  outputSize?: number;  outputType?: string;  outputQuality?: number;  accept?: string;  /** Largest file taken, bytes. */  maxSize?: number;  maxZoom?: number;  title?: string;  hint?: string;  ref?: React.Ref<AvatarCropperHandle>;}type Phase = "idle" | "opening" | "open" | "closing";type Photo = { src: string; w: number; h: number; owned: boolean };type Note = { text: string; tone: "muted" | "error"; key: number };const EASE = "cubic-bezier(0.16,1,0.3,1)";const THROW = "cubic-bezier(0.34,1.36,0.64,1)";const MORPH = 560;const MARGIN = 22;const RADIUS = 22;const STAGE_MAX = 320;const SQUARE = 0.22;const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";const reducedQuery = "(prefers-reduced-motion: reduce)";const subscribeReduced = (onChange: () => void) => {  const query = window.matchMedia(reducedQuery);  query.addEventListener("change", onChange);  return () => query.removeEventListener("change", onChange);};const useReducedMotion = () =>  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);const swap = (on: boolean, reduced: boolean, delay = 0): React.CSSProperties => ({  opacity: on ? 1 : 0,  transform: on ? "none" : "scale(0.9)",  filter: on ? "none" : "blur(4px)",  transition: reduced ? "none" : `opacity 300ms ${EASE} ${delay}ms, transform 420ms ${EASE} ${delay}ms, filter 300ms ${EASE} ${delay}ms`,});const initials = (name: string) =>  name    .split(/\s+/)    .filter(Boolean)    .slice(0, 2)    .map((w) => w[0]!.toUpperCase())    .join("");/** The crop's outline inside a stage of side s: a circle, or a rounded square. */function holePath(shape: "circle" | "square", s: number, d: number) {  const c = s / 2;  if (shape === "circle") return `M${c - d / 2} ${c}a${d / 2} ${d / 2} 0 1 0 ${d} 0a${d / 2} ${d / 2} 0 1 0 ${-d} 0Z`;  const r = d * SQUARE;  const x = c - d / 2;  return `M${x + r} ${x}H${x + d - r}A${r} ${r} 0 0 1 ${x + d} ${x + r}V${x + d - r}A${r} ${r} 0 0 1 ${x + d - r} ${x + d}H${x + r}A${r} ${r} 0 0 1 ${x} ${x + d - r}V${x + r}A${r} ${r} 0 0 1 ${x + r} ${x}Z`;}/** A sentence that changes crossfades whole: the old one lifts away through a blur while the new one rises in. */function Line({ text, reduced }: { text: string; reduced: boolean }) {  const [shown, setShown] = React.useState({ text, key: 0 });  const [leaving, setLeaving] = React.useState<{ text: string; key: number }[]>([]);  if (text !== shown.text) {    if (!reduced) setLeaving((l) => [...l, shown]);    setShown({ text, key: shown.key + 1 });  }  return (    <span className="relative block">      <span        key={shown.key}        className="block"        ref={(el) => {          if (!el || reduced || shown.key === 0 || el.dataset.in) return;          el.dataset.in = "1";          el.animate(            [              { opacity: 0, transform: "translateY(0.35em)", filter: "blur(4px)" },              { opacity: 1, transform: "none", filter: "blur(0px)" },            ],            { duration: 420, delay: 60, easing: EASE, fill: "backwards" },          );        }}      >        {shown.text}      </span>      {leaving.map((l) => (        <span          key={l.key}          aria-hidden="true"          className="pointer-events-none absolute inset-x-0 top-0 block"          ref={(el) => {            if (!el || el.dataset.out) return;            el.dataset.out = "1";            el.animate(              [                { opacity: 1, transform: "none", filter: "blur(0px)" },                { opacity: 0, transform: "translateY(-0.35em)", filter: "blur(4px)" },              ],              { duration: 200, easing: EASE, fill: "forwards" },            ).onfinish = () => setLeaving((all) => all.filter((x) => x.key !== l.key));          }}        >          {l.text}        </span>      ))}    </span>  );}function IconButton({ label, onClick, children, disabled }: { label: string; onClick: () => void; children: React.ReactNode; disabled?: boolean }) {  return (    <button      type="button"      aria-label={label}      title={label}      onClick={onClick}      disabled={disabled}      className={`flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-40 [&_svg]:size-3.5 ${FOCUS}`}    >      {children}    </button>  );}export function AvatarCropper({  value,  defaultValue = null,  onValueChange,  name,  onSave,  onRemove,  shape = "circle",  size = 64,  outputSize = 512,  outputType = "image/webp",  outputQuality = 0.9,  accept = "image/*",  maxSize = 10_000_000,  maxZoom = 4,  title = "Profile photo",  hint = "JPG, PNG or WebP. Drop one on the circle.",  ref,  className = "",  ...props}: AvatarCropperProps) {  const reduced = useReducedMotion();  const uid = React.useId().replace(/[^\w-]/g, "");  const [own, setOwn] = React.useState<string | null>(defaultValue);  const current = value === undefined ? own : value;  const [phase, setPhase] = React.useState<Phase>("idle");  const [photo, setPhoto] = React.useState<Photo | null>(null);  const [stageSize, setStageSize] = React.useState(STAGE_MAX);  const [status, setStatus] = React.useState<ActionStatus>("idle");  const [removing, setRemoving] = React.useState<ActionStatus>("idle");  const [note, setNote] = React.useState<Note | null>(null);  const [over, setOver] = React.useState(false);  const [committed, setCommitted] = React.useState<CropState>({ x: 0, y: 0, z: 1, r: 0 });  const [announce, setAnnounce] = React.useState("");  // How the stage goes back: into the new photo (save) or handing over to the old one (cancel).  const [landing, setLanding] = React.useState<{ src: string; save: boolean } | null>(null);  /** The photo a save just landed on: shown at once, no fade, for the frame the stage disappears. */  const [justLanded, setJustLanded] = React.useState<string | null>(null);  React.useEffect(() => {    if (!justLanded) return;    const frame = requestAnimationFrame(() => setJustLanded(null));    return () => cancelAnimationFrame(frame);  }, [justLanded]);  const rootRef = React.useRef<HTMLDivElement>(null);  const slotRef = React.useRef<HTMLButtonElement>(null);  const panelRef = React.useRef<HTMLDivElement>(null);  const surfaceRef = React.useRef<HTMLDivElement>(null);  const controlsRef = React.useRef<HTMLDivElement>(null);  const imgRef = React.useRef<HTMLImageElement>(null);  const fileRef = React.useRef<HTMLInputElement>(null);  const ownedUrls = React.useRef(new Set<string>());  const phaseRef = React.useRef(phase);  phaseRef.current = phase;  /** Focus moves into the stage only when someone opened it (not a photo opened on load), and back to the avatar after. */  const takeFocus = React.useRef(false);  const returnFocus = React.useRef(false);  React.useEffect(() => {    if (phase === "open" && takeFocus.current) {      takeFocus.current = false;      crop.refs.stage.current?.focus({ preventScroll: true });    }    if (phase === "idle" && returnFocus.current) {      returnFocus.current = false;      slotRef.current?.focus({ preventScroll: true });    }    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [phase]);  const d = stageSize - MARGIN * 2;  const dims = React.useMemo(() => photo && { w: photo.w, h: photo.h }, [photo]);  const lastZoom = React.useRef(1);  const crop = useCropGesture({    size: d,    image: dims,    maxZoom,    reduced,    onSettle: (s) => {      setCommitted(s);      // Announced once it settles, never while it moves.      if (Math.round(s.z * 100) !== Math.round(lastZoom.current * 100)) setAnnounce(`Zoom ${Math.round(s.z * 100)}%`);      lastZoom.current = s.z;    },  });  React.useEffect(    () => () => {      ownedUrls.current.forEach((u) => URL.revokeObjectURL(u));    },    [],  );  const flash = (text: string, tone: Note["tone"] = "muted") => {    const key = Date.now();    setNote({ text, tone, key });    window.setTimeout(() => setNote((n) => (n?.key === key ? null : n)), 2800);  };  const shake = (el: HTMLElement | null) => {    if (!el || reduced) return;    el.animate([{ transform: "none" }, { transform: "translateX(-4px)" }, { transform: "translateX(4px)" }, { transform: "translateX(-2px)" }, { transform: "none" }], {      duration: 380,      easing: "ease-out",    });  };  const setCurrent = (url: string | null) => {    if (value === undefined) setOwn(url);    onValueChange?.(url);  };  // ── Opening a photo ────────────────────────────────────  const open = async (input: File | Blob | string) => {    if (phaseRef.current === "opening" || phaseRef.current === "closing") return;    const byHand = typeof navigator === "undefined" || !("userActivation" in navigator) || navigator.userActivation.isActive;    if (typeof input !== "string") {      if (!input.type.startsWith("image/")) {        flash("That file isn't a photo.", "error");        return shake(slotRef.current);      }      if (input.size > maxSize) {        flash(`That photo is over ${Math.round(maxSize / 1_000_000)} MB.`, "error");        return shake(slotRef.current);      }    }    const src = typeof input === "string" ? input : URL.createObjectURL(input);    if (typeof input !== "string") ownedUrls.current.add(src);    const img = new Image();    img.src = src;    try {      await img.decode();    } catch {      flash("Couldn't read that photo.", "error");      return shake(slotRef.current);    }    const width = rootRef.current?.offsetWidth ?? STAGE_MAX;    setStageSize(Math.round(Math.min(STAGE_MAX, Math.max(220, width))));    setPhoto({ src, w: img.naturalWidth, h: img.naturalHeight, owned: typeof input !== "string" });    setStatus("idle");    setCommitted({ x: 0, y: 0, z: 1, r: 0 });    lastZoom.current = 1;    crop.reset();    if (phaseRef.current === "idle") {      takeFocus.current = byHand || !!rootRef.current?.contains(document.activeElement);      setPhase("opening");    }    else {      // Already open: the new photo fades in where the old one was.      imgRef.current?.animate([{ opacity: 0, filter: "blur(6px)" }, { opacity: 1, filter: "blur(0px)" }], { duration: 360, easing: EASE });    }    setAnnounce("Photo opened. Drag to move it, pinch, scroll or use the slider to zoom, then Save.");  };  const browse = () => fileRef.current?.click();  React.useImperativeHandle(ref, () => ({ open, browse }));  // ── The morph: the avatar's circle grows into the stage, and back ──  /** The stage's resting transform when it is the avatar: its crop on the slot's circle. */  const collapsedFrame = () => {    const slot = slotRef.current!.getBoundingClientRect();    const stage = surfaceRef.current!;    // Measured between morphs, when the stage carries no transform.    const box = stage.getBoundingClientRect();    const dx = slot.left + slot.width / 2 - (box.left + stageSize / 2);    const dy = slot.top + slot.height / 2 - (box.top + stageSize / 2);    const hole = shape === "circle" ? d / 2 : d * SQUARE;    return { transform: `translate(${dx}px, ${dy}px) scale(${slot.width / d})`, clipPath: `inset(${MARGIN}px round ${hole}px)` };  };  const openFrame = { transform: "translate(0px, 0px) scale(1)", clipPath: `inset(0px round ${RADIUS}px)` };  React.useLayoutEffect(() => {    const surface = surfaceRef.current;    const panel = panelRef.current;    if (!surface || !panel) return;    if (phase === "opening") {      if (reduced) return setPhase("open");      const height = panel.scrollHeight;      panel.animate([{ height: "0px" }, { height: `${height}px` }], { duration: MORPH, easing: EASE });      // The dim is there from the first frame: the clip starts as the crop's own outline, so it opens onto the rest of      // the photo already dimmed while the circle stays the photo.      const a = surface.animate([collapsedFrame(), openFrame], { duration: MORPH, easing: EASE });      controlsRef.current?.animate(        [          { opacity: 0, transform: "translateY(-6px)", filter: "blur(4px)" },          { opacity: 1, transform: "none", filter: "blur(0px)" },        ],        { duration: 420, delay: 220, easing: EASE, fill: "backwards" },      );      a.onfinish = () => setPhase("open");      return () => a.cancel();    }    if (phase === "closing") {      const done = () => {        panel.getAnimations().forEach((a) => a.cancel());        setPhase("idle");        if (landing?.save) {          setCurrent(landing.src);          setJustLanded(landing.src);        }        setLanding(null);        setPhoto(null);        returnFocus.current = !!rootRef.current?.contains(document.activeElement);      };      if (reduced) return done();      const height = panel.offsetHeight;      panel.animate([{ height: `${height}px` }, { height: "0px" }], { duration: MORPH - 40, easing: EASE, fill: "forwards" });      controlsRef.current?.animate([{ opacity: 1 }, { opacity: 0, filter: "blur(4px)" }], { duration: 160, easing: EASE, fill: "forwards" });      const a = surface.animate([openFrame, collapsedFrame()], { duration: MORPH, easing: EASE, fill: "forwards" });      // Cancelling hands over to the old photo: once the stage is nearly home it fades while the old one comes back.      if (!landing?.save) surface.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 240, delay: 220, easing: "linear", fill: "forwards" });      a.onfinish = done;      return () => a.cancel();    }    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [phase]);  const close = (save: { src: string } | null) => {    if (phaseRef.current !== "open") return;    setLanding(save ? { src: save.src, save: true } : { src: current ?? "", save: false });    setPhase("closing");  };  const cancel = () => {    close(null);    setAnnounce("Cancelled.");  };  const save = async () => {    if (status === "pending" || phase !== "open" || !imgRef.current) return;    setStatus("pending");    setAnnounce("Saving.");    try {      const blob = await crop.toBlob(imgRef.current, outputSize, outputType, outputQuality);      const area = crop.area()!;      const local = URL.createObjectURL(blob);      ownedUrls.current.add(local);      const stored = await onSave(blob, area);      const src = typeof stored === "string" && stored ? stored : local;      // Ready before the stage lands, so the hand-over is invisible.      const ready = new Image();      ready.src = src;      await ready.decode().catch(() => undefined);      setStatus("success");      setAnnounce("Photo saved.");      window.setTimeout(() => {        close({ src });        flash("Photo updated");      }, reduced ? 0 : 520);    } catch {      setStatus("error");      setAnnounce("Couldn't save the photo. Try again.");    }  };  const remove = async () => {    if (!onRemove || removing === "pending") return;    setRemoving("pending");    try {      await onRemove();      setRemoving("idle");      setCurrent(null);      flash("Photo removed");      setAnnounce("Photo removed.");    } catch {      setRemoving("error");    }  };  // ── Files from outside: drop and paste ─────────────────  const fromTransfer = (data: DataTransfer | null) => {    const file = data?.files?.[0] ?? [...(data?.items ?? [])].find((i) => i.kind === "file")?.getAsFile() ?? null;    if (file) open(file);    return !!file;  };  const dragProps = {    onDragOver: (e: React.DragEvent) => {      if (![...e.dataTransfer.types].includes("Files") || phase === "opening" || phase === "closing") return;      e.preventDefault();      e.dataTransfer.dropEffect = "copy";      if (!over) setOver(true);    },    onDragLeave: (e: React.DragEvent) => {      if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setOver(false);    },    onDrop: (e: React.DragEvent) => {      e.preventDefault();      setOver(false);      fromTransfer(e.dataTransfer);    },  };  const onStageKey = (e: React.KeyboardEvent) => {    const step = e.shiftKey ? 32 : 8;    const keys: Record<string, () => void> = {      ArrowLeft: () => crop.nudge(-step, 0),      ArrowRight: () => crop.nudge(step, 0),      ArrowUp: () => crop.nudge(0, -step),      ArrowDown: () => crop.nudge(0, step),      "+": () => crop.zoomBy(1.15),      "=": () => crop.zoomBy(1.15),      "-": () => crop.zoomBy(1 / 1.15),      r: () => rotate(),      R: () => rotate(),      Enter: () => save(),    };    const run = keys[e.key];    if (!run) return;    e.preventDefault();    run();  };  const rotate = () => {    crop.rotate();    setCommitted((c) => ({ ...c, r: c.r + 1 }));    setAnnounce(`Rotated to ${(((crop.state().r % 4) + 4) % 4) * 90}°.`);  };  // ── Render ─────────────────────────────────────────────  const editing = phase !== "idle";  const round = shape === "circle" ? "9999px" : `${Math.round(size * SQUARE)}px`;  const cover = photo ? d / Math.min(photo.w, photo.h) : 1;  const hole = holePath(shape, stageSize, d);  // What the slot shows: the current photo, or (once a save lands) the new one; nothing while the stage has it.  const landed = phase === "closing" && landing?.save ? landing.src : null;  const shownSrc = landed ?? current;  const slotVisible = phase === "idle" || (phase === "closing" && !landing?.save);  const layers = [...new Set([current, landed].filter((s): s is string => !!s))];  const line = note ?? (editing ? { text: "Drag to move, pinch or scroll to zoom", tone: "muted" as const, key: 0 } : over ? { text: "Drop to use it", tone: "muted" as const, key: 0 } : { text: hint, tone: "muted" as const, key: 0 });  return (    <div      ref={rootRef}      className={`w-full text-foreground ${className}`}      onPaste={(e) => {        if (fromTransfer(e.clipboardData)) e.preventDefault();      }}      onKeyDown={(e) => {        if (e.key === "Escape" && phase === "open") {          e.preventDefault();          cancel();        }      }}      {...props}    >      <input        ref={fileRef}        type="file"        accept={accept}        hidden        onChange={(e) => {          const file = e.target.files?.[0];          e.target.value = "";          if (file) open(file);        }}      />      <div className="flex items-center gap-4">        <button          ref={slotRef}          type="button"          onClick={browse}          disabled={editing}          aria-label={current ? `Change ${name}'s photo` : `Add a photo for ${name}`}          aria-describedby={`${uid}-line`}          {...dragProps}          className={`group/slot relative shrink-0 cursor-pointer disabled:cursor-default ${FOCUS}`}          style={{            width: size,            height: size,            borderRadius: round,            transform: over ? "scale(1.06)" : "none",            transition: reduced ? "none" : `transform 420ms ${THROW}`,          }}        >          <span className="absolute inset-0 overflow-hidden bg-muted" style={{ borderRadius: round }}>            <span              aria-hidden="true"              className="absolute inset-0 flex items-center justify-center font-medium text-muted-foreground"              style={{ ...swap(!shownSrc && slotVisible, reduced, phase === "closing" ? 200 : 0), fontSize: Math.round(size * 0.32) }}            >              {initials(name)}            </span>            {layers.map((src) => (              // eslint-disable-next-line @next/next/no-img-element              <img                key={src}                src={src}                alt=""                draggable={false}                className="absolute inset-0 size-full object-cover"                style={                  // The new photo takes over the moment the stage lands on it: no fade, it's already there.                  src === landed                    ? { opacity: 0, transition: "none" }                    : src === justLanded                      ? { opacity: 1, transition: "none" }                      : swap(src === shownSrc && slotVisible, reduced, phase === "closing" ? 200 : 0)                }              />            ))}            {/* Hover: a quiet dim and a camera, so the circle reads as the place to click or drop. */}            <span              aria-hidden="true"              className="absolute inset-0 flex items-center justify-center text-background opacity-0 transition-opacity duration-200 group-hover/slot:opacity-100 group-disabled/slot:!opacity-0"              style={{ background: "color-mix(in oklab, var(--foreground) 38%, transparent)" }}            >              <Camera className="size-[38%] max-h-5 max-w-5" strokeWidth={1.75} />            </span>          </span>          <span            aria-hidden="true"            className="pointer-events-none absolute inset-0 transition-shadow duration-200"            style={{ borderRadius: round, boxShadow: over ? "inset 0 0 0 1.5px var(--ring)" : "inset 0 0 0 1px var(--border)" }}          />        </button>        <div className="min-w-0 flex-1">          <p className="text-[14px] font-medium tracking-[-0.01em]">{title}</p>          <p id={`${uid}-line`} className={`mt-0.5 text-[12.5px] transition-colors duration-300 ${line.tone === "error" ? "text-red-600 dark:text-red-400" : "text-muted-foreground"}`}>            <Line text={line.text} reduced={reduced} />          </p>          <div            className="-ml-2.5 mt-1.5 flex items-center gap-0.5 transition-[opacity,filter] duration-300"            style={{ opacity: editing ? 0 : 1, filter: editing ? "blur(3px)" : "none" }}            inert={editing}          >            <StatusButton variant="ghost" size="sm" status="idle" labels={{ idle: current ? "Replace" : "Upload" }} onClick={browse} />            {onRemove && (              <span                className="grid transition-[grid-template-columns,opacity] duration-300"                style={{ gridTemplateColumns: current ? "1fr" : "0fr", opacity: current ? 1 : 0, transitionTimingFunction: EASE }}                inert={!current}              >                <span className="min-w-0 overflow-hidden">                  <StatusButton variant="ghost" size="sm" status={removing} labels={{ idle: "Remove", pending: "Removing", success: "Removed", error: "Try again" }} onClick={remove} />                </span>              </span>            )}          </div>        </div>      </div>      {/* The stage: the same circle, grown. Its height is the panel's, which eases open and shut. */}      <div ref={panelRef} style={{ height: editing ? undefined : 0 }}>        {editing && photo && (          <div className="pt-4">            <div              ref={surfaceRef}              className="relative overflow-hidden bg-muted will-change-transform"              style={{ width: stageSize, height: stageSize, borderRadius: RADIUS, transformOrigin: "50% 50%" }}              {...dragProps}            >              <div                ref={crop.refs.stage}                tabIndex={0}                role="group"                aria-roledescription="crop area"                aria-label={`Crop ${name}'s photo`}                aria-describedby={`${uid}-how`}                onKeyDown={onStageKey}                {...crop.handlers}                className={`absolute inset-0 touch-none select-none outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring ${                  phase === "open" ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"                }`}                style={{ borderRadius: RADIUS }}              >                <div ref={crop.refs.pan} className="absolute left-1/2 top-1/2 size-0">                  <div ref={crop.refs.zoom} className="absolute left-0 top-0 size-0">                    <div ref={crop.refs.rot} className="absolute left-0 top-0 size-0">                      {/* eslint-disable-next-line @next/next/no-img-element */}                      <img                        ref={imgRef}                        src={photo.src}                        alt=""                        draggable={false}                        className="pointer-events-none absolute max-w-none select-none"                        style={{ width: photo.w * cover, height: photo.h * cover, left: (-photo.w * cover) / 2, top: (-photo.h * cover) / 2 }}                      />                    </div>                  </div>                </div>                <svg aria-hidden="true" className="pointer-events-none absolute inset-0 size-full" viewBox={`0 0 ${stageSize} ${stageSize}`}>                  <path fillRule="evenodd" d={`M0 0H${stageSize}V${stageSize}H0Z${hole}`} style={{ fill: "color-mix(in oklab, var(--background) 64%, transparent)" }} />                  <clipPath id={`${uid}-hole`}>                    <path d={hole} />                  </clipPath>                  {/* Thirds, only while a finger is on the photo. */}                  <g ref={crop.refs.grid} clipPath={`url(#${uid}-hole)`} style={{ opacity: 0, transition: reduced ? "none" : `opacity 240ms ${EASE}` }}>                    {[1, 2].map((i) => (                      <React.Fragment key={i}>                        <path d={`M${MARGIN + (d * i) / 3} ${MARGIN}V${MARGIN + d}`} style={{ stroke: "color-mix(in oklab, var(--background) 55%, transparent)" }} strokeWidth={1} />                        <path d={`M${MARGIN} ${MARGIN + (d * i) / 3}H${MARGIN + d}`} style={{ stroke: "color-mix(in oklab, var(--background) 55%, transparent)" }} strokeWidth={1} />                      </React.Fragment>                    ))}                  </g>                  <path d={hole} fill="none" style={{ stroke: "var(--background)" }} strokeOpacity={0.9} strokeWidth={1.25} />                </svg>              </div>            </div>            <div ref={controlsRef} style={{ width: stageSize }}>              <div className="mt-3 flex items-center gap-1">                <IconButton label="Zoom out" onClick={() => crop.zoomBy(1 / 1.25)} disabled={committed.z <= 1.001}>                  <Minus strokeWidth={1.75} />                </IconButton>                <input                  ref={crop.refs.slider}                  type="range"                  min={1}                  max={maxZoom}                  step={0.01}                  defaultValue={1}                  aria-label="Zoom"                  aria-valuetext={`${Math.round(committed.z * 100)}%`}                  onInput={(e) => crop.zoomTo(Number(e.currentTarget.value))}                  onPointerUp={() => crop.settle()}                  onKeyUp={() => crop.settle()}                  onBlur={() => crop.settle()}                  className={`h-7 min-w-0 flex-1 cursor-pointer appearance-none bg-transparent focus-visible:outline-none [&::-moz-range-thumb]:size-3.5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-[0_0_0_1px_var(--foreground)] [&::-moz-range-track]:h-px [&::-moz-range-track]:bg-border [&::-webkit-slider-runnable-track]:h-px [&::-webkit-slider-runnable-track]:bg-border [&::-webkit-slider-thumb]:-mt-[6.5px] [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-[0_0_0_1px_var(--foreground)] focus-visible:[&::-webkit-slider-thumb]:shadow-[0_0_0_2px_var(--ring)] focus-visible:[&::-moz-range-thumb]:shadow-[0_0_0_2px_var(--ring)]`}                />                <IconButton label="Zoom in" onClick={() => crop.zoomBy(1.25)} disabled={committed.z >= maxZoom - 0.001}>                  <Plus strokeWidth={1.75} />                </IconButton>                <span className="w-11 shrink-0 text-right text-[12px] tabular-nums text-muted-foreground" aria-hidden="true">                  <NumberRoll value={Math.round(committed.z * 100)} suffix="%" duration={700} />                </span>                <IconButton label="Rotate a quarter turn" onClick={rotate}>                  <RotateCw strokeWidth={1.75} />                </IconButton>              </div>              <div className="mt-2.5 flex items-center justify-end gap-1.5">                <button                  type="button"                  onClick={cancel}                  disabled={status === "pending" || status === "success"}                  className={`inline-flex h-9 cursor-pointer items-center rounded-full px-4 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-50 ${FOCUS}`}                >                  Cancel                </button>                <StatusButton status={status} labels={{ idle: "Save", pending: "Saving", success: "Saved", error: "Try again" }} onClick={save} />              </div>            </div>          </div>        )}      </div>      <p id={`${uid}-how`} className="sr-only">        Arrow keys move the photo, plus and minus zoom, R rotates, Enter saves and Escape cancels.      </p>      <p className="sr-only" role="status" aria-live="polite">        {announce}      </p>    </div>  );}
components/use-crop-gesture.ts
"use client";import * as React from "react";/* ───────────────────────────────────────────────────────── * CROP GESTURE: a photo you hold, not a box you drag * *   pan       the photo follows the finger exactly; past an *             edge it resists more the further it goes, the way *             iOS Photos does, so the crop never shows a gap *   pinch     two fingers zoom about the point between them; *             wheel and trackpad pinch zoom about the pointer; *             below the fit or past the limit it resists too *   release   it keeps the speed you let go with and slows *             down; anything out of bounds springs back, *             critically damped, carrying that speed *   rotate    quarter turns, and the bounds re-solve so the *             photo still covers the crop * * One frame loop with fixed 4ms steps, writing transforms to * three nested layers (pan, zoom, rotate) and sleeping the * moment everything is still. React hears about it once, when * it settles. Layers are promoted only while they move, so the * photo re-rasterises sharp at its new size. * ───────────────────────────────────────────────────────── */export type CropState = { x: number; y: number; z: number; r: number };/** The crop in the photo's own pixels: its centre, the side of the square, and the quarter turns applied (degrees). */export type CropArea = { centerX: number; centerY: number; size: number; rotation: number };type Options = {  /** Side of the crop region at full size, px. */  size: number;  /** Natural size of the photo. */  image: { w: number; h: number } | null;  maxZoom: number;  reduced: boolean;  /** Called once a gesture, wheel, key or spring has settled. */  onSettle?: (state: CropState) => void;};type Vec = { x: number; y: number };const K = 230; // spring stiffness, 1/s²const C = 2 * Math.sqrt(K); // critical dampingconst FRICTION = 4.2; // momentum decay, 1/sconst STEP = 4; // msconst THROW = "cubic-bezier(0.34,1.36,0.64,1)";const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));/** Resistance past an edge: the further over, the less it gives (iOS's 0.55 constant). */const band = (over: number, dim: number) => Math.sign(over) * (1 - 1 / ((Math.abs(over) * 0.55) / dim + 1)) * dim;/** The raw overshoot that shows as `shown` once banded, so a gesture can start from a photo that's mid-spring. */const unband = (shown: number, dim: number) => {  const u = Math.min(0.95, Math.abs(shown) / dim);  return Math.sign(shown) * (1 / (1 - u) - 1) * (dim / 0.55);};export function useCropGesture({ size, image, maxZoom, reduced, onSettle }: Options) {  const pan = React.useRef<HTMLDivElement>(null);  const zoom = React.useRef<HTMLDivElement>(null);  const rot = React.useRef<HTMLDivElement>(null);  const slider = React.useRef<HTMLInputElement>(null);  const grid = React.useRef<SVGGElement>(null);  const stage = React.useRef<HTMLDivElement | null>(null);  const state = React.useRef<CropState>({ x: 0, y: 0, z: 1, r: 0 });  const vel = React.useRef({ x: 0, y: 0, z: 0 });  const goal = React.useRef<Partial<CropState> | null>(null);  const loop = React.useRef(0);  const lastFrame = React.useRef(0);  const pointers = React.useRef(new Map<number, Vec>());  const hold = React.useRef<{ x: number; y: number; z: number; mid: Vec; dist: number } | null>(null);  const samples = React.useRef<{ x: number; y: number; t: number }[]>([]);  const wheelTimer = React.useRef(0);  const opts = React.useRef({ size, image, maxZoom, reduced, onSettle });  opts.current = { size, image, maxZoom, reduced, onSettle };  /** How far the photo may move each way at zoom z and rotation r before it would uncover the crop. */  const limits = (z: number, r: number): Vec => {    const { image: img, size: d } = opts.current;    if (!img) return { x: 0, y: 0 };    const cover = d / Math.min(img.w, img.h);    const w = (r % 2 ? img.h : img.w) * cover * z;    const h = (r % 2 ? img.w : img.h) * cover * z;    return { x: Math.max(0, (w - d) / 2), y: Math.max(0, (h - d) / 2) };  };  const promote = (on: boolean) => {    for (const el of [pan.current, zoom.current]) if (el) el.style.willChange = on ? "transform" : "";    if (grid.current) grid.current.style.opacity = on && hold.current ? "1" : "0";  };  const write = () => {    const s = state.current;    if (pan.current) pan.current.style.transform = `translate3d(${s.x}px, ${s.y}px, 0)`;    if (zoom.current) zoom.current.style.transform = `scale(${s.z})`;    const input = slider.current;    if (input && document.activeElement !== input) input.value = String(clamp(s.z, 1, opts.current.maxZoom));  };  const settle = () => {    cancelAnimationFrame(loop.current);    loop.current = 0;    vel.current = { x: 0, y: 0, z: 0 };    goal.current = null;    promote(false);    opts.current.onSettle?.({ ...state.current });  };  const tick = (now: number) => {    const dt = Math.min(48, now - (lastFrame.current || now - 16));    lastFrame.current = now;    const s = state.current;    const v = vel.current;    const { maxZoom: max } = opts.current;    const steps = Math.max(1, Math.round(dt / STEP));    const h = dt / steps / 1000;    for (let i = 0; i < steps; i++) {      const zTo = clamp(goal.current?.z ?? s.z, 1, max);      v.z += (-K * (s.z - zTo) - C * v.z) * h;      s.z += v.z * h;      const lim = limits(s.z, s.r);      for (const axis of ["x", "y"] as const) {        const target = goal.current?.[axis];        const bound = lim[axis];        if (target !== undefined || Math.abs(s[axis]) > bound) {          // A goal (keys, rotation) or out of bounds: spring there, keeping whatever speed it has.          const to = clamp(target ?? s[axis], -bound, bound);          v[axis] += (-K * (s[axis] - to) - C * v[axis]) * h;        } else v[axis] *= Math.exp(-FRICTION * h);        s[axis] += v[axis] * h;      }    }    write();    const lim = limits(s.z, s.r);    const zTo = clamp(goal.current?.z ?? s.z, 1, max);    const still =      Math.abs(v.x) < 4 &&      Math.abs(v.y) < 4 &&      Math.abs(v.z) < 0.01 &&      Math.abs(s.z - zTo) < 0.0008 &&      Math.abs(s.x) <= lim.x + 0.3 &&      Math.abs(s.y) <= lim.y + 0.3 &&      (goal.current?.x === undefined || Math.abs(s.x - clamp(goal.current.x, -lim.x, lim.x)) < 0.3) &&      (goal.current?.y === undefined || Math.abs(s.y - clamp(goal.current.y, -lim.y, lim.y)) < 0.3);    if (still) {      s.z = zTo;      const end = limits(s.z, s.r);      s.x = clamp(goal.current?.x ?? s.x, -end.x, end.x);      s.y = clamp(goal.current?.y ?? s.y, -end.y, end.y);      write();      settle();      return;    }    loop.current = requestAnimationFrame(tick);  };  const run = () => {    if (opts.current.reduced) {      // No springs: land where it would have, at once.      const s = state.current;      s.z = clamp(goal.current?.z ?? s.z, 1, opts.current.maxZoom);      const lim = limits(s.z, s.r);      s.x = clamp(goal.current?.x ?? s.x, -lim.x, lim.x);      s.y = clamp(goal.current?.y ?? s.y, -lim.y, lim.y);      write();      settle();      return;    }    promote(true);    if (!loop.current) {      lastFrame.current = 0;      loop.current = requestAnimationFrame(tick);    }  };  const stop = () => {    cancelAnimationFrame(loop.current);    loop.current = 0;  };  React.useEffect(() => () => cancelAnimationFrame(loop.current), []);  // ── Pointers ───────────────────────────────────────────  const centre = (p: Vec): Vec => {    const r = stage.current!.getBoundingClientRect();    return { x: p.x - (r.left + r.width / 2), y: p.y - (r.top + r.height / 2) };  };  const midpoint = (): { mid: Vec; dist: number } => {    const ps = [...pointers.current.values()];    if (ps.length < 2) return { mid: centre(ps[0]), dist: 0 };    return { mid: centre({ x: (ps[0].x + ps[1].x) / 2, y: (ps[0].y + ps[1].y) / 2 }), dist: Math.hypot(ps[0].x - ps[1].x, ps[0].y - ps[1].y) };  };  /** Start (or restart, when a finger joins or leaves) from what's shown, unbanded so nothing jumps. */  const begin = () => {    const s = state.current;    const { maxZoom: max } = opts.current;    const z = s.z < 1 ? 1 - (1 - s.z) / 0.35 : s.z > max ? max + (s.z - max) / 0.3 : s.z;    const lim = limits(s.z, s.r);    const d = opts.current.size;    const raw = (p: number, bound: number) => (Math.abs(p) > bound ? Math.sign(p) * bound + unband(p - Math.sign(p) * bound, d) : p);    const { mid, dist } = midpoint();    hold.current = { x: raw(s.x, lim.x), y: raw(s.y, lim.y), z, mid, dist };    samples.current = [];  };  const follow = () => {    const g = hold.current;    if (!g) return;    const s = state.current;    const { maxZoom: max, size: d } = opts.current;    const { mid, dist } = midpoint();    let z = g.z;    let x = g.x + (mid.x - g.mid.x);    let y = g.y + (mid.y - g.mid.y);    if (pointers.current.size > 1 && g.dist > 0) {      z = g.z * (dist / g.dist);      // The point of the photo that was under the fingers stays under them.      x = mid.x - (g.mid.x - g.x) * (z / g.z);      y = mid.y - (g.mid.y - g.y) * (z / g.z);    }    s.z = z < 1 ? Math.max(0.6, 1 - (1 - z) * 0.35) : z > max ? max + (z - max) * 0.3 : z;    const lim = limits(s.z, s.r);    s.x = Math.abs(x) > lim.x ? Math.sign(x) * lim.x + band(x - Math.sign(x) * lim.x, d) : x;    s.y = Math.abs(y) > lim.y ? Math.sign(y) * lim.y + band(y - Math.sign(y) * lim.y, d) : y;    write();    const t = performance.now();    samples.current.push({ x: s.x, y: s.y, t });    while (samples.current.length > 2 && t - samples.current[0].t > 90) samples.current.shift();  };  const onPointerDown = (e: React.PointerEvent<HTMLElement>) => {    if (!opts.current.image || (e.pointerType === "mouse" && e.button !== 0)) return;    e.currentTarget.setPointerCapture(e.pointerId);    pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });    // Catch it mid-flight: whatever was moving stops under the finger.    stop();    vel.current = { x: 0, y: 0, z: 0 };    goal.current = null;    begin();    promote(true);  };  const onPointerMove = (e: React.PointerEvent<HTMLElement>) => {    if (!pointers.current.has(e.pointerId)) return;    pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });    follow();  };  const onPointerUp = (e: React.PointerEvent<HTMLElement>) => {    if (!pointers.current.delete(e.pointerId)) return;    if (pointers.current.size) return begin();    const ss = samples.current;    const first = ss[0];    const last = ss[ss.length - 1];    const fresh = last && performance.now() - last.t < 80 && last.t > first.t;    vel.current = fresh ? { x: ((last.x - first.x) / (last.t - first.t)) * 1000, y: ((last.y - first.y) / (last.t - first.t)) * 1000, z: 0 } : { x: 0, y: 0, z: 0 };    hold.current = null;    run();  };  // Wheel and trackpad pinch zoom about the pointer. Not passive: the page shouldn't scroll under the photo.  React.useEffect(() => {    const el = stage.current;    if (!el) return;    const onWheel = (e: WheelEvent) => {      if (!opts.current.image) return;      e.preventDefault();      stop();      const s = state.current;      const { maxZoom: max } = opts.current;      const z0 = s.z;      const z1 = clamp(z0 * Math.exp(-e.deltaY * (e.ctrlKey ? 0.012 : 0.0016)), 1, max);      const p = centre({ x: e.clientX, y: e.clientY });      const lim = limits(z1, s.r);      s.x = clamp(p.x - (p.x - s.x) * (z1 / z0), -lim.x, lim.x);      s.y = clamp(p.y - (p.y - s.y) * (z1 / z0), -lim.y, lim.y);      s.z = z1;      promote(true);      write();      window.clearTimeout(wheelTimer.current);      wheelTimer.current = window.setTimeout(settle, 160);    };    el.addEventListener("wheel", onWheel, { passive: false });    return () => {      el.removeEventListener("wheel", onWheel);      window.clearTimeout(wheelTimer.current);    };    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [image]);  // ── Commands ───────────────────────────────────────────  /** Zoom to z about the centre at once (the slider). Call settle() yourself when the slider lets go. */  const zoomTo = (z: number) => {    stop();    const s = state.current;    const z1 = clamp(z, 1, opts.current.maxZoom);    const lim = limits(z1, s.r);    s.x = clamp(s.x * (z1 / s.z), -lim.x, lim.x);    s.y = clamp(s.y * (z1 / s.z), -lim.y, lim.y);    s.z = z1;    promote(true);    write();  };  /** Glide by a step (keys, buttons): the spring takes it there and it settles. */  const nudge = (dx: number, dy: number) => {    const s = state.current;    goal.current = { x: (goal.current?.x ?? s.x) + dx, y: (goal.current?.y ?? s.y) + dy, z: goal.current?.z ?? s.z };    run();  };  const zoomBy = (factor: number) => {    const s = state.current;    const z0 = goal.current?.z ?? s.z;    const z1 = clamp(z0 * factor, 1, opts.current.maxZoom);    goal.current = { x: (goal.current?.x ?? s.x) * (z1 / z0), y: (goal.current?.y ?? s.y) * (z1 / z0), z: z1 };    run();  };  /** A quarter turn clockwise, thrown with a slight overshoot; the photo then springs inside the new bounds. */  const rotate = () => {    const s = state.current;    const from = s.r * 90;    s.r += 1;    const el = rot.current;    if (el) {      el.style.transform = `rotate(${s.r * 90}deg)`;      if (!opts.current.reduced) el.animate([{ transform: `rotate(${from}deg)` }, { transform: `rotate(${s.r * 90}deg)` }], { duration: 560, easing: THROW });    }    goal.current = { x: s.x, y: s.y, z: s.z };    run();  };  /** Back to the fit for a new photo. */  const reset = () => {    stop();    state.current = { x: 0, y: 0, z: 1, r: 0 };    vel.current = { x: 0, y: 0, z: 0 };    goal.current = null;    if (rot.current) rot.current.style.transform = "rotate(0deg)";    write();  };  /** Where the crop sits in the photo's own pixels. */  const area = (): CropArea | null => {    const { image: img, size: d } = opts.current;    if (!img) return null;    const s = state.current;    const k = (d / Math.min(img.w, img.h)) * s.z;    const turn = ((s.r % 4) + 4) % 4;    const a = (-turn * Math.PI) / 2;    // The crop's centre relative to the photo's centre, on screen, turned back into the photo's own frame.    const dx = -s.x;    const dy = -s.y;    const ux = (dx * Math.cos(a) - dy * Math.sin(a)) / k;    const uy = (dx * Math.sin(a) + dy * Math.cos(a)) / k;    return { centerX: img.w / 2 + ux, centerY: img.h / 2 + uy, size: d / k, rotation: turn * 90 };  };  /** Draws the crop into a square canvas of `output` px and encodes it. */  const toBlob = async (img: HTMLImageElement, output: number, type: string, quality: number): Promise<Blob> => {    const { image: dims, size: d } = opts.current;    if (!dims) throw new Error("No photo");    const s = state.current;    const canvas = document.createElement("canvas");    canvas.width = output;    canvas.height = output;    const ctx = canvas.getContext("2d");    if (!ctx) throw new Error("No canvas");    const k = (d / Math.min(dims.w, dims.h)) * s.z;    ctx.imageSmoothingQuality = "high";    ctx.translate(output / 2, output / 2);    ctx.scale(output / d, output / d);    ctx.translate(s.x, s.y);    ctx.rotate((s.r * Math.PI) / 2);    ctx.scale(k, k);    ctx.drawImage(img, -dims.w / 2, -dims.h / 2, dims.w, dims.h);    return new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("Couldn't encode the photo"))), type, quality));  };  return {    refs: { pan, zoom, rot, slider, grid, stage },    handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel: onPointerUp },    state: () => ({ ...state.current }),    zoomTo,    settle,    nudge,    zoomBy,    rotate,    reset,    area,    toBlob,  };}

Registry item: /r/avatar-cropper.json