Loading images
Open a photo from a file input, a URL or a blob, and check which formats and sizes load.
Open a file from an input
src on Root takes a URL string, a Blob, a File, or null for an empty cropper. Keep the picked file in state and pass it straight to src:
<input
aria-label="Choose a photo"
type="file"
accept="image/*"
onChange={(event) =>
setFile(event.target.files?.[0] ?? null)
}
/>
<Cropper.Root src={file}>
<Cropper.Viewport style={{ maxWidth: 480, height: 360 }}>
<Cropper.Image />
<Cropper.Mask />
<Cropper.Area aria-label="Crop area" />
</Cropper.Viewport>
</Cropper.Root>zriz reads the format from the first 4096 bytes of the file, so a wrong name or a wrong raster mime type does not stop a photo from opening. A file declared as a raster image type never opens as SVG, and svg={false} on Root or svg: false on loadImage refuses SVG altogether. Both fail with decode. When src changes, zriz cancels a load that is still running and keeps onError quiet for it. The previous photo stays on screen until the new one is ready, and stays there when the new one fails.
Start each photo from the full frame
A new src keeps the current crop, angle and flip, and fits them to the new photo. A controlled zoom goes back to the prop when the new photo has another size. To start every photo from the whole frame, give Root a key that changes with each pick, such as a counter you bump in onChange. A new key also frees the previous photo at once.
<Cropper.Root key={picks} src={file}>
{children}
</Cropper.Root>Open a URL on another domain
zriz fetches a URL with fetch in CORS mode, so the server that hosts the photo must send Access-Control-Allow-Origin. Without it, the load fails with cross-origin. A 404 or any other error status fails with decode.
If that server needs cookies, pass crossOrigin="use-credentials". The server then has to name your origin in Access-Control-Allow-Origin, not *, and send Access-Control-Allow-Credentials: true.
If that server wants a token in a header, fetch the photo yourself with that header and pass the response's blob to src.
Check which formats open
| Format | What happens |
|---|---|
| JPEG, PNG, WebP, AVIF, GIF | Open in every current browser |
| BMP | Opens in every current browser, but Safari cannot open some BMP files with a v4 or v5 header. Those fail with decode. Pass a decode function to open them, or convert the file to JPEG first |
| HEIC, JPEG XL, TIFF | Open where the browser decodes them. They fail with unsupported-format elsewhere, and when the file is damaged |
| SVG | Takes its size from width and height in px, in, cm, mm, q, pt or pc, or from viewBox. With one side and a viewBox, the other follows the ratio. With no viewBox, a missing width opens at 300 and a missing height at 150. A side in % or em counts as missing. zriz draws it once at that size, at most the preview size, and exports from that drawing. One that would freeze the page fails with too-large, see security |
| Animated GIF, PNG, WebP, AVIF | Open on their first frame. From a file or URL, animated on the loaded image is true, and always for a GIF. A HEIC sequence also reports true. An img, video, canvas, OffscreenCanvas or ImageBitmap source always reports false, even an img showing a GIF. An img already playing opens on the frame it shows in Safari, so pass a Blob for the first frame |
zriz walks the structure of a JPEG, PNG, AVIF or HEIC before it decodes, so a file cut short fails with decode in every browser. Data after the end of the file still opens. Firefox opens a cut GIF, and Firefox and Safari open a cut BMP, with the missing part painted in.
zriz measures the crop value and the export plan in the photo as the browser shows it. Every EXIF orientation in a JPEG opens upright in every browser. Chromium and Firefox ignore the orientation of a WebP and Safari honours it, so a tagged WebP opens differently per browser, and the route in server crop refuses it.
Handle large photos
A worker decodes a preview sized for the screen. The original stays a blob until you export.
| Limit | Size |
|---|---|
| Preview size | Twice the screen's long edge in device pixels, at most 4096 px on a touch screen and 8192 px elsewhere. previewEdge on Root sets it |
| Export size | 16.8 megapixels on a touch screen and 268 megapixels elsewhere, with a long edge of at most 16383 px for WebP and 65500 px for other types. A bigger crop comes out scaled down |
Before it decodes, zriz reads the pixel count from the file's header. A JPEG, PNG, WebP, AVIF, HEIC, GIF, BMP or TIFF over maxPixels fails with too-large. A GIF counts its largest frame. A JPEG XL has no count zriz can read, so it fails after the decode, and so does what a decode function returns. The limit is 268402689 pixels, which opens every camera photo and matches sharp. Pass maxPixels to loadImage to change it. maxPixels does not apply to an img, video, canvas, OffscreenCanvas or ImageBitmap source, or to an SVG, which zriz draws at most at the preview size.
A load has one time limit. A download stays inside it while bytes keep arriving, and the decode gets what is left after the last byte. Past it the load fails with timeout, and errors gives the number. zriz runs six downloads and three decodes at a time, and a photo waiting for its turn is not on the clock. A URL that hangs holds one of the six until the limit. To allow more time, load the photo yourself and pass timeout, as in the HEIC section below.
A content security policy needs these sources:
| Directive | What it enables |
|---|---|
worker-src blob: | Decoding and exporting off the main thread. Without it zriz does both on the main thread, and the page can stutter while a large photo opens |
img-src blob: | The moving layer during gestures and display-p3 export. Without it the photo paints on the canvas during gestures, a crop with perspective stays empty, and a display-p3 export fails with decode |
img-src data: | SVG files. Without it an SVG fails with decode |
connect-src | Each host you load photos from, plus blob: and data: if you pass those URLs as src. Without it the load fails with cross-origin |
With require-trusted-types-for 'script', add zriz to trusted-types. zriz starts its workers through a policy of that name, and the policy only takes blob: URLs. Without it the page works, but decodes and exports on the main thread.
A strict style-src also refuses style attributes in server HTML. The parts write their own styles again during hydration, so they work after it. A style prop of yours on a server rendered part stays refused. Add style-src-attr 'unsafe-inline' to allow it, or give the part a class from your stylesheet instead.
Open HEIC on every browser
To decode a format the browser cannot, call loadImage yourself and pass a decode function. zriz calls it only when the browser's own decode fails. It gets the Blob and returns an ImageBitmap. Save this as decoder.ts. Its convert sends the file to a route on your server that turns it into JPEG, so the original leaves the device. Security says how to treat it. loadImage and attachImage come from @zriz/core/dom, so add @zriz/core as in exporting:
import { attachImage, loadImage } from "@zriz/core/dom";
import type { Cropper } from "@zriz/react";
const convert = async (blob: Blob) => {
const response = await fetch("/api/convert", {
method: "POST",
body: blob,
});
return await createImageBitmap(await response.blob());
};
export const open = async (
cropper: Cropper,
file: File,
signal: AbortSignal
) => {
const image = await loadImage(file, {
timeout: 30_000,
decode: convert,
signal,
});
attachImage(cropper, image);
};Pass a ref to actionsRef, leave src unset on Root and call open() from the file input. onLoad and onError do not fire for a photo you attach yourself, so the component tracks both from open():
"use client";
import type { Cropper as CropperApi } from "@zriz/react";
import * as Cropper from "@zriz/react/cropper";
import { useEffect, useRef, useState } from "react";
import { open } from "./decoder";
type Props = { onSave: (cropper: CropperApi) => void };
export const Heic = ({ onSave }: Props) => {
const cropper = useRef<CropperApi>(null);
const last = useRef<AbortController | null>(null);
const [ready, setReady] = useState(false);
const [failed, setFailed] = useState(false);
useEffect(() => () => last.current?.abort(), []);
return (
<>
<input
aria-label="Choose a photo"
type="file"
accept="image/*,.heic"
onChange={(event) => {
const file = event.target.files?.[0];
if (!file || !cropper.current) return;
last.current?.abort();
const controller = new AbortController();
last.current = controller;
setReady(false);
setFailed(false);
open(cropper.current, file, controller.signal).then(
() => setReady(true),
() => {
if (!controller.signal.aborted) setFailed(true);
}
);
}}
/>
<Cropper.Root actionsRef={cropper}>
<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={!ready}
onClick={() =>
cropper.current && onSave(cropper.current)
}
>
Save
</button>
{failed && (
<p role="alert">This photo does not open here.</p>
)}
</>
);
};ready turns the save button on once the photo is attached. onSave gets the cropper for exportCrop, as in exporting. A new pick or an unmount aborts the load still running, so a slow file never replaces a newer one or lands on a cropper that left.
loadImage also takes timeout in milliseconds, previewEdge, the longest side of the preview in pixels, maxPixels, svg, crossOrigin and an abort signal. It accepts an img, video, canvas, OffscreenCanvas or ImageBitmap as its source. A video needs a current frame, so wait for loadeddata or seeked, or the load fails with decode. A fully transparent frame counts as no frame.
attachImage frees a photo when it leaves its last cropper, and destroy() does the same for the photo on that cropper. Attaching to a destroyed cropper frees the photo at once. A freed photo cannot be attached again, so load it again. A photo that never reached a cropper holds memory until you call discardImage(image), for example after your own stale check or an exportImage with no attachImage. It does nothing while a cropper holds the photo.
zriz never closes an ImageBitmap you pass as the source. Close it yourself once the photo left its cropper, and not before.
Handle a failed load
onError on Root gets a ZrizError. Its code names the problem, such as timeout or cross-origin. onLoad fires instead when the photo is ready, with its width, height and mime. Errors lists every code with its fix.