Full editor
The crop area, the dial, zoom, aspect ratios, the buttons and a JPEG export in one component.
Lay out the crop area
Give the viewport a size. The stylesheet paints the mask, the crop frame, the handles and the grid. Eight Handle parts let the visitor drag every corner and edge.
const handles = [
"top-left",
"top",
"top-right",
"right",
"bottom-right",
"bottom",
"bottom-left",
"left",
] as const;
<Cropper.Viewport
style={{ maxWidth: 640, height: 480, padding: 24 }}
>
<Cropper.Image />
<Cropper.Mask />
<Cropper.Area aria-label="Crop area">
<Cropper.Grid />
{handles.map((position) => (
<Cropper.Handle
key={position}
position={position}
/>
))}
</Cropper.Area>
</Cropper.Viewport>Straighten with the dial
The dial fills its parent unless you give it a width, see dial.
DialValue prints the angle as a whole number. format adds the degree sign.
<Cropper.DialValue
format={(degrees) =>
`${Math.sign(degrees) * Math.round(Math.abs(degrees))}°`
}
/>
<Cropper.Dial
aria-label="Straighten"
style={{ maxWidth: 361 }}
>
<Cropper.DialTicks />
</Cropper.Dial>Add a zoom slider
zriz ships Zoom and ZoomThumb with no paint. track draws a 2 px line and thumb draws the knob. zriz moves the knob from the left end at 1× to the right end at the largest zoom.
const track = {
width: 200,
height: 24,
backgroundImage:
"linear-gradient(currentColor, currentColor)",
backgroundSize: "100% 2px",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
};
const thumb = {
top: 4,
width: 16,
height: 16,
borderRadius: 8,
background: "currentColor",
translate: "-50%",
};
<Cropper.Zoom aria-label="Zoom" style={track}>
<Cropper.ZoomThumb style={thumb} />
</Cropper.Zoom>Forced colours mode removes a gradient and a plain background, so the slider would vanish. Save this as forced-colors.css next to the editor, which imports it. It brings both back in CanvasText. The styles above are inline, so it needs !important:
@media (forced-colors: active) {
[data-zriz-item="zoom"] {
forced-color-adjust: none;
background-image: linear-gradient(
CanvasText,
CanvasText
) !important;
}
[data-zriz-item="zoom-thumb"] {
background: CanvasText !important;
}
}Pick an aspect ratio
AspectGroup is one tab stop. Aspect ratio lists its keys.
Orientation swaps 4:5 for 5:4 and 16:9 for 9:16. It is off while the aspect is free, original or square. After a swap no item is checked, because the list has neither 5:4 nor 9:16. Aspect ratio flips the labels with the orientation so one item stays checked.
const aspects = [
{ label: "Free", value: "free" },
{ label: "Original", value: "original" },
{ label: "Square", value: 1 },
{ label: "4:5", value: 4 / 5 },
{ label: "16:9", value: 16 / 9 },
] as const;
<Cropper.AspectGroup aria-label="Aspect ratio">
{aspects.map((aspect) => (
<Cropper.AspectItem
key={aspect.label}
value={aspect.value}
>
{aspect.label}
</Cropper.AspectItem>
))}
</Cropper.AspectGroup>
<Cropper.Orientation>Orientation</Cropper.Orientation>Turn, flip and reset
The buttons come with no paint, so put your own icons or words inside. Rotate turns the photo 90° counter-clockwise, Flip mirrors it, and Reset puts the crop back where it started. Reset stays hidden until the crop changes.
<Cropper.Rotate>Rotate</Cropper.Rotate>
<Cropper.Flip>Flip</Cropper.Flip>
<Cropper.Reset>Reset</Cropper.Reset>Describe it to screen readers
Instructions is hidden text. The crop area points to it with aria-describedby, so a screen reader reads it when the area gets focus.
The dial and the zoom slider read out their own values. The buttons do not, so announce speaks for them after each commit. It returns null for any other change. Accessibility covers the keys and the labels.
const spoken: Partial<Record<ChangeReason, string>> = {
"rotate-90": "Turned 90 degrees",
flip: "Flipped",
orientation: "Orientation swapped",
reset: "Crop reset",
};
const announce = (_: CropValue, details: CommitDetails) =>
spoken[details.reason] ?? null;
const instructions = [
"Drag the photo to move it.",
"Arrows move the crop. Alt and arrows resize it.",
"Plus and minus zoom, and 0 resets.",
].join(" ");
<Cropper.Instructions>
{instructions}
</Cropper.Instructions>
<Cropper.Announcer announce={announce} />Export a JPEG
exportCrop makes a JPEG unless the crop is a circle. The button stays off until onLoad fires, because an export before that rejects. exportCrop comes from @zriz/core/dom.
An export can still fail on a very large photo, so save shows its own alert when it does. onError shows a second one when the photo does not open. Errors lists the codes.
key={src} gives each photo a fresh cropper, and the check on shown turns the button off and hides both alerts when src changes.
running holds the export in flight. A second click does nothing, and the button keeps focus because saving sets aria-disabled and leaves it enabled. A new src aborts the export, so an old crop never reaches onSave after the photo changed.
const running = useRef<AbortController | null>(null);
const [saving, setSaving] = useState(false);
const [ready, setReady] = useState(false);
const [failed, setFailed] = useState(false);
const [exportFailed, setExportFailed] = useState(false);
const [shown, setShown] = useState(src);
if (shown !== src) {
setShown(src);
setReady(false);
setFailed(false);
setExportFailed(false);
}
useEffect(() => () => running.current?.abort(), [src]);
const save = async () => {
if (!actions.current || running.current) return;
const controller = new AbortController();
running.current = controller;
setSaving(true);
setExportFailed(false);
try {
const { blob } = await exportCrop(actions.current, {
signal: controller.signal,
});
onSave(blob);
} catch {
setExportFailed(!controller.signal.aborted);
} finally {
running.current = null;
setSaving(false);
}
};
<Cropper.Root
key={src}
actionsRef={actions}
src={src}
onLoad={() => setReady(true)}
onError={() => setFailed(true)}
>
<button
type="button"
disabled={!ready}
aria-disabled={saving}
onClick={save}
>
Export
</button>
{failed && <p role="alert">This photo does not open.</p>}
{exportFailed && (
<p role="alert">This crop did not save. Try again.</p>
)}The whole file
Editor takes a photo URL and hands onSave the JPEG:
"use client";
import { exportCrop } from "@zriz/core/dom";
import type {
ChangeReason,
CommitDetails,
Cropper as CropperApi,
CropValue,
} from "@zriz/react";
import * as Cropper from "@zriz/react/cropper";
import "@zriz/react/styles.css";
import { useEffect, useRef, useState } from "react";
import "./forced-colors.css";
const handles = [
"top-left",
"top",
"top-right",
"right",
"bottom-right",
"bottom",
"bottom-left",
"left",
] as const;
const track = {
width: 200,
height: 24,
backgroundImage:
"linear-gradient(currentColor, currentColor)",
backgroundSize: "100% 2px",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
};
const thumb = {
top: 4,
width: 16,
height: 16,
borderRadius: 8,
background: "currentColor",
translate: "-50%",
};
const aspects = [
{ label: "Free", value: "free" },
{ label: "Original", value: "original" },
{ label: "Square", value: 1 },
{ label: "4:5", value: 4 / 5 },
{ label: "16:9", value: 16 / 9 },
] as const;
const spoken: Partial<Record<ChangeReason, string>> = {
"rotate-90": "Turned 90 degrees",
flip: "Flipped",
orientation: "Orientation swapped",
reset: "Crop reset",
};
const announce = (_: CropValue, details: CommitDetails) =>
spoken[details.reason] ?? null;
const instructions = [
"Drag the photo to move it.",
"Arrows move the crop. Alt and arrows resize it.",
"Plus and minus zoom, and 0 resets.",
].join(" ");
type Props = { src: string; onSave: (jpeg: Blob) => void };
export const Editor = ({ src, onSave }: Props) => {
const actions = useRef<CropperApi>(null);
const running = useRef<AbortController | null>(null);
const [saving, setSaving] = useState(false);
const [ready, setReady] = useState(false);
const [failed, setFailed] = useState(false);
const [exportFailed, setExportFailed] = useState(false);
const [shown, setShown] = useState(src);
if (shown !== src) {
setShown(src);
setReady(false);
setFailed(false);
setExportFailed(false);
}
useEffect(() => () => running.current?.abort(), [src]);
const save = async () => {
if (!actions.current || running.current) return;
const controller = new AbortController();
running.current = controller;
setSaving(true);
setExportFailed(false);
try {
const { blob } = await exportCrop(actions.current, {
signal: controller.signal,
});
onSave(blob);
} catch {
setExportFailed(!controller.signal.aborted);
} finally {
running.current = null;
setSaving(false);
}
};
return (
<Cropper.Root
key={src}
actionsRef={actions}
src={src}
onLoad={() => setReady(true)}
onError={() => setFailed(true)}
>
<Cropper.Viewport
style={{ maxWidth: 640, height: 480, padding: 24 }}
>
<Cropper.Image />
<Cropper.Mask />
<Cropper.Area aria-label="Crop area">
<Cropper.Grid />
{handles.map((position) => (
<Cropper.Handle
key={position}
position={position}
/>
))}
</Cropper.Area>
</Cropper.Viewport>
<Cropper.DialValue
format={(degrees) =>
`${Math.sign(degrees) * Math.round(Math.abs(degrees))}°`
}
/>
<Cropper.Dial
aria-label="Straighten"
style={{ maxWidth: 361 }}
>
<Cropper.DialTicks />
</Cropper.Dial>
<Cropper.Zoom aria-label="Zoom" style={track}>
<Cropper.ZoomThumb style={thumb} />
</Cropper.Zoom>
<Cropper.AspectGroup aria-label="Aspect ratio">
{aspects.map((aspect) => (
<Cropper.AspectItem
key={aspect.label}
value={aspect.value}
>
{aspect.label}
</Cropper.AspectItem>
))}
</Cropper.AspectGroup>
<Cropper.Orientation>Orientation</Cropper.Orientation>
<Cropper.Rotate>Rotate</Cropper.Rotate>
<Cropper.Flip>Flip</Cropper.Flip>
<Cropper.Reset>Reset</Cropper.Reset>
<Cropper.Instructions>
{instructions}
</Cropper.Instructions>
<Cropper.Announcer announce={announce} />
<button
type="button"
disabled={!ready}
aria-disabled={saving}
onClick={save}
>
Export
</button>
{failed && <p role="alert">This photo does not open.</p>}
{exportFailed && (
<p role="alert">This crop did not save. Try again.</p>
)}
</Cropper.Root>
);
};The demo above runs this file with the icons and styles from the zriz site. It shows the angle to one decimal and calls square 1:1. Its aspect labels turn with the orientation, as on aspect ratio. It leaves out the zoom slider, and wheel and pinch still zoom.