Skip to navigation

Exporting

Turn the crop into an image file in the browser, or send the crop value to your server and cut the original there.

Open
Download
JPEGPNGWebP

Save the crop as a file

exportCrop comes from @zriz/core/dom. @zriz/react needs @zriz/core as a peer dependency and re-exports the types an editor needs, such as CropValue, Cropper and ChangeReason. The types of exportCrop and loadImage, such as ExportOptions, ExportResult, LoadedImage and ZrizErrorCode, come from @zriz/core/dom too. Pure functions and the other core types, such as parse and ExportPlan, come from @zriz/core. Quick start installs it. Without React, add it alone:

pnpm add @zriz/core

Keep it at the version @zriz/react asks for. A second copy of @zriz/core cannot see the photo, see no-image on errors.

Pass a ref to actionsRef on Root, and pass cropper.current to exportCrop from a save button. The button stays disabled until onLoad fires for this file, because an export before the photo loads rejects with no-image:

"use client";

import { exportCrop } from "@zriz/core/dom";
import type { Cropper as CropperApi } from "@zriz/react";
import * as Cropper from "@zriz/react/cropper";
import { useEffect, useRef, useState } from "react";

export const SaveCrop = ({ file }: { file: File }) => {
  const cropper = useRef<CropperApi>(null);
  const running = useRef<AbortController | null>(null);
  const [loaded, setLoaded] = useState<File | null>(null);
  const [saving, setSaving] = useState(false);

  useEffect(() => () => running.current?.abort(), [file]);

  const save = async () => {
    if (!cropper.current || running.current) return;
    const controller = new AbortController();

    running.current = controller;
    setSaving(true);
    const result = await exportCrop(cropper.current, {
      type: "image/webp",
      maxSize: { width: 2048, height: 2048 },
      signal: controller.signal,
    }).catch((error: unknown) => {
      if (!controller.signal.aborted) console.error(error);

      return null;
    });

    running.current = null;
    setSaving(false);
    if (!result) return;
    const { blob, type } = result;
    const link = document.createElement("a");

    link.href = URL.createObjectURL(blob);
    const extension = type.replace("image/", "");

    link.download = `crop.${extension}`;
    link.click();
    setTimeout(() => URL.revokeObjectURL(link.href), 1000);
  };

  return (
    <>
      <Cropper.Root
        src={file}
        actionsRef={cropper}
        onLoad={() => setLoaded(file)}
      >
        <Cropper.Viewport
          style={{ maxWidth: 480, height: 360 }}
        >
          <Cropper.Image />
          <Cropper.Mask />
          <Cropper.Area aria-label="Crop area" />
        </Cropper.Viewport>
      </Cropper.Root>
      <button
        type="button"
        disabled={loaded !== file}
        aria-disabled={saving}
        onClick={save}
      >
        Save
      </button>
    </>
  );
};

An export can fail on a very large photo, so the snippet catches it. Errors lists the codes. running holds the export in flight. A second click does nothing, and a new file aborts it, so an old crop never downloads after the photo changed.

The demo above runs this export with the same maxSize, in the format you pick in its track, and uses the styles from the zriz site.

The export draws from the original, not the preview on screen. Without maxSize, the file has the crop's size in source pixels.

Export a round avatar

With shape="circle" on Root, exportCrop reads the shape and returns a PNG with transparent corners. exportImage(cropper, image) exports a LoadedImage from loadImage instead. It does not read the shape, so pass shape: "circle" yourself. Avatar has the whole component.

Choose size and format

Option
type "image/jpeg" | "image/png" | "image/webp" = "image/jpeg"
The file format. A circle crop defaults to PNG. JPEG has no transparency, so clear pixels come out white. Safari cannot write WebP, so there you get JPEG, or PNG for a circle
quality number = 0.9
JPEG and WebP quality, from 0 to 1. For a JPEG photo the default drops to the photo's own quality, so the file does not grow when you only crop it
maxSize { width, height }
Scales the file down to fit inside this box, never up. A box with a side that is not a positive number is ignored
colorSpace "srgb" | "display-p3" = "srgb"
"display-p3" keeps wide colour when the photo came from a file or URL. It decodes on the main thread
shape "rect" | "circle"
"rect" cuts a rectangle. "circle" cuts a circle, with transparent corners in PNG and WebP and white ones in JPEG. Defaults to shape on Root
signal AbortSignal
Cancels a running export, which then rejects with aborted

exportCrop resolves to { blob, type, width, height, scaled, crop }. Read type before you name the file, because a browser that cannot encode the type you asked for returns another one. scaled is true when the file came out smaller than the crop. Three things cause it: maxSize, the export size limit in loading images, and the retry at half and a quarter of the size when the browser cannot draw or encode the file. crop is the export plan the file was drawn from, at the scale it came out.

Exports run one at a time, in the order you call them. An aborted export stops waiting at once, but one the browser already started holds the next one back until it ends.

The browser draws pixels onto a canvas and encodes them, so EXIF, GPS, XMP, thumbnails, gain maps and bytes after the image never reach the file, and neither does 16-bit depth. Safari writes a small EXIF block of its own, with only the crop's size and colour space. Security covers the original, which keeps all of it.

Export an exact size

maxSize never scales up, so a crop smaller than the box comes out smaller. Set minSize on Root to the same size, and every crop is at least that big in source pixels. On a photo at least that big, a square crop with minSize and maxSize both at 512 by 512 exports at 512 by 512.

minSize reads along the photo's own sides, so after a quarter turn a 1200 by 630 minimum can export at 630 by 1200. While you straighten a crop of another shape than the minimum, covering the photo comes first, so that crop can drop below it. The angle stays free while the minimum's diagonal fits along each side of the photo, so only the minimum's size next to the photo counts. A bigger minimum lowers getRotationLimit() below 45, and on a square photo that starts at a square minimum of about 71% of its side. The crop does not grow back on its own.

Handle a failed export

exportCrop rejects with a ZrizError. Errors lists each code an export can fail with, when it fires and how to fix it.

After the first photo appears, zriz loads the export code in the background, so the first export does not wait for a download.

Show a small preview of the crop

Call exportCrop with a small maxSize in onLoad and in onValueCommit, and show the blob with URL.createObjectURL(). The first photo sends no commit until someone touches it, so onLoad draws the first preview. A later photo commits only when it has to refit the crop. Pass an AbortController signal and abort the last export on each commit, so a slow one never overwrites a newer one. Revoke the old object URL when a new preview replaces it:

"use client";

import { exportCrop } from "@zriz/core/dom";
import type { Cropper as CropperApi } from "@zriz/react";
import * as Cropper from "@zriz/react/cropper";
import { useEffect, useRef, useState } from "react";

export const CropWithPreview = ({ file }: { file: File }) => {
  const cropper = useRef<CropperApi>(null);
  const last = useRef<AbortController | null>(null);
  const [preview, setPreview] = useState<string | null>(null);

  useEffect(() => () => last.current?.abort(), []);

  useEffect(
    () => () => {
      if (preview) URL.revokeObjectURL(preview);
    },
    [preview]
  );

  const refresh = async () => {
    if (!cropper.current) return;
    last.current?.abort();
    const controller = new AbortController();

    last.current = controller;

    try {
      const { blob } = await exportCrop(cropper.current, {
        maxSize: { width: 256, height: 256 },
        signal: controller.signal,
      });

      if (!controller.signal.aborted) {
        setPreview(URL.createObjectURL(blob));
      }
    } catch (error) {
      if (!controller.signal.aborted) console.error(error);
    }
  };

  return (
    <>
      <Cropper.Root
        src={file}
        actionsRef={cropper}
        onLoad={() => void refresh()}
        onValueCommit={() => void refresh()}
      >
        <Cropper.Viewport
          style={{ maxWidth: 480, height: 360 }}
        >
          <Cropper.Image />
          <Cropper.Mask />
          <Cropper.Area aria-label="Crop area" />
        </Cropper.Viewport>
      </Cropper.Root>
      {preview && <img src={preview} alt="Crop preview" />}
    </>
  );
};

Cut the original on a server

To cut on a server, send the crop value from getValue() and let the server recompute the plan, as in server crop. Never trust a plan a client sends. getPixelCrop() returns the export plan in source pixels, or null until a photo loads, and each commit carries it as details.pixelCrop. Use it for your own tools, and read all of it, because pixelRect is null with a straighten angle or perspective.

Field
width number
The output width in pixels, never below 1 px. getPixelCrop() takes a scale, 1 by default and for anything but a positive number or a scale that pushes a side past Number.MAX_SAFE_INTEGER
height number
The output height in pixels at the same scale
pixelRect { left, top, width, height } | null
{ left, top, width, height } in the upright original, or null with a straighten angle or perspective
rotateExpand { flop, degrees, dx, dy }
The mirror as flop, the quarter turns times 90 plus the straighten angle as degrees, and the crop's centre from the photo's centre after that whole turn as dx and dy, in source pixels
forward [a, b, c, d, e, f]
The map from source pixels to output pixels, as [a, b, c, d, e, f] in canvas setTransform() order. Alone it holds only when homography is null. With perspective, output = forward(c + H(s - c)), with c the centre of the image in source pixels
inverse [a, b, c, d, e, f]
The map from output pixels back to source pixels. Alone it holds only when homography is null. With perspective, the source pixel is s = c + H⁻¹(inverse(output) - c), with c as above
homography number[] | null
The 3 by 3 perspective matrix H, as 9 numbers row by row, or null with no perspective. Divide by the third coordinate after you apply it

With no straighten and no perspective, the plan comes down to plain numbers that any tool can use: the rectangle, the flop and the turn. pixelRect is the truth for the file: its edges land on whole source pixels, so on a very small photo the frame on screen can sit up to half a source pixel from the file edge. A side never goes below 1 px, so a thin crop can come out slightly over maxSize.

toSharp(plan, image) from @zriz/core takes the plan and the upright photo's size and returns sharp steps with kind "extract" or "affine". It ignores perspective without an error, so check plan.homography first. Server crop shows the route for a level crop and a straightened one.