Vanilla JS
Run the same cropper in plain JavaScript or any framework, with createCropper and the DOM binders.
Install the core package
Add @zriz/core as in exporting. It has no dependencies, not even React.
Write the markup
Give each element its data-zriz-item, so the stylesheet finds it, and give the viewport a width and a height. The binders set no role or name on the crop area, so write them in the markup:
<div id="cropper" data-zriz-item="root">
<div
id="viewport"
data-zriz-item="viewport"
style="max-width: 480px; height: 360px; padding: 24px"
>
<div id="photo" data-zriz-item="image"></div>
<div id="mask" data-zriz-item="mask"></div>
<div
id="area"
data-zriz-item="area"
role="application"
tabindex="0"
aria-label="Crop area"
style="position: absolute"
></div>
</div>
<div
id="dial"
data-zriz-item="dial"
aria-label="Straighten"
style="position: relative"
>
<div id="ticks" data-zriz-item="dial-ticks"></div>
</div>
</div>
<input id="file" type="file" aria-label="Choose a photo" accept="image/*" />
<p id="status" role="alert"></p>Bind the parts
Create a cropper, bind each element, add the four corner handles, and attach the photo when the user picks a file. Save this as cropper.ts. The snippets below import cropper and cleanups from it:
import { createCropper } from "@zriz/core";
import {
attachImage,
bindArea,
bindHandle,
bindMask,
bindPhoto,
bindState,
bindViewport,
loadImage,
} from "@zriz/core/dom";
import "@zriz/core/styles.css";
const byId = (id: string) =>
document.getElementById(id) as HTMLElement;
export const cropper = createCropper({
defaultValue: { aspect: 4 / 3 },
});
export const cleanups = [
bindState(cropper, byId("cropper")),
bindViewport(cropper, byId("viewport")),
bindPhoto(cropper, byId("photo")),
bindMask(cropper, byId("mask")),
bindArea(cropper, byId("area")),
];
const corners = [
"top-left",
"top-right",
"bottom-left",
"bottom-right",
] as const;
for (const corner of corners) {
const handle = document.createElement("div");
handle.dataset.zrizItem = "handle";
handle.setAttribute("aria-hidden", "true");
byId("area").append(handle);
cleanups.push(bindHandle(cropper, handle, corner));
}
const input = byId("file") as HTMLInputElement;
let last: AbortController | undefined;
const onFile = async () => {
const file = input.files?.[0];
if (!file) return;
last?.abort();
const controller = new AbortController();
const { signal } = controller;
last = controller;
byId("status").textContent = "";
try {
attachImage(cropper, await loadImage(file, { signal }));
} catch {
if (signal.aborted) return;
byId("status").textContent = "This photo does not open.";
}
};
input.addEventListener("change", onFile);
cleanups.push(() => {
last?.abort();
input.removeEventListener("change", onFile);
});Each binder writes geometry inline and returns its cleanup. bindHandle also takes "top", "right", "bottom" and "left" for the edge bars. After you pick a file, the photo shows under a 4:3 crop. The handles have no layout until the next section places them.
Each pick aborts the load before it, so a slow file never replaces a newer one, and clears the old message. A cleanup aborts the last load and removes the listener. When a file does not open, loadImage rejects with a ZrizError, and errors lists its codes.
Place the handles
bindHandle wires the drag but leaves layout to you. This CSS centres a 44 px hit box on each corner and on the middle of each edge, and --zriz-handle-size on the area resizes it as on crop area:
[data-zriz-item="handle"] {
position: absolute;
top: 0;
left: 0;
width: var(--zriz-handle-size, 44px);
height: var(--zriz-handle-size, 44px);
translate: -50% -50%;
}
[data-edge$="right"] {
left: 100%;
}
[data-edge^="bottom"] {
top: 100%;
}
[data-edge="top"],
[data-edge="bottom"] {
left: 50%;
}
[data-edge="left"],
[data-edge="right"] {
top: 50%;
}Move the crop with keys
bindArea moves the crop but listens to no keys. Pass the area's events to areaKeyHandlers:
import { areaKeyHandlers } from "@zriz/core/dom";
import { cropper } from "./cropper";
const area = document.getElementById("area") as HTMLElement;
const keys = areaKeyHandlers(cropper);
area.onkeydown = keys.onKeyDown;
area.onkeyup = keys.onKeyUp;
area.onblur = keys.onBlur;
export const unbind = () => {
keys.release();
};The keys are the ones on crop area. Call keys.release() when you unbind, so a key held at that moment does not lock the cropper. zoomHandlers and handleHandlers have the same release.
Add the dial
bindDial handles drags, keys and the slider's ARIA values. bindDialTicks fills the strip with 41 ticks and moves it:
import { bindDial, bindDialTicks } from "@zriz/core/dom";
import { cleanups, cropper } from "./cropper";
const dial = document.getElementById("dial") as HTMLElement;
const ticks = document.getElementById("ticks") as HTMLElement;
cleanups.push(
bindDial(cropper, dial),
bindDialTicks(cropper, ticks)
);On a right-to-left page, the arrows and the drag swap. Pass { dir: "rtl" } or { dir: "ltr" } as the third argument of bindDial to set it yourself.
The same argument takes locale and format for the spoken angle, and ownText: true to keep an aria-valuetext of yours, which bindDial overwrites otherwise. It also takes gain, the pixels of drag per degree, 4 by default, slop, the pixels a drag travels before the dial moves, 10 by default, and tau, the time constant of a flick in ms, 95 by default.
After a flick, the speed falls to about a third every tau ms, and the glide stops once it drops under 0.5° a second. It ends about the flick's speed times tau past where you let go, so a flick at 100° a second glides about 9.5°.
locale, format and ownText belong to that element. dir, gain, slop and tau belong to the cropper: a second bindDial merges its values over the first, and both dials follow them. bindDialTicks spaces the ticks gain times 2.25 px apart, so it redraws when gain changes. When the last dial unbinds, the defaults come back.
bindDialTicks(cropper, strip, count) makes 41 ticks by default, and uses the children when the strip already has some.
Add a zoom slider
Zoom has no single binder. Give the element role="slider", tabindex="0", an aria-label, a size, position: relative, touch-action: none and user-select: none, and draw the thumb yourself. The stylesheet paints nothing on zoom:
<div
id="zoom"
data-zriz-item="zoom"
role="slider"
tabindex="0"
aria-label="Zoom"
style="
position: relative;
width: 200px;
height: 24px;
touch-action: none;
user-select: none;
-webkit-user-select: none;
"
>
<div
id="thumb"
data-zriz-item="zoom-thumb"
style="
top: 4px;
width: 16px;
height: 16px;
border-radius: 8px;
background: currentColor;
translate: -50%;
"
></div>
</div>Forced colours mode drops the colour on the thumb. Add this rule to your stylesheet to paint it in CanvasText. Keep it in a CSS file, because a strict style-src refuses a <style> element. The thumb's colour is inline, so the rule needs !important:
@media (forced-colors: active) {
[data-zriz-item="zoom-thumb"] {
background: CanvasText !important;
}
}Then hand its events to zoomHandlers:
import {
bindZoomThumb,
zoomAria,
zoomHandlers,
} from "@zriz/core/dom";
import { cleanups, cropper } from "./cropper";
const zoom = document.getElementById("zoom") as HTMLElement;
const thumb = document.getElementById("thumb") as HTMLElement;
const handlers = zoomHandlers(cropper);
zoom.onpointerdown = handlers.onPointerDown;
zoom.onpointermove = handlers.onPointerMove;
zoom.onpointerup = handlers.onPointerUp;
zoom.onpointercancel = handlers.onPointerCancel;
zoom.onlostpointercapture = handlers.onLostPointerCapture;
zoom.onkeydown = handlers.onKeyDown;
zoom.onkeyup = handlers.onKeyUp;
zoom.onblur = handlers.onBlur;
cleanups.push(
zoomAria(cropper, zoom),
bindZoomThumb(cropper, thumb)
);zoomAria keeps the slider's values current, and bindZoomThumb moves the thumb from 0% at 1× to 100% at the largest zoom. To end the track early, pass the same max to all three: zoomHandlers(cropper, { max, dir }), zoomAria(cropper, zoom, { max, locale, format, ownText }) and bindZoomThumb(cropper, thumb, { max }). Without ownText: true, zoomAria overwrites your aria-valuetext.
Read the crop
The commit event fires once per gesture with the crop value and the export plan, which is null until a photo loads:
import { cropper } from "./cropper";
cropper.on("commit", (value, details) => {
console.log(value.size, details.pixelCrop);
});To turn the crop into a file, pass the cropper to exportCrop as in exporting. The first photo sends no commit until someone touches it, and a later photo sends one only when it has to refit the crop, see events.
Connect the rest
| Part | How to wire it |
|---|---|
| Dial value | bindDialValue(cropper, element, format) writes the angle as text. Without format, it writes whole degrees |
| Grid | No binder. bindState writes data-grid on the root as "none", "coarse" or "fine". Draw the lines in CSS. The stylesheet keeps data-zriz-item="grid" at opacity 0 without data-visible, and no binder sets it, so set opacity from [data-grid] on the root or leave that attribute off the element |
| State attributes | bindState writes every attribute root lists on your element, except data-shape. Set that one in your markup. Only the React parts write data-interacting on the area, data-active on a handle, data-ready on the image, data-dragging on zoom and data-shape on the area and mask. Leave out the fade rule on viewport, which would keep the photo hidden here |
| Buttons | No binder. Call rotate90(), flip(), reset() or setAspect(1) on the cropper from a click |
| Your own img | bindImage(cropper, element) sizes and moves an element of yours with the photo. You set its src |
| Round crop | Pass "circle" as the third argument of bindMask and { shape: "circle" } as the third argument of bindArea. Set aspect: 1 in defaultValue. The stylesheet hides the corner handles when the area has data-shape="circle", which bindArea leaves to your markup |
| Wheel and twist | Pass { wheel: "zoom", rotateGesture: false } as the third argument of bindViewport. wheel also takes "pan" and "none" |
| Motion | Call viewOf(cropper).setOptions({ motion: "reduce", recenterDelay: 800 }). motion also takes "auto" and "always". Each call replaces both options |
| Live variables | Pass { liveVariables: true } as the third argument of bindArea, see crop area |
| Announcements | No binder. On commit, write your own text into an element with role="status", and point the area's aria-describedby at your instructions |
Tear it down
Call every cleanup, detach the photo so its memory is freed, then destroy the cropper:
import { attachImage } from "@zriz/core/dom";
import { cleanups, cropper } from "./cropper";
for (const cleanup of cleanups) cleanup();
attachImage(cropper, null);
cropper.destroy();destroy() also runs each plugin's destroy.