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