{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-cropper",
  "title": "Image Cropper",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Crop a picture under a fixed frame: drag to move, wheel, pinch or slider to zoom, straighten and turn by quarters, any aspect ratio or a round avatar, full keyboard control and a canvas export to a file.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/image-cropper.tsx",
      "content": "// Ballmac UI: Image Cropper. https://ui.ballmac.com/components/image-cropper\n\"use client\"\n\nimport * as React from \"react\"\nimport { ImageOff, RotateCcw, RotateCw, ZoomIn, ZoomOut } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\nimport { cn } from \"@/lib/utils\"\n\n/** How the picture sits under the frame. `x` and `y` move its center from the frame's center, in pixels on screen. `zoom` is 1 at the smallest the picture may be. */\ntype Crop = { x: number; y: number; zoom: number; /** Degrees, any amount. */ rotation: number }\n\n/** The cropped part, in pixels of the original picture. With a rotation it is the box around the rotated frame. */\ntype CropArea = { x: number; y: number; width: number; height: number }\n\ntype CropResult = { crop: Crop; area: CropArea; /** The crop frame's width and height on screen. */ frame: { width: number; height: number } }\n\ntype CropImageOptions = {\n  /** Width of the result in pixels. The height follows the frame. Defaults to the cropped area's own width, at most `maxWidth`. */\n  width?: number\n  maxWidth?: number\n  type?: \"image/png\" | \"image/jpeg\" | \"image/webp\"\n  quality?: number\n  /** Cut the corners away, for a round avatar. A PNG or WebP keeps them see-through; a JPEG fills them with `background`. */\n  round?: boolean\n  background?: string\n}\n\ntype ImageCropperHandle = {\n  /** The crop as it is now. */\n  getCrop: () => CropResult | null\n  /** The cropped picture as a file. */\n  toBlob: (options?: CropImageOptions) => Promise<Blob>\n  reset: () => void\n}\n\ntype ImageCropperProps = Omit<React.ComponentProps<\"div\">, \"onChange\" | \"defaultValue\" | \"ref\"> & {\n  /** The picture: a web address or an object URL. */\n  src: string\n  /** Width divided by height of the frame: 1 for a square, 16 / 9 for a banner. */\n  aspect?: number\n  /** A round frame shows what a round avatar will keep. */\n  shape?: \"rect\" | \"round\"\n  value?: Crop\n  defaultValue?: Partial<Crop>\n  onCropChange?: (crop: Crop) => void\n  /** Called when a drag, a key press or a slider move ends, with the crop in pixels of the original. */\n  onCropComplete?: (result: CropResult) => void\n  /** Zoom the picture can reach. */\n  maxZoom?: number\n  /** Height of the picture area. */\n  height?: number\n  /** Show thirds lines over the frame. */\n  grid?: boolean\n  /** Zoom with the mouse wheel. Off if the cropper sits in a page people scroll through. */\n  zoomOnWheel?: boolean\n  /** Hide the zoom, straighten and rotate controls, to place your own. */\n  controls?: boolean\n  /** Written for a screen reader: what the picture shows. */\n  alt?: string\n  /** `crossorigin` for the picture. Needed to export one from another site. */\n  crossOrigin?: \"anonymous\" | \"use-credentials\" | \"\"\n  onError?: (error: Error) => void\n  /** Gives you `toBlob`, `getCrop` and `reset`. */\n  cropperRef?: React.Ref<ImageCropperHandle>\n}\n\n// ------------------------------------------------------------------------------------------------- geometry (pure, so it can be tested)\n\nconst rad = (d: number) => (d * Math.PI) / 180\nconst clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n))\n\n/** The smallest scale at which a picture of this size, turned by `rotation`, still covers the frame. */\nexport function coverScale(image: { width: number; height: number }, frame: { width: number; height: number }, rotation: number) {\n  const t = rad(rotation)\n  const cos = Math.abs(Math.cos(t))\n  const sin = Math.abs(Math.sin(t))\n  // the frame's corners, turned into the picture's own axes, must lie inside it\n  const needW = (frame.width * cos + frame.height * sin) / image.width\n  const needH = (frame.width * sin + frame.height * cos) / image.height\n  return Math.max(needW, needH)\n}\n\n/** Keeps the picture over the frame: moves `offset` to the nearest place where no corner of the frame is outside it. */\nexport function clampOffset(offset: { x: number; y: number }, image: { width: number; height: number }, frame: { width: number; height: number }, scale: number, rotation: number) {\n  const t = rad(rotation)\n  const cos = Math.cos(t)\n  const sin = Math.sin(t)\n  const halfW = (image.width * scale) / 2\n  const halfH = (image.height * scale) / 2\n  let { x, y } = offset\n  // In the picture's axes the frame is a turned box. Its centre may move until a corner touches the edge: check each of the four corners and\n  // push back along the picture's axes. A few passes settle it, because pushing along one axis can free the other.\n  const corners = [\n    [frame.width / 2, frame.height / 2],\n    [-frame.width / 2, frame.height / 2],\n    [frame.width / 2, -frame.height / 2],\n    [-frame.width / 2, -frame.height / 2],\n  ] as const\n  for (let pass = 0; pass < 24; pass++) {\n    let moved = false\n    for (const [cx, cy] of corners) {\n      // the corner in the picture's axes, relative to the picture's centre: x along (cos, sin), y along (-sin, cos)\n      const rx = cx - x\n      const ry = cy - y\n      const px = rx * cos + ry * sin\n      const py = -rx * sin + ry * cos\n      const overX = Math.abs(px) - halfW\n      const overY = Math.abs(py) - halfH\n      if (overX > 1e-9) {\n        const d = Math.sign(px) * overX\n        x += d * cos\n        y += d * sin\n        moved = true\n      }\n      if (overY > 1e-9) {\n        const d = Math.sign(py) * overY\n        x -= d * sin\n        y += d * cos\n        moved = true\n      }\n    }\n    if (!moved) break\n  }\n  return { x, y }\n}\n\n/** The part of the original picture inside the frame. */\nexport function cropArea(image: { width: number; height: number }, frame: { width: number; height: number }, crop: Crop, baseScale: number): CropArea {\n  const scale = baseScale * crop.zoom\n  const t = rad(crop.rotation)\n  const cos = Math.abs(Math.cos(t))\n  const sin = Math.abs(Math.sin(t))\n  const boxW = (frame.width * cos + frame.height * sin) / scale\n  const boxH = (frame.width * sin + frame.height * cos) / scale\n  // the frame's centre in the picture's own pixels\n  const c = Math.cos(rad(crop.rotation))\n  const s = Math.sin(rad(crop.rotation))\n  const px = (-crop.x * c - crop.y * s) / scale\n  const py = (crop.x * s - crop.y * c) / scale\n  const cx = image.width / 2 + px\n  const cy = image.height / 2 + py\n  return { x: Math.round(cx - boxW / 2), y: Math.round(cy - boxH / 2), width: Math.round(boxW), height: Math.round(boxH) }\n}\n\n/** The canvas transform that draws the cropped picture at `outWidth` pixels across: translate, rotate, scale, in that order. */\nexport function cropTransform(frame: { width: number; height: number }, crop: Crop, baseScale: number, outWidth: number) {\n  const k = outWidth / frame.width\n  return { outWidth, outHeight: Math.round(frame.height * k), translateX: (frame.width / 2 + crop.x) * k, translateY: (frame.height / 2 + crop.y) * k, rotation: rad(crop.rotation), scale: baseScale * crop.zoom * k }\n}\n\n/** Draws the crop to a canvas and returns it as a file. The picture must be same-origin or served with CORS headers. */\nexport async function cropImage(image: HTMLImageElement, frame: { width: number; height: number }, crop: Crop, baseScale: number, options: CropImageOptions = {}): Promise<Blob> {\n  const { maxWidth = 2048, type = \"image/png\", quality = 0.92, round = false, background = \"rgb(255, 255, 255)\" } = options\n  const natural = baseScale * crop.zoom\n  const width = Math.round(clamp(options.width ?? frame.width / natural, 1, maxWidth))\n  const t = cropTransform(frame, crop, baseScale, width)\n  const canvas = document.createElement(\"canvas\")\n  canvas.width = t.outWidth\n  canvas.height = t.outHeight\n  const ctx = canvas.getContext(\"2d\")\n  if (!ctx) throw new Error(\"A canvas is not available.\")\n  if (type === \"image/jpeg\") {\n    ctx.fillStyle = background\n    ctx.fillRect(0, 0, canvas.width, canvas.height)\n  }\n  if (round) {\n    ctx.beginPath()\n    ctx.ellipse(canvas.width / 2, canvas.height / 2, canvas.width / 2, canvas.height / 2, 0, 0, Math.PI * 2)\n    ctx.clip()\n  }\n  ctx.imageSmoothingQuality = \"high\"\n  ctx.translate(t.translateX, t.translateY)\n  ctx.rotate(t.rotation)\n  ctx.scale(t.scale, t.scale)\n  ctx.drawImage(image, -image.naturalWidth / 2, -image.naturalHeight / 2)\n  return new Promise<Blob>((resolve, reject) => {\n    try {\n      canvas.toBlob((b) => (b ? resolve(b) : reject(new Error(\"The picture could not be made.\"))), type, quality)\n    } catch (e) {\n      reject(e instanceof Error ? e : new Error(String(e)))\n    }\n  })\n}\n\n// ------------------------------------------------------------------------------------------------- component\n\nconst START: Crop = { x: 0, y: 0, zoom: 1, rotation: 0 }\nconst STEP = 10\n\n/**\n * Crops a picture with a frame that stays still while the picture moves under it: drag to move, wheel, pinch or slider to zoom, a slider to straighten\n * and buttons to turn it a quarter. The frame can be any shape from `aspect`, and `toBlob` gives you the result as a file. Every control has a key.\n */\nfunction ImageCropper({\n  src,\n  aspect = 1,\n  shape = \"rect\",\n  value,\n  defaultValue,\n  onCropChange,\n  onCropComplete,\n  maxZoom = 4,\n  height = 320,\n  grid = true,\n  zoomOnWheel = true,\n  controls = true,\n  alt,\n  crossOrigin = \"anonymous\",\n  onError,\n  className,\n  cropperRef,\n  ...props\n}: ImageCropperProps) {\n  const msg = useMessages()\n  const id = React.useId()\n  const box = React.useRef<HTMLDivElement>(null)\n  const img = React.useRef<HTMLImageElement>(null)\n  const [status, setStatus] = React.useState<\"loading\" | \"ready\" | \"error\">(\"loading\")\n  const [natural, setNatural] = React.useState({ width: 0, height: 0 })\n  const [size, setSize] = React.useState({ width: 480, height })\n  const [inner, setInner] = React.useState<Crop>({ ...START, ...defaultValue })\n  const crop = value ?? inner\n  const [dragging, setDragging] = React.useState(false)\n  const [spoken, setSpoken] = React.useState(\"\")\n  const [attempt, setAttempt] = React.useState(0)\n  const pointers = React.useRef(new Map<number, { x: number; y: number }>())\n  const pinch = React.useRef<{ distance: number; zoom: number } | null>(null)\n  const onCompleteRef = React.useRef(onCropComplete)\n  onCompleteRef.current = onCropComplete\n\n  React.useEffect(() => {\n    setStatus(\"loading\")\n    // A picture that finished loading before this page was hydrated has already fired its load event, so ask it. It is ready if it has a size,\n    // and has failed if it finished with none.\n    const el = img.current\n    if (el?.complete && src) {\n      if (el.naturalWidth > 0) {\n        setNatural({ width: el.naturalWidth, height: el.naturalHeight })\n        setStatus(\"ready\")\n      } else setStatus(\"error\")\n    }\n  }, [src, attempt])\n\n  // the box's width is whatever the page gives it\n  React.useEffect(() => {\n    const el = box.current\n    if (!el || typeof ResizeObserver === \"undefined\") return\n    const ro = new ResizeObserver(() => setSize({ width: el.clientWidth || 480, height: el.clientHeight || height }))\n    ro.observe(el)\n    setSize({ width: el.clientWidth || 480, height: el.clientHeight || height })\n    return () => ro.disconnect()\n  }, [height])\n\n  // the frame: the biggest box of this shape that fits with some air around it\n  const frame = React.useMemo(() => {\n    const room = { width: Math.max(size.width - 32, 40), height: Math.max(size.height - 32, 40) }\n    const w = Math.min(room.width, room.height * aspect)\n    return { width: Math.round(w), height: Math.round(w / aspect) }\n  }, [size, aspect])\n\n  const base = natural.width ? coverScale(natural, frame, crop.rotation) : 1\n  const scale = base * crop.zoom\n\n  const set = React.useCallback(\n    (next: Partial<Crop>, done = false) => {\n      const zoom = clamp(next.zoom ?? crop.zoom, 1, maxZoom)\n      const rotation = next.rotation ?? crop.rotation\n      const b = natural.width ? coverScale(natural, frame, rotation) : 1\n      const kept = natural.width ? clampOffset({ x: next.x ?? crop.x, y: next.y ?? crop.y }, natural, frame, b * zoom, rotation) : { x: 0, y: 0 }\n      const out: Crop = { x: kept.x, y: kept.y, zoom, rotation }\n      if (value === undefined) setInner(out)\n      onCropChange?.(out)\n      if (done && natural.width) onCompleteRef.current?.({ crop: out, area: cropArea(natural, frame, out, b), frame })\n    },\n    [crop, frame, natural, maxZoom, value, onCropChange]\n  )\n\n  // the frame or the picture changed size: keep the picture over it\n  React.useEffect(() => {\n    if (status === \"ready\") set({}, false)\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [frame.width, frame.height, natural.width, status])\n\n  React.useImperativeHandle(\n    cropperRef,\n    () => ({\n      getCrop: () => (natural.width ? { crop, area: cropArea(natural, frame, crop, base), frame } : null),\n      toBlob: (options) => {\n        const el = img.current\n        if (!el || !natural.width) return Promise.reject(new Error(\"The picture has not loaded yet.\"))\n        return cropImage(el, frame, crop, base, { round: shape === \"round\", ...options })\n      },\n      reset: () => set({ ...START }, true),\n    }),\n    [natural, crop, frame, base, shape, set]\n  )\n\n  // ----- pointer: drag to move, two fingers to zoom\n  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (status !== \"ready\") return\n    e.currentTarget.setPointerCapture(e.pointerId)\n    pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })\n    setDragging(true)\n    if (pointers.current.size === 2) {\n      const [a, b] = [...pointers.current.values()]\n      pinch.current = { distance: Math.hypot(a!.x - b!.x, a!.y - b!.y), zoom: crop.zoom }\n    }\n  }\n  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    const prev = pointers.current.get(e.pointerId)\n    if (!prev) return\n    const now = { x: e.clientX, y: e.clientY }\n    pointers.current.set(e.pointerId, now)\n    if (pointers.current.size === 2 && pinch.current) {\n      const [a, b] = [...pointers.current.values()]\n      const d = Math.hypot(a!.x - b!.x, a!.y - b!.y)\n      set({ zoom: pinch.current.zoom * (d / pinch.current.distance) })\n      return\n    }\n    if (pointers.current.size === 1) set({ x: crop.x + (now.x - prev.x), y: crop.y + (now.y - prev.y) })\n  }\n  const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (!pointers.current.delete(e.pointerId)) return\n    pinch.current = null\n    if (pointers.current.size === 0) {\n      setDragging(false)\n      set({}, true)\n    }\n  }\n  const wheel = React.useRef({ zoomOnWheel, set, crop })\n  wheel.current = { zoomOnWheel, set, crop }\n  React.useEffect(() => {\n    const el = box.current\n    if (!el) return\n    let t: ReturnType<typeof setTimeout> | undefined\n    const onWheel = (e: WheelEvent) => {\n      if (!wheel.current.zoomOnWheel || status !== \"ready\") return\n      e.preventDefault()\n      wheel.current.set({ zoom: wheel.current.crop.zoom * Math.exp(-e.deltaY * 0.0015) })\n      clearTimeout(t)\n      t = setTimeout(() => wheel.current.set({}, true), 150)\n    }\n    el.addEventListener(\"wheel\", onWheel, { passive: false })\n    return () => {\n      el.removeEventListener(\"wheel\", onWheel)\n      clearTimeout(t)\n    }\n  }, [status])\n\n  // ----- keyboard\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    if (status !== \"ready\" || e.target !== e.currentTarget) return\n    const step = e.shiftKey ? STEP * 4 : STEP\n    let handled = true\n    switch (e.key) {\n      case \"ArrowLeft\": set({ x: crop.x + step }, true); break\n      case \"ArrowRight\": set({ x: crop.x - step }, true); break\n      case \"ArrowUp\": set({ y: crop.y + step }, true); break\n      case \"ArrowDown\": set({ y: crop.y - step }, true); break\n      case \"+\":\n      case \"=\": zoomBy(1.1); break\n      case \"-\":\n      case \"_\": zoomBy(1 / 1.1); break\n      case \"[\": turn(-90); break\n      case \"]\": turn(90); break\n      case \"0\": set({ ...START }, true); setSpoken(msg(\"image-cropper.resetDone\", \"Reset\")); break\n      default: handled = false\n    }\n    if (handled) e.preventDefault()\n  }\n  const zoomBy = (f: number) => {\n    const zoom = clamp(crop.zoom * f, 1, maxZoom)\n    set({ zoom }, true)\n    setSpoken(msg(\"image-cropper.zoomSpoken\", \"Zoom {percent}%\", { percent: Math.round(zoom * 100) }))\n  }\n  const turn = (deg: number) => {\n    const rotation = crop.rotation + deg\n    set({ rotation }, true)\n    setSpoken(msg(\"image-cropper.rotationSpoken\", \"Rotated to {degrees} degrees\", { degrees: ((Math.round(rotation) % 360) + 360) % 360 }))\n  }\n\n  // the straighten slider moves within a quarter turn either side of the nearest quarter\n  const quarter = Math.round(crop.rotation / 90) * 90\n  const fine = clamp(crop.rotation - quarter, -45, 45)\n\n  const showGrid = grid && status === \"ready\"\n  const round = shape === \"round\"\n  const transform = `translate(calc(-50% + ${crop.x}px), calc(-50% + ${crop.y}px)) rotate(${crop.rotation}deg) scale(${scale})`\n\n  return (\n    <div data-slot=\"image-cropper\" className={cn(\"min-w-0\", className)} {...props}>\n      <div\n        ref={box}\n        role=\"group\"\n        tabIndex={status === \"ready\" ? 0 : -1}\n        aria-roledescription={msg(\"image-cropper.role\", \"image cropper\")}\n        aria-label={alt ?? msg(\"image-cropper.label\", \"Crop the picture\")}\n        aria-describedby={`${id}-help`}\n        onKeyDown={onKeyDown}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerUp}\n        onPointerCancel={onPointerUp}\n        data-dragging={dragging || undefined}\n        data-state={status}\n        className={cn(\n          \"relative touch-none select-none overflow-hidden rounded-xl bg-muted outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n          status === \"ready\" && (dragging ? \"cursor-grabbing\" : \"cursor-grab\")\n        )}\n        style={{ height }}\n      >\n        {/* the picture, centred, then moved, turned and scaled; its size is its own until the scale is applied */}\n        {src ? (\n        <img\n          key={`${src}-${attempt}`}\n          ref={img}\n          src={src}\n          alt=\"\"\n          draggable={false}\n          crossOrigin={crossOrigin || undefined}\n          onLoad={(e) => {\n            const el = e.currentTarget\n            setNatural({ width: el.naturalWidth, height: el.naturalHeight })\n            setStatus(\"ready\")\n          }}\n          onError={() => {\n            setStatus(\"error\")\n            onError?.(new Error(`The picture ${src} could not be loaded.`))\n          }}\n          className=\"pointer-events-none absolute left-1/2 top-1/2 max-w-none origin-center will-change-transform\"\n          style={{ width: natural.width || undefined, height: natural.height || undefined, transform, opacity: status === \"ready\" ? 1 : 0 }}\n        />\n        ) : null}\n\n        {status === \"ready\" ? (\n          <>\n            {/* the dimmed picture outside the frame, and the frame itself */}\n            <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 flex items-center justify-center\">\n              <div\n                data-slot=\"image-cropper-frame\"\n                className={cn(\"relative ring-2 ring-white/90 [box-shadow:0_0_0_9999px_rgb(0_0_0/0.55)]\", round ? \"rounded-full\" : \"rounded-sm\")}\n                style={{ width: frame.width, height: frame.height }}\n              >\n                {showGrid ? (\n                  <div className={cn(\"absolute inset-0 overflow-hidden transition-opacity duration-150 motion-reduce:transition-none\", dragging ? \"opacity-100\" : \"opacity-0\", round && \"rounded-full\")}>\n                    <div className=\"absolute inset-y-0 start-1/3 w-px bg-white/60\" />\n                    <div className=\"absolute inset-y-0 start-2/3 w-px bg-white/60\" />\n                    <div className=\"absolute inset-x-0 top-1/3 h-px bg-white/60\" />\n                    <div className=\"absolute inset-x-0 top-2/3 h-px bg-white/60\" />\n                  </div>\n                ) : null}\n              </div>\n            </div>\n          </>\n        ) : null}\n\n        {status === \"loading\" ? (\n          <div role=\"status\" className=\"absolute inset-0 flex items-center justify-center\">\n            <Skeleton className=\"absolute inset-4 rounded-lg\" />\n            <span className=\"sr-only\">{msg(\"image-cropper.loading\", \"Loading the picture\")}</span>\n          </div>\n        ) : null}\n        {status === \"error\" ? (\n          <div role=\"alert\" className=\"absolute inset-0 flex flex-col items-center justify-center gap-2 p-4 text-center\">\n            <ImageOff className=\"size-8 text-muted-foreground\" aria-hidden=\"true\" />\n            <p className=\"text-sm font-medium\">{msg(\"image-cropper.errorTitle\", \"The picture could not be loaded\")}</p>\n            <Button type=\"button\" size=\"sm\" variant=\"outline\" onClick={() => setAttempt((n) => n + 1)}>\n              {msg(\"image-cropper.retry\", \"Try again\")}\n            </Button>\n          </div>\n        ) : null}\n      </div>\n\n      <p id={`${id}-help`} className=\"sr-only\">\n        {msg(\"image-cropper.help\", \"Arrow keys move the picture. Plus and minus zoom. The square brackets turn it a quarter. Zero resets.\")}\n      </p>\n\n      {controls ? (\n        <div data-slot=\"image-cropper-controls\" className=\"mt-3 grid gap-3 sm:grid-cols-[1fr_auto] sm:items-center\">\n          <div className=\"grid gap-2\">\n            <div className=\"flex items-center gap-2\">\n              <ZoomOut className=\"size-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n              <input\n                type=\"range\"\n                aria-label={msg(\"image-cropper.zoom\", \"Zoom\")}\n                aria-valuetext={msg(\"image-cropper.zoomValue\", \"{percent}%\", { percent: Math.round(crop.zoom * 100) })}\n                min={1}\n                max={maxZoom}\n                step={0.01}\n                value={crop.zoom}\n                disabled={status !== \"ready\"}\n                onChange={(e) => set({ zoom: Number(e.target.value) })}\n                onPointerUp={() => set({}, true)}\n                onKeyUp={() => set({}, true)}\n                className=\"h-1.5 w-full min-w-0 cursor-pointer accent-primary disabled:opacity-50\"\n              />\n              <ZoomIn className=\"size-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n            </div>\n            <div className=\"flex items-center gap-2\">\n              <RotateCcw className=\"size-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n              <input\n                type=\"range\"\n                aria-label={msg(\"image-cropper.straighten\", \"Straighten\")}\n                aria-valuetext={msg(\"image-cropper.degrees\", \"{degrees} degrees\", { degrees: Math.round(fine) })}\n                min={-45}\n                max={45}\n                step={0.5}\n                value={fine}\n                disabled={status !== \"ready\"}\n                onChange={(e) => set({ rotation: quarter + Number(e.target.value) })}\n                onPointerUp={() => set({}, true)}\n                onKeyUp={() => set({}, true)}\n                className=\"h-1.5 w-full min-w-0 cursor-pointer accent-primary disabled:opacity-50\"\n              />\n              <RotateCw className=\"size-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n            </div>\n          </div>\n          <div className=\"flex items-center gap-1\">\n            <Button type=\"button\" variant=\"outline\" size=\"icon\" aria-label={msg(\"image-cropper.rotateLeft\", \"Turn left\")} title={msg(\"image-cropper.rotateLeft\", \"Turn left\")} disabled={status !== \"ready\"} onClick={() => turn(-90)}>\n              <RotateCcw className=\"rtl:-scale-x-100\" aria-hidden=\"true\" />\n            </Button>\n            <Button type=\"button\" variant=\"outline\" size=\"icon\" aria-label={msg(\"image-cropper.rotateRight\", \"Turn right\")} title={msg(\"image-cropper.rotateRight\", \"Turn right\")} disabled={status !== \"ready\"} onClick={() => turn(90)}>\n              <RotateCw className=\"rtl:-scale-x-100\" aria-hidden=\"true\" />\n            </Button>\n            <Button type=\"button\" variant=\"ghost\" size=\"sm\" disabled={status !== \"ready\"} onClick={() => (set({ ...START }, true), setSpoken(msg(\"image-cropper.resetDone\", \"Reset\")))}>\n              {msg(\"image-cropper.reset\", \"Reset\")}\n            </Button>\n          </div>\n        </div>\n      ) : null}\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {spoken}\n      </span>\n    </div>\n  )\n}\n\nexport { ImageCropper, type Crop, type CropArea, type CropImageOptions, type CropResult, type ImageCropperHandle, type ImageCropperProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/image-cropper.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "image",
      "crop",
      "avatar",
      "upload",
      "zoom",
      "rotate",
      "canvas"
    ],
    "version": "1.0.0",
    "updated": "2026-10-19",
    "tests": true,
    "ai": {
      "summary": "<ImageCropper src aspect={1} cropperRef={ref} />. Get the file with await ref.current.toBlob({ width, type }); onCropComplete gives the area in original pixels to crop on a server. shape=\"round\" for avatars.",
      "whenToUse": [
        "Profile pictures, cover images and product photos cropped before upload",
        "Any upload that needs a fixed aspect ratio",
        "Straightening a slightly tilted photo"
      ],
      "whenNotToUse": [
        "Free-form selection boxes with resizable handles",
        "Photo editing with filters, text or drawing",
        "Pictures from another site without CORS headers, which cannot be exported"
      ],
      "composesWith": [
        "file-dropzone",
        "avatar",
        "dialog",
        "button"
      ],
      "a11y": [
        {
          "keys": "Arrow keys",
          "action": "Move the picture under the frame; Shift moves it further"
        },
        {
          "keys": "+ / -",
          "action": "Zoom in and out; the zoom is announced"
        },
        {
          "keys": "[ / ]",
          "action": "Turn a quarter left or right"
        },
        {
          "keys": "0",
          "action": "Reset"
        },
        {
          "keys": "Sliders and buttons",
          "action": "Zoom and straighten are native range inputs with spoken values; turn and reset are buttons"
        }
      ],
      "customization": [
        "aspect, shape, height, maxZoom, grid, zoomOnWheel",
        "value and onCropChange to hold the crop; onCropComplete for the area in original pixels",
        "toBlob options: width, maxWidth, type, quality, round, background",
        "controls={false} to build your own controls on the same ref"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "image-cropper-loading",
        "trigger": "no src yet, or while the picture loads",
        "note": "A skeleton in the picture's place, announced as loading; the controls are off."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "image-cropper-error",
        "trigger": "the picture fails to load",
        "note": "A message with a Try again button, announced as an alert; onError gets the error."
      }
    ],
    "examples": [
      "image-cropper-demo",
      "image-cropper-aspect",
      "image-cropper-avatar",
      "image-cropper-controlled",
      "image-cropper-loading",
      "image-cropper-error"
    ],
    "url": "https://ui.ballmac.com/components/image-cropper"
  },
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}