{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-crop",
  "title": "Image Crop",
  "description": "The step that comes straight after a photo is chosen: the picture sits behind a frame of the shape the product needs, and you drag it about and pinch or scroll to zoom until the right part is in the window. Reach for it wherever an upload has to end up a fixed shape — a profile photo or avatar, a team or workspace logo, a cover or banner image, an OG/social preview card, a product or listing photo, a thumbnail for a video or article, a group or channel icon, an ID or document photo being squared up before it is sent, a header image in a CMS, and the crop step of any onboarding or import wizard. Common asks it answers: \"image crop react\", \"image cropper component\", \"avatar cropper\", \"profile picture crop react\", \"crop image before upload\", \"react-easy-crop alternative\", \"react-image-crop alternative\", \"cropperjs alternative\", \"shadcn image crop\", \"shadcn avatar upload\", \"crop to square react\", \"circular crop avatar\", \"zoom and pan image react\", \"pinch to zoom crop\", \"canvas drawImage crop\", \"get cropped image as blob\", \"cropped image is offset\", \"crop is off by a few pixels\", \"crop coordinates wrong scale\", \"image rotated after upload\", \"photo sideways after crop\", \"exif orientation canvas\", \"createImageBitmap imageOrientation\", \"canvas blank on iphone\", \"canvas too large ios safari\", \"resize image in browser before upload\", \"createObjectURL memory leak\", \"canvas toBlob securityerror\", \"tainted canvas crop\", \"accessible image cropper\", \"crop image keyboard only\", \"画像 クロップ react\", \"アバター 切り抜き\", \"アップロード 前に リサイズ\", \"写真が横向きになる exif\". Official shadcn/ui has nothing to build this from, and the measurement is not close: fetching every entry in its registry today — 63 listed, 62 fetchable, questionnaire is indexed and 404s on both style tracks — and grepping 212 KB of source, crop, naturalWidth, FileReader, createImageBitmap, drawImage, getContext, toDataURL, toBlob, imageOrientation, exif, devicePixelRatio, createObjectURL, setPointerCapture, pinch and the string <canvas are every one of them a zero hit. The only match for zoom anywhere is eleven occurrences of Tailwind's zoom-in-95 / zoom-out-95 overlay animation on dialogs and menus. Its aspect-ratio component is a six-line re-export of the Radix primitive that holds a box at a shape and knows nothing about a picture inside it, and avatar renders an image that has already been made square by somebody else. So an agent asked for a crop step builds it from a bare canvas, and the three things that make this hard are exactly the three it gets wrong. The first is that the coordinates on screen are not the coordinates being cut. A drag is measured in CSS pixels on a 320px-wide preview; the crop is taken from an 8000px original, and the factor between them has to be applied exactly once, in one direction. Applied the wrong way or against a frame width measured before a sidebar opened, the saved picture is offset from the one that was on screen — close enough to ship and wrong enough to notice. This keeps the value in the source image's own pixels ({ x, y, width, height }), so the crop survives a resize, a phone with a different screen and a round trip through a database, and derives the screen geometry from it rather than the other way round. The frame is measured with a ResizeObserver rather than once on mount, because a dialog or a sidebar changes its width without the window doing anything. The second is EXIF orientation, and it produces the bug report everybody recognises: the preview is the right way up and the saved avatar is lying on its side. A phone stores the photo in the sensor's orientation with a tag saying which way to turn it; browsers have applied that tag to <img> for years and drawImage of the same file has not always agreed, so the picture rotates somewhere between the box that was cropped and the canvas it was cut into. createImageBitmap(blob, { imageOrientation: \"from-image\" }) is the line that settles it, and here the result is checked against the preview before it is trusted — if the bitmap and the element disagree about which side is the long one, the element wins, because being faithful to what the user actually chose beats being upright. The third is that the output is otherwise unbounded. The obvious way to cut a rectangle out is a canvas the size of the source: for an 8000x6000 photo that is 48 megapixels, 192 MB of RGBA before anything is drawn, and the destination is a 256px avatar. iOS Safari does not throw there — it drops the backing store and hands back a blank image, or kills the tab. So the canvas is made at the size of the output and the crop is scaled into it in a single drawImage with high-quality smoothing, never allocating more than the picture actually wanted. Pass output={{ width: 512 }} for a slot you have to fill, or leave it and the longest side is capped at 2048; a crop is never upscaled, which would only make a bigger file out of the same detail. It is operable without a pointer, which is the part hand-rolled croppers skip and the reason a sign-up step becomes one a whole class of people cannot finish. The frame takes focus and answers the arrow keys (Shift for a longer step) and + / -, and under it are real range inputs — zoom, plus a horizontal and a vertical position that announce themselves as percentages — with the position pair sr-only until something in it has focus, so a screen reader always reaches them while a sighted keyboard user sees them appear only on tabbing in rather than facing two sliders that duplicate the arrow keys. Pointer capture keeps a drag alive past the edge of the frame, two-finger pinch zooms about the point between the fingers, a wheel zooms about the cursor through a non-passive listener (React's own wheel handler is passive, where preventDefault does nothing and the page scrolls behind the cropper), touch-action is none so the first drag on a phone does not scroll the page instead, and the browser's native image drag is turned off so a pan does not hand the file to whatever is underneath. The API: src takes a URL or the File/Blob straight from the input — pass the Blob when you have one, and the object URL is made and revoked for you, which is the leak every upload screen has. Then aspect, shape=\"round\" to draw the frame as a circle (the export stays the rectangle: an avatar is displayed round by the page that shows it, and baking the circle in means a transparent PNG that grows black corners the first time something flattens it), maxZoom, grid, controls, disabled, crossOrigin, and labels for translation. value/defaultValue and onChange work controlled or uncontrolled over the crop rectangle, and onChangeEnd fires once a gesture settles — the one to re-encode a preview in, since onChange fires per pointer frame. The ref exposes getCrop, setCrop, reset, getImageSize, toCanvas, toBlob and toDataURL. coverCrop, clampCrop, cropZoom, zoomCropTo and outputSize are exported as pure functions, so the same rectangle can be re-cut on a server that has no DOM. There are deliberately no resize handles: free-form selection with eight grips is a different component doing a different job, and the frame here is the output shape. Within pulld it completes the upload path: file-dropzone takes the file in, this shapes it, upload-list shows the rows while it goes up. It sits beside image-comparison, which puts two pictures of the same thing under one divider, and signature-pad, the other component here that draws. One file, zero dependencies, not even an icon, and every colour is a shadcn token except the thirds guides, which are a translucent white on purpose because what they lie on is a photograph rather than a themed surface. Light and dark follow on their own.",
  "files": [
    {
      "path": "registry/ui/image-crop.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/** The size of the source image, in the image's own pixels. */\nexport interface ImageSize {\n  width: number\n  height: number\n}\n\n/**\n * The part of the image that is kept, in the source image's own pixels.\n *\n * Not fractions of the frame and not screen pixels. Every real bug in a cropper is these two\n * systems being mixed up: somebody drags a 320px-wide preview of an 8000px photo, and a factor\n * applied in the wrong direction saves a picture a dot off the frame they were looking at — or,\n * applied twice, a corner of the photo nobody chose. Keeping the value in source pixels means it\n * survives a resize, a phone with a different screen and a round trip through a database, and it\n * is exactly the rectangle a server-side resize would be handed.\n */\nexport interface CropRect {\n  x: number\n  y: number\n  width: number\n  height: number\n}\n\n/** How big the exported image should be. See {@link outputSize} for what each combination means. */\nexport interface CropOutputOptions {\n  /** Exact output width in pixels. The height follows the crop's shape unless it is given too. */\n  width?: number\n  /** Exact output height in pixels. */\n  height?: number\n  /** Cap on the longest side when neither is given (default 2048). See {@link outputSize}. */\n  maxSize?: number\n}\n\n/** Output options plus the encoding, for the calls that produce a file rather than a canvas. */\nexport interface CropEncodeOptions extends CropOutputOptions {\n  /** MIME type, e.g. `\"image/jpeg\"` or `\"image/webp\"` (default `\"image/png\"`). */\n  type?: string\n  /** Quality for lossy types, 0–1. */\n  quality?: number\n}\n\nexport interface ImageCropProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"defaultValue\" | \"children\"> {\n  /**\n   * The image to crop: a URL, or the `File`/`Blob` that came straight out of a file input.\n   *\n   * A `Blob` is the better one to pass when you have it. It saves you the object URL — this makes\n   * one and revokes it when the source changes or it unmounts, which is the leak every upload\n   * screen has — and it is the form the exporter can ask about orientation, which is the whole of\n   * the note about photographs that arrive lying on their side.\n   */\n  src: string | Blob\n  /** Alt text for the image being cropped. Describe the picture, not the act of cropping. */\n  alt?: string\n  /** Width divided by height of the crop frame (default 1, a square). */\n  aspect?: number\n  /** Draw the frame as a circle. The crop is still the rectangle; see the note on the component. */\n  shape?: \"rect\" | \"round\"\n  /** Draw thirds guides over the frame while it is being moved (default true). */\n  grid?: boolean\n  /** Controlled crop rectangle, in source pixels. Pair with `onChange`. */\n  value?: CropRect\n  /** Starting crop for an uncontrolled component. Defaults to the whole image at this aspect. */\n  defaultValue?: CropRect\n  /** Called on every frame of a drag, pinch, wheel or key press. */\n  onChange?: (crop: CropRect) => void\n  /**\n   * Called once a gesture finishes — pointer up, key press, slider release, wheel settled.\n   *\n   * This is the one to re-encode a preview in. `onChange` fires per pointer frame, and drawing an\n   * 8000px source into a canvas sixty times a second is how a cropper turns a phone into a heater.\n   */\n  onChangeEnd?: (crop: CropRect) => void\n  /** Called when the image has loaded, with its natural size. */\n  onImageLoad?: (size: ImageSize) => void\n  /** How far in the image can be zoomed, as a multiple of \"fills the frame\" (default 8). */\n  maxZoom?: number\n  /** Default sizing and encoding for the export methods on the ref. */\n  output?: CropEncodeOptions\n  /** Passed to the underlying `<img>`. Needed to export a cross-origin image; see the note. */\n  crossOrigin?: \"anonymous\" | \"use-credentials\"\n  /** Show the zoom slider under the frame (default true). The position sliders stay either way. */\n  controls?: boolean\n  /** Freezes the frame and dims it. */\n  disabled?: boolean\n  /** Labels for the controls and the announcements, for translation. */\n  labels?: Partial<typeof DEFAULT_LABELS>\n  /** Classes for the frame itself, e.g. to give it a fixed width. */\n  frameClassName?: string\n}\n\n/** What the imperative ref exposes, for the submit handler that has to produce the file. */\nexport interface ImageCropHandle {\n  /** The current crop rectangle, in source pixels, or `null` before the image loads. */\n  getCrop: () => CropRect | null\n  /** Moves the crop. The rectangle is clamped and snapped to the aspect, exactly as a drag is. */\n  setCrop: (crop: CropRect) => void\n  /** Back to the whole image at this aspect. */\n  reset: () => void\n  /** The natural size of the loaded image, or `null` before it loads. */\n  getImageSize: () => ImageSize | null\n  /** The cropped image on a canvas. Rejects if the image has not loaded. */\n  toCanvas: (options?: CropOutputOptions) => Promise<HTMLCanvasElement>\n  /** The cropped image as a file. `null` only if the browser declines to encode it. */\n  toBlob: (options?: CropEncodeOptions) => Promise<Blob | null>\n  /** The cropped image as a data URL. */\n  toDataURL: (options?: CropEncodeOptions) => Promise<string>\n}\n\nconst DEFAULT_LABELS = {\n  /** Names the frame for assistive technology. */\n  frame: \"Crop image\",\n  /** Read after the name, so the gestures are announced rather than having to be discovered. */\n  hint: \"Drag to move the image. Pinch or scroll to zoom. Arrow keys nudge, plus and minus zoom.\",\n  zoom: \"Zoom\",\n  horizontal: \"Horizontal position\",\n  vertical: \"Vertical position\",\n  /** Announced after a keyboard nudge or the end of a drag. */\n  position: (zoom: number, across: number, down: number) =>\n    `Zoom ${zoom}x, ${across}% across, ${down}% down.`,\n}\n\nconst DEFAULT_ASPECT = 1\nconst DEFAULT_MAX_ZOOM = 8\n/** Longest side of an export that did not ask for a size. See {@link outputSize}. */\nexport const DEFAULT_MAX_OUTPUT = 2048\n/** Fraction of the crop an arrow key moves, plain and with Shift held. */\nconst NUDGE = 0.02\nconst NUDGE_FAST = 0.1\n/** Multiplier per press of + or -. */\nconst KEY_ZOOM = 1.2\n\nconst clamp = (n: number, min: number, max: number) => Math.min(Math.max(n, min), max)\n\n/**\n * The largest rectangle of `aspect` that fits inside the image, centred. Zoom 1.\n *\n * `Math.min` twice rather than once and a division: the second side computed from the first can\n * land a floating-point hair outside the image, and the clamp that follows would then shift the\n * crop off centre for a reason invisible in the numbers.\n */\nexport function coverCrop(image: ImageSize, aspect: number = DEFAULT_ASPECT): CropRect {\n  const ratio = aspect > 0 && Number.isFinite(aspect) ? aspect : DEFAULT_ASPECT\n  const width = Math.min(image.width, image.height * ratio)\n  const height = Math.min(image.height, width / ratio)\n  return { x: (image.width - width) / 2, y: (image.height - height) / 2, width, height }\n}\n\n/**\n * Snaps a rectangle to the aspect and pushes it back inside the image.\n *\n * The height is derived from the width rather than trusted, which is what makes the frame the\n * authority on shape: an avatar frame is square, so a crop that is not square is not one the user\n * can have seen. The position is then clamped to the image, and that is the rule that prevents the\n * one output nobody accepts — an avatar with a transparent wedge down one side, because the frame\n * was allowed to hang over the edge of the photo.\n */\nexport function clampCrop(\n  crop: CropRect,\n  image: ImageSize,\n  aspect: number = DEFAULT_ASPECT,\n  minSize = 1\n): CropRect {\n  const cover = coverCrop(image, aspect)\n  const ratio = cover.height > 0 ? cover.width / cover.height : DEFAULT_ASPECT\n  let width = clamp(crop.width, Math.min(minSize, cover.width), cover.width)\n  let height = width / ratio\n  if (height > cover.height) {\n    height = cover.height\n    width = height * ratio\n  }\n  return {\n    x: clamp(crop.x, 0, Math.max(image.width - width, 0)),\n    y: clamp(crop.y, 0, Math.max(image.height - height, 0)),\n    width,\n    height,\n  }\n}\n\n/** How far in the crop is: 1 fits the whole image to the frame, 4 shows a quarter of its width. */\nexport function cropZoom(crop: CropRect, image: ImageSize, aspect: number = DEFAULT_ASPECT): number {\n  const cover = coverCrop(image, aspect)\n  return crop.width > 0 ? cover.width / crop.width : 1\n}\n\n/**\n * Re-zooms a crop, keeping one point of the image where it already is on screen.\n *\n * `focal` is in source pixels — the point under the cursor, or between two fingers. Without it a\n * zoom is anchored to the middle of the frame, which is right for a slider and wrong for a wheel:\n * scrolling in on somebody's face and watching it slide out of the frame is the difference between\n * a cropper that feels direct and one that has to be fought.\n */\nexport function zoomCropTo(\n  crop: CropRect,\n  image: ImageSize,\n  aspect: number = DEFAULT_ASPECT,\n  zoom: number,\n  focal?: { x: number; y: number }\n): CropRect {\n  const cover = coverCrop(image, aspect)\n  const ratio = cover.height > 0 ? cover.width / cover.height : DEFAULT_ASPECT\n  const next = Math.max(Number.isFinite(zoom) ? zoom : 1, 1)\n  const width = cover.width / next\n  const height = width / ratio\n  const point = focal ?? { x: crop.x + crop.width / 2, y: crop.y + crop.height / 2 }\n  // Where the focal point sits inside the crop now, as a fraction — so it sits there afterwards.\n  const fx = crop.width > 0 ? (point.x - crop.x) / crop.width : 0.5\n  const fy = crop.height > 0 ? (point.y - crop.y) / crop.height : 0.5\n  return clampCrop({ x: point.x - fx * width, y: point.y - fy * height, width, height }, image, aspect)\n}\n\n/**\n * The pixel size of the export, and the reason this component owns the export at all.\n *\n * The obvious way to cut a rectangle out of a picture is to make a canvas the size of the source,\n * draw the whole thing, and read the rectangle back. A phone photo is 8000x6000, so that canvas is\n * 48 megapixels — 192 MB of RGBA before a single pixel is drawn on it — and the destination is a\n * 256px avatar. iOS Safari does not throw there; it drops the backing store and hands back a blank\n * image, or kills the tab. So the canvas is made at the size of the *output* and the crop is\n * scaled into it in one `drawImage`, which never allocates more than the picture actually wanted.\n *\n * Which means the size has to be decided before any pixels exist:\n * - `width` and `height` — exactly that, whatever shape the crop is (the caller has a slot to fill).\n * - one of them — the other follows the crop, so nothing is squashed.\n * - neither — the crop's own pixels, scaled down so the longest side is at most `maxSize`. A crop\n *   is never enlarged past its source: upscaling here makes a bigger file out of the same detail.\n */\nexport function outputSize(crop: CropRect, options: CropOutputOptions = {}): ImageSize {\n  const { width, height, maxSize = DEFAULT_MAX_OUTPUT } = options\n  const ratio = crop.height > 0 ? crop.width / crop.height : 1\n  const px = (n: number) => Math.max(1, Math.round(n))\n\n  if (width !== undefined && height !== undefined) return { width: px(width), height: px(height) }\n  if (width !== undefined) return { width: px(width), height: px(width / ratio) }\n  if (height !== undefined) return { width: px(height * ratio), height: px(height) }\n\n  const longest = Math.max(crop.width, crop.height)\n  const scale = maxSize > 0 && longest > maxSize ? maxSize / longest : 1\n  return { width: px(crop.width * scale), height: px(crop.height * scale) }\n}\n\n/**\n * Something drawable whose pixels are laid out the way the preview showed them.\n *\n * This is the EXIF trap, and it produces the bug report everybody recognises: the preview was the\n * right way up and the saved avatar is lying on its side. A photo from a phone is usually stored\n * in the sensor's orientation with a tag saying which way to turn it; browsers have applied that\n * tag to `<img>` for years, and `drawImage` of the same file has not always agreed — so the\n * picture rotates somewhere between the box somebody cropped and the canvas it was cut into.\n *\n * `createImageBitmap(blob, { imageOrientation: \"from-image\" })` is the one line that settles it,\n * because it says out loud which of the two behaviours is wanted instead of inheriting whichever\n * this browser has. The result is then *checked against the preview* rather than trusted: if the\n * bitmap and the `<img>` disagree about which side is the long one, this browser did not turn the\n * element, and exporting the bitmap would save a picture that was never on screen. The element\n * wins in that case — being faithful to what the user chose beats being upright.\n */\nasync function drawableFor(img: HTMLImageElement, blob: Blob | null): Promise<CanvasImageSource> {\n  if (blob && typeof createImageBitmap === \"function\") {\n    try {\n      const bitmap = await createImageBitmap(blob, { imageOrientation: \"from-image\" })\n      if (bitmap.width === img.naturalWidth && bitmap.height === img.naturalHeight) return bitmap\n      bitmap.close?.()\n    } catch {\n      // A source this browser will not decode that way (an SVG, or a Blob already released). The\n      // element holds a decoded copy regardless, and that copy is the one on screen.\n    }\n  }\n  return img\n}\n\n/** Cuts `crop` out of the image and draws it, scaled, onto a canvas of the output size. */\nasync function cropToCanvas(\n  img: HTMLImageElement,\n  blob: Blob | null,\n  crop: CropRect,\n  options: CropOutputOptions\n): Promise<HTMLCanvasElement> {\n  const out = outputSize(crop, options)\n  const canvas = document.createElement(\"canvas\")\n  canvas.width = out.width\n  canvas.height = out.height\n  const ctx = canvas.getContext(\"2d\")\n  if (!ctx) throw new Error(\"image-crop: this browser returned no 2D canvas context\")\n\n  // A crop is nearly always drawn smaller than it was, and the default resampling makes a\n  // downscale of that size crunchy in exactly the place people look at — a face.\n  ctx.imageSmoothingEnabled = true\n  ctx.imageSmoothingQuality = \"high\"\n\n  const source = await drawableFor(img, blob)\n  ctx.drawImage(source, crop.x, crop.y, crop.width, crop.height, 0, 0, out.width, out.height)\n  if (source !== img) (source as ImageBitmap).close?.()\n  return canvas\n}\n\n// A real range input, left to render natively. `appearance-none` is the usual reflex here and it\n// is a trap without a full set of ::-webkit-slider-thumb rules — which a single-file registry\n// component cannot ship — so the thumb simply disappears in WebKit. `accent-color` themes the\n// native control from the same token instead, and keeps the platform's own keyboard behaviour.\nconst SLIDER_CLASS =\n  \"min-w-0 flex-1 cursor-pointer accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50\"\n\nconst ROW_CLASS = \"flex items-center gap-3 text-xs text-muted-foreground\"\n\n/**\n * Fit a photograph to a frame: drag it about, pinch or scroll to zoom, then take the rectangle.\n *\n * ```tsx\n * const cropper = React.useRef<ImageCropHandle>(null)\n *\n * <ImageCrop\n *   src={file}                                        // the File straight from the input\n *   alt=\"Your profile photo\"\n *   shape=\"round\"\n *   output={{ width: 512, type: \"image/webp\", quality: 0.9 }}\n *   ref={cropper}\n * />\n * <Button onClick={async () => upload(await cropper.current!.toBlob())}>Save</Button>\n * ```\n *\n * The frame *is* the crop: the image lies behind a window of the right shape and is moved under\n * it, rather than a marquee being dragged around on top of a picture. That is the interaction an\n * avatar, cover or thumbnail step wants — the output shape is fixed and the only question is which\n * part of the photo goes in it — and it is why `aspect` is a prop and there are no resize handles.\n * Free-form selection with eight handles is a different component doing a different job.\n *\n * `shape=\"round\"` rounds the frame, not the output. The exported image is the rectangle, because\n * an avatar is displayed round by the page that shows it, at whatever size it shows it: baking the\n * circle in means a transparent PNG that grows black corners the first time it lands somewhere\n * that flattens it, and a JPEG that cannot be round at all.\n *\n * Keyboard and assistive technology reach all of it. The frame takes focus and answers the arrow\n * keys (Shift for a longer step) and + / -, and under it are real range inputs: zoom, and a\n * horizontal and vertical position that announce themselves as percentages. The position pair is\n * hidden until something inside it has focus — a screen reader reaches them regardless, and a\n * sighted keyboard user sees them appear the moment they tab in rather than looking at two sliders\n * duplicating the arrow keys they already have. A cropper that answers only to a drag is a step in\n * a sign-up form that a whole class of people cannot finish.\n *\n * One thing it cannot do for you: exporting a cross-origin image taints the canvas, and `toBlob`\n * then throws a `SecurityError`. Pass `crossOrigin=\"anonymous\"` and serve the image with CORS\n * headers, or hand this component the `Blob` you already have.\n */\nexport const ImageCrop = React.forwardRef<ImageCropHandle, ImageCropProps>(function ImageCrop(\n  {\n    className,\n    src,\n    alt = \"\",\n    aspect = DEFAULT_ASPECT,\n    shape = \"rect\",\n    grid = true,\n    value: valueProp,\n    defaultValue,\n    onChange,\n    onChangeEnd,\n    onImageLoad,\n    maxZoom = DEFAULT_MAX_ZOOM,\n    output,\n    crossOrigin,\n    controls = true,\n    disabled = false,\n    labels,\n    frameClassName,\n    ...props\n  },\n  ref\n) {\n  const text = { ...DEFAULT_LABELS, ...labels }\n  const reactId = React.useId()\n  const hintId = `${reactId}-hint`\n\n  const frameRef = React.useRef<HTMLDivElement | null>(null)\n  const imgRef = React.useRef<HTMLImageElement | null>(null)\n\n  const [image, setImage] = React.useState<ImageSize | null>(null)\n  const [frame, setFrame] = React.useState<ImageSize>({ width: 0, height: 0 })\n  const [uncontrolled, setUncontrolled] = React.useState<CropRect | null>(defaultValue ?? null)\n  const [interacting, setInteracting] = React.useState(false)\n  const [announcement, setAnnouncement] = React.useState(\"\")\n\n  const topZoom = Math.max(maxZoom, 1)\n  const isControlled = valueProp !== undefined\n  const raw = isControlled ? valueProp : uncontrolled\n  // Nothing is a valid crop until the image's own size is known, and every move needs it clamped,\n  // so the rectangle is normalised once here rather than at each of the seven places it changes.\n  const crop = image ? clampCrop(raw ?? coverCrop(image, aspect), image, aspect) : null\n\n  // A Blob source needs a URL, and needs it taken away again. An upload screen that makes one per\n  // selected file and never revokes it holds every image the user tried onto the heap for the life\n  // of the page, which on a photo picker is the largest leak the page has.\n  const [objectUrl, setObjectUrl] = React.useState<string | null>(null)\n  React.useEffect(() => {\n    if (typeof src === \"string\") {\n      setObjectUrl(null)\n      return\n    }\n    const url = URL.createObjectURL(src)\n    setObjectUrl(url)\n    return () => URL.revokeObjectURL(url)\n  }, [src])\n  const href = typeof src === \"string\" ? src : objectUrl\n  const blob = typeof src === \"string\" ? null : src\n\n  const commit = React.useCallback(\n    (next: CropRect, end = false) => {\n      if (!isControlled) setUncontrolled(next)\n      onChange?.(next)\n      if (end) onChangeEnd?.(next)\n    },\n    [isControlled, onChange, onChangeEnd]\n  )\n\n  // CSS pixels per source pixel. The larger of the two, so a frame whose measured size is a\n  // rounding hair off the aspect is covered in both directions: half a pixel of photo spills out\n  // of the window, where the other way round leaves a hairline of background down one edge.\n  const cssPerPixel =\n    crop && crop.width > 0 && crop.height > 0\n      ? Math.max(frame.width / crop.width, frame.height / crop.height)\n      : 0\n\n  // Handlers reach the current geometry through a ref. A pointermove during a drag is bound once,\n  // and would otherwise be closing over the crop as it was when the gesture began.\n  const live = React.useRef({ crop, image, aspect, topZoom, disabled })\n  React.useEffect(() => {\n    live.current = { crop, image, aspect, topZoom, disabled }\n  })\n\n  const notifyLoad = React.useRef(onImageLoad)\n  notifyLoad.current = onImageLoad\n\n  /**\n   * Records the natural size, once per image.\n   *\n   * Stable, and guarded by the size it last reported, because it is also called from the `<img>`\n   * ref callback — an inline callback there is detached and reattached on every render, so an\n   * unguarded `setImage` with a fresh object would re-render, reattach, and never stop.\n   */\n  const reported = React.useRef(\"\")\n  const handleLoad = React.useCallback((node: HTMLImageElement) => {\n    const size = { width: node.naturalWidth, height: node.naturalHeight }\n    if (!size.width || !size.height) return\n    const key = `${size.width}x${size.height}`\n    if (reported.current === key) return\n    reported.current = key\n    setImage(size)\n    notifyLoad.current?.(size)\n  }, [])\n\n  const attachImage = React.useCallback(\n    (node: HTMLImageElement | null) => {\n      imgRef.current = node\n      // A cached image can already be complete before React attaches onLoad, in which case the\n      // event never fires and the cropper sits blank until something else re-renders it.\n      if (node?.complete && node.naturalWidth) handleLoad(node)\n    },\n    [handleLoad]\n  )\n\n  // A new source is a new picture, so the old rectangle describes pixels that are gone. Tied to\n  // the source actually changing: on mount there is nothing to discard, and discarding anyway\n  // would throw away `defaultValue` — and the first `href` of a Blob arrives one render late, so\n  // an appearing URL is the same non-event as the first one.\n  const previousHref = React.useRef(href)\n  React.useEffect(() => {\n    const previous = previousHref.current\n    previousHref.current = href\n    if (previous === href || previous === null) return\n    reported.current = \"\"\n    setImage(null)\n    if (!isControlled) setUncontrolled(null)\n  }, [href, isControlled])\n\n  // The frame's width comes from the page and its height from the aspect. Observed rather than\n  // measured once: this sits in dialogs and sidebars, which change width without the window doing\n  // anything, and a stale frame width is a wrong scale factor — which is the defect that saves a\n  // picture offset from the one on screen.\n  React.useEffect(() => {\n    const node = frameRef.current\n    if (!node) return\n    const measure = () => {\n      const box = node.getBoundingClientRect?.()\n      const next = { width: box?.width ?? 0, height: box?.height ?? 0 }\n      // Bailing out on an unchanged size is not a micro-optimisation here: a ResizeObserver fires\n      // on every tick of a window being dragged wider, and a fresh object each time would re-render\n      // the whole cropper for each one — during the gesture where it can least afford it.\n      setFrame((prev) => (prev.width === next.width && prev.height === next.height ? prev : next))\n    }\n    measure()\n    if (typeof ResizeObserver === \"undefined\") return\n    const observer = new ResizeObserver(measure)\n    observer.observe(node)\n    return () => observer.disconnect()\n  }, [])\n\n  const announce = React.useCallback(\n    (next: CropRect, size: ImageSize) => {\n      const pct = (n: number, span: number) => (span > 0.5 ? Math.round((n / span) * 100) : 50)\n      setAnnouncement(\n        text.position(\n          Math.round(cropZoom(next, size, live.current.aspect) * 10) / 10,\n          pct(next.x, size.width - next.width),\n          pct(next.y, size.height - next.height)\n        )\n      )\n    },\n    [text]\n  )\n\n  /** The frame as it is right now. Measured rather than read from state, so a drag mid-resize is\n   * still cutting the picture that is on screen. */\n  const frameBox = () => {\n    const box = frameRef.current?.getBoundingClientRect?.()\n    return { left: box?.left ?? 0, top: box?.top ?? 0, width: box?.width ?? 0, height: box?.height ?? 0 }\n  }\n\n  /** CSS pixels per source pixel for a given crop, against the live frame. */\n  const scaleFor = (from: CropRect, box: ReturnType<typeof frameBox>) =>\n    Math.max(from.width > 0 ? box.width / from.width : 0, from.height > 0 ? box.height / from.height : 0)\n\n  /** Where a client point lands in the source image, given the crop it is measured against. */\n  const sourceAt = (from: CropRect, clientX: number, clientY: number) => {\n    const box = frameBox()\n    const k = scaleFor(from, box)\n    return k > 0\n      ? { x: from.x + (clientX - box.left) / k, y: from.y + (clientY - box.top) / k }\n      : { x: from.x + from.width / 2, y: from.y + from.height / 2 }\n  }\n\n  /** The pointers currently down on the frame, and the geometry the gesture started from. */\n  const pointers = React.useRef(new Map<number, { x: number; y: number }>())\n  const gesture = React.useRef<{\n    crop: CropRect\n    zoom: number\n    mid: { x: number; y: number }\n    distance: number\n  } | null>(null)\n\n  const midpoint = () => {\n    const list = [...pointers.current.values()]\n    if (list.length === 0) return { x: 0, y: 0 }\n    const sum = list.reduce((a, p) => ({ x: a.x + p.x, y: a.y + p.y }), { x: 0, y: 0 })\n    return { x: sum.x / list.length, y: sum.y / list.length }\n  }\n\n  const spread = () => {\n    const list = [...pointers.current.values()]\n    return list.length < 2 ? 0 : Math.hypot(list[0].x - list[1].x, list[0].y - list[1].y)\n  }\n\n  const beginGesture = () => {\n    const current = live.current.crop\n    const size = live.current.image\n    if (!current || !size) {\n      gesture.current = null\n      return\n    }\n    gesture.current = {\n      crop: current,\n      zoom: cropZoom(current, size, live.current.aspect),\n      mid: midpoint(),\n      distance: spread(),\n    }\n  }\n\n  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (live.current.disabled || !live.current.crop) return\n    if (event.pointerType === \"mouse\" && event.button !== 0) return\n    // Without capture the drag ends the moment the hand leaves the frame, which on a cropper is\n    // every drag that pushes the picture as far as it goes.\n    event.currentTarget.setPointerCapture?.(event.pointerId)\n    pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY })\n    // Every change in how many fingers are down restarts the gesture from where the picture is\n    // now. Without that, the second finger landing makes the image jump by the distance between\n    // the first finger and the new midpoint.\n    beginGesture()\n    setInteracting(true)\n  }\n\n  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (!pointers.current.has(event.pointerId)) return\n    const start = gesture.current\n    const size = live.current.image\n    if (!start || !size) return\n    event.preventDefault()\n    pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY })\n\n    const box = frameBox()\n    const mid = midpoint()\n\n    if (pointers.current.size >= 2 && start.distance > 0) {\n      const zoom = clamp((start.zoom * spread()) / start.distance, 1, live.current.topZoom)\n      // The point that was between the fingers when the pinch began, in the picture. It has to end\n      // up under where the fingers are *now*, and that is both halves of a pinch: the ratio sets\n      // the size, then the rectangle is placed so one point of the photo has not moved on the glass.\n      const focal = sourceAt(start.crop, start.mid.x, start.mid.y)\n      const zoomed = zoomCropTo(start.crop, size, live.current.aspect, zoom, focal)\n      const k = scaleFor(zoomed, box)\n      commit(\n        clampCrop(\n          k > 0\n            ? { ...zoomed, x: focal.x - (mid.x - box.left) / k, y: focal.y - (mid.y - box.top) / k }\n            : zoomed,\n          size,\n          live.current.aspect\n        )\n      )\n      return\n    }\n\n    // Measured from where the gesture started rather than from the previous event: accumulated\n    // per-event deltas drift, and a crop resting against an edge would eat the movement that\n    // should come back the moment the finger turns around.\n    const k = scaleFor(start.crop, box)\n    if (k <= 0) return\n    commit(\n      clampCrop(\n        {\n          ...start.crop,\n          x: start.crop.x - (mid.x - start.mid.x) / k,\n          y: start.crop.y - (mid.y - start.mid.y) / k,\n        },\n        size,\n        live.current.aspect\n      )\n    )\n  }\n\n  const endPointer = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (!pointers.current.delete(event.pointerId)) return\n    // Asked first: releasing a capture that pointercancel has already taken back throws.\n    if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {\n      event.currentTarget.releasePointerCapture(event.pointerId)\n    }\n    if (pointers.current.size > 0) {\n      beginGesture()\n      return\n    }\n    gesture.current = null\n    setInteracting(false)\n    const current = live.current.crop\n    const size = live.current.image\n    if (current && size) {\n      onChangeEnd?.(current)\n      announce(current, size)\n    }\n  }\n\n  // Wheel zoom is bound by hand because React delivers wheel through a passive listener on the\n  // root, where preventDefault does nothing at all — the page would scroll behind the cropper\n  // while the cropper zoomed. Non-passive, and only over the frame.\n  const settle = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n  React.useEffect(() => {\n    const node = frameRef.current\n    if (!node) return\n    const onWheel = (event: WheelEvent) => {\n      const current = live.current.crop\n      const size = live.current.image\n      if (live.current.disabled || !current || !size) return\n      event.preventDefault()\n      // deltaMode 1 counts lines and 2 counts pages. A Firefox mouse wheel reports 3 lines where\n      // a trackpad reports pixels, so reading the number as pixels zooms by a thousandth there.\n      const box = frameBox()\n      const perUnit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? box.height || 400 : 1\n      const zoom = clamp(\n        cropZoom(current, size, live.current.aspect) * Math.exp(-event.deltaY * perUnit * 0.002),\n        1,\n        live.current.topZoom\n      )\n      const next = zoomCropTo(\n        current,\n        size,\n        live.current.aspect,\n        zoom,\n        sourceAt(current, event.clientX, event.clientY)\n      )\n      commit(next)\n      // A wheel has no end event, so the settled crop is reported once the pushing stops.\n      if (settle.current) clearTimeout(settle.current)\n      settle.current = setTimeout(() => {\n        const settled = live.current.crop\n        if (settled) onChangeEnd?.(settled)\n      }, 200)\n    }\n    node.addEventListener(\"wheel\", onWheel, { passive: false })\n    return () => {\n      node.removeEventListener(\"wheel\", onWheel)\n      if (settle.current) clearTimeout(settle.current)\n    }\n  }, [commit, onChangeEnd])\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const current = live.current.crop\n    const size = live.current.image\n    if (disabled || !current || !size) return\n    const step = event.shiftKey ? NUDGE_FAST : NUDGE\n    let next: CropRect | null = null\n\n    if (event.key === \"ArrowLeft\" || event.key === \"ArrowRight\") {\n      const by = current.width * step * (event.key === \"ArrowLeft\" ? -1 : 1)\n      next = clampCrop({ ...current, x: current.x + by }, size, aspect)\n    } else if (event.key === \"ArrowUp\" || event.key === \"ArrowDown\") {\n      const by = current.height * step * (event.key === \"ArrowUp\" ? -1 : 1)\n      next = clampCrop({ ...current, y: current.y + by }, size, aspect)\n    } else if (event.key === \"+\" || event.key === \"=\") {\n      next = zoomCropTo(current, size, aspect, cropZoom(current, size, aspect) * KEY_ZOOM)\n    } else if (event.key === \"-\" || event.key === \"_\") {\n      next = zoomCropTo(current, size, aspect, cropZoom(current, size, aspect) / KEY_ZOOM)\n    }\n\n    if (!next) return\n    // Only once a key is one this handles: otherwise Tab could not leave the frame, and a page\n    // that scrolls would stop scrolling for every key anybody pressed inside it.\n    event.preventDefault()\n    const zoomed = clampCrop(\n      cropZoom(next, size, aspect) > topZoom ? zoomCropTo(next, size, aspect, topZoom) : next,\n      size,\n      aspect\n    )\n    commit(zoomed, true)\n    announce(zoomed, size)\n  }\n\n  /** Reports the crop that a slider has finished moving. Sliders have no gesture end of their own. */\n  const endSlider = () => {\n    const current = live.current.crop\n    const size = live.current.image\n    if (current && size) {\n      onChangeEnd?.(current)\n      announce(current, size)\n    }\n  }\n\n  const setZoom = (zoom: number) => {\n    const current = live.current.crop\n    const size = live.current.image\n    if (!current || !size) return\n    commit(zoomCropTo(current, size, aspect, clamp(zoom, 1, topZoom)))\n  }\n\n  /** A position slider works in percent of the travel available, so it reads the same at any zoom. */\n  const setPosition = (axis: \"x\" | \"y\", percent: number) => {\n    const current = live.current.crop\n    const size = live.current.image\n    if (!current || !size) return\n    const span = axis === \"x\" ? size.width - current.width : size.height - current.height\n    commit(clampCrop({ ...current, [axis]: (span * percent) / 100 }, size, aspect))\n  }\n\n  const handle: ImageCropHandle = {\n    getCrop: () => live.current.crop,\n    setCrop: (next) => {\n      const size = live.current.image\n      if (!size) return\n      const clamped = clampCrop(next, size, aspect)\n      commit(\n        cropZoom(clamped, size, aspect) > topZoom\n          ? zoomCropTo(clamped, size, aspect, topZoom)\n          : clamped,\n        true\n      )\n    },\n    reset: () => {\n      const size = live.current.image\n      if (size) commit(coverCrop(size, aspect), true)\n    },\n    getImageSize: () => live.current.image,\n    toCanvas: async (options) => {\n      const node = imgRef.current\n      const current = live.current.crop\n      if (!node || !current) throw new Error(\"image-crop: the image has not loaded yet\")\n      return cropToCanvas(node, blob, current, { ...output, ...options })\n    },\n    toBlob: async (options) => {\n      const merged = { ...output, ...options }\n      const canvas = await handle.toCanvas(merged)\n      return new Promise<Blob | null>((resolve) =>\n        canvas.toBlob(resolve, merged.type ?? \"image/png\", merged.quality)\n      )\n    },\n    toDataURL: async (options) => {\n      const merged = { ...output, ...options }\n      const canvas = await handle.toCanvas(merged)\n      return canvas.toDataURL(merged.type ?? \"image/png\", merged.quality)\n    },\n  }\n  React.useImperativeHandle(ref, () => handle)\n\n  const zoom = crop && image ? clamp(cropZoom(crop, image, aspect), 1, topZoom) : 1\n  const spanX = crop && image ? image.width - crop.width : 0\n  const spanY = crop && image ? image.height - crop.height : 0\n  const percent = (n: number, span: number) => (span > 0.5 ? Math.round((n / span) * 100) : 0)\n\n  return (\n    <div className={cn(\"flex w-full flex-col gap-3 text-foreground\", className)} {...props}>\n      <div\n        ref={frameRef}\n        role=\"group\"\n        aria-label={text.frame}\n        aria-describedby={hintId}\n        aria-disabled={disabled || undefined}\n        tabIndex={disabled ? -1 : 0}\n        onPointerDown={handlePointerDown}\n        onPointerMove={handlePointerMove}\n        onPointerUp={endPointer}\n        onPointerCancel={endPointer}\n        onKeyDown={handleKeyDown}\n        style={{ aspectRatio: `${aspect}` }}\n        className={cn(\n          // touch-action none, or the first drag on a phone scrolls the page while the browser\n          // works out what the gesture was, and the start of the pan is swallowed either way.\n          \"relative w-full touch-none select-none overflow-hidden border border-border bg-muted\",\n          shape === \"round\" ? \"rounded-full\" : \"rounded-md\",\n          disabled ? \"cursor-not-allowed opacity-60\" : \"cursor-grab active:cursor-grabbing\",\n          \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n          frameClassName\n        )}\n      >\n        {href ? (\n          // A plain <img>, deliberately. A framework's image component wants a known path it can\n          // re-encode at build time, and half the sources here are Blob URLs for a file chosen a\n          // second ago — while the cropper needs this element's own naturalWidth and the exact\n          // pixels the export will read back.\n          <img\n            ref={attachImage}\n            src={href}\n            alt={alt}\n            crossOrigin={crossOrigin}\n            // The browser's own image drag starts on the first pointer move and hands the file to\n            // whatever is underneath, instead of panning.\n            draggable={false}\n            onLoad={(event) => handleLoad(event.currentTarget)}\n            className=\"pointer-events-none absolute left-0 top-0 max-w-none\"\n            style={\n              crop && image && cssPerPixel > 0\n                ? {\n                    width: image.width * cssPerPixel,\n                    height: image.height * cssPerPixel,\n                    transform: `translate(${-crop.x * cssPerPixel}px, ${-crop.y * cssPerPixel}px)`,\n                  }\n                : { visibility: \"hidden\" }\n            }\n          />\n        ) : null}\n\n        {grid && interacting ? (\n          <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\">\n            {/*\n              The one fixed colour in the component, and it is fixed on purpose: what these lines\n              lie on is a photograph, not a themed surface. A border token is picked to sit against\n              the page's own background, so in dark mode it disappears into any bright picture and\n              in light mode into any dark one — where a translucent white reads as a guide over\n              both, which is why every camera app draws it this way.\n            */}\n            <div className=\"absolute inset-y-0 left-1/3 w-px bg-white/40\" />\n            <div className=\"absolute inset-y-0 left-2/3 w-px bg-white/40\" />\n            <div className=\"absolute inset-x-0 top-1/3 h-px bg-white/40\" />\n            <div className=\"absolute inset-x-0 top-2/3 h-px bg-white/40\" />\n          </div>\n        ) : null}\n      </div>\n\n      {controls ? (\n        <label className={ROW_CLASS}>\n          <span className=\"shrink-0\">{text.zoom}</span>\n          <input\n            type=\"range\"\n            min={1}\n            max={topZoom}\n            step={0.01}\n            value={zoom}\n            disabled={disabled || !image}\n            onChange={(event) => setZoom(Number(event.target.value))}\n            onPointerUp={endSlider}\n            onKeyUp={endSlider}\n            className={SLIDER_CLASS}\n          />\n        </label>\n      ) : null}\n\n      {/*\n        The two axes as real sliders, hidden until something in here has focus. A screen reader\n        reaches them regardless — they are in the accessibility tree, labelled, and announce a\n        percentage as they move — while a sighted keyboard user has the arrow keys on the frame and\n        sees these appear only on tabbing into them. Visually hiding a focusable control is\n        otherwise a trap, which is the whole reason the group unhides itself on focus-within.\n      */}\n      <div className=\"sr-only flex-col gap-2 focus-within:not-sr-only focus-within:flex\">\n        <label className={ROW_CLASS}>\n          <span className=\"shrink-0\">{text.horizontal}</span>\n          <input\n            type=\"range\"\n            min={0}\n            max={100}\n            step={1}\n            value={percent(crop?.x ?? 0, spanX)}\n            disabled={disabled || !image || spanX <= 0.5}\n            onChange={(event) => setPosition(\"x\", Number(event.target.value))}\n            onPointerUp={endSlider}\n            onKeyUp={endSlider}\n            className={SLIDER_CLASS}\n          />\n        </label>\n        <label className={ROW_CLASS}>\n          <span className=\"shrink-0\">{text.vertical}</span>\n          <input\n            type=\"range\"\n            min={0}\n            max={100}\n            step={1}\n            value={percent(crop?.y ?? 0, spanY)}\n            disabled={disabled || !image || spanY <= 0.5}\n            onChange={(event) => setPosition(\"y\", Number(event.target.value))}\n            onPointerUp={endSlider}\n            onKeyUp={endSlider}\n            className={SLIDER_CLASS}\n          />\n        </label>\n      </div>\n\n      <span id={hintId} className=\"sr-only\">\n        {text.hint}\n      </span>\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </div>\n  )\n})\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
