headless React image cropper
README.md

@oomfware/cropper #

headless React image cropper.

npm install @oomfware/cropper

compose the cropper from a viewport, source image, and crop window, then style every visible part yourself. the crop stays in source-pixel coordinates while the viewport resizes, and supports drag, pinch, wheel zoom, and quarter-turn rotation.

usage #

quick start #

import { Cropper, type CropValue } from '@oomfware/cropper';
import { useState } from 'react';

const AvatarCropper = () => {
	const [crop, setCrop] = useState<CropValue>();

	return (
		<>
			<Cropper.Root
				aspectRatio={1}
				config={{ insets: { top: 24, right: 24, bottom: 24, left: 24 } }}
				onValueChange={setCrop}
			>
				<Cropper.Viewport className="cropper-viewport">
					<Cropper.Image
						className="cropper-image"
						src="/photos/pug.jpg"
						alt="Pug in a blanket"
						width={1600}
						height={1067}
					/>
					<Cropper.Window className="cropper-window" />
				</Cropper.Viewport>
			</Cropper.Root>

			<pre>{crop ? JSON.stringify(crop, null, 2) : 'measuring image'}</pre>
		</>
	);
};

give the viewport an explicit size and decorate the window however you like:

.cropper-viewport {
	width: min(100%, 34rem);
	aspect-ratio: 4 / 3;
	background: #111;
	cursor: grab;
}

.cropper-viewport[data-dragging] {
	cursor: grabbing;
}

.cropper-window {
	border-radius: 50%;
	box-shadow:
		inset 0 0 0 1px rgb(255 255 255 / 0.8),
		0 0 0 9999px rgb(0 0 0 / 0.6);
}

the parts apply only the inline layout styles needed for cropping. there is no bundled stylesheet, toolbar, dialog, file picker, or output encoder.

the crop value #

onValueChange reports a rectangle in the rotated source image's natural pixels:

interface CropValue {
	rotation: 0 | 1 | 2 | 3;
	x: number;
	y: number;
	width: number;
	height: number;
}

rotation is the number of clockwise quarter-turns to apply before reading the rectangle. x and y are its top-left corner in that rotated coordinate space. values may be fractional; round only when the image pipeline consuming the crop requires integer pixels.

the package describes the crop but does not create a cropped Blob or canvas. pass the value to your image pipeline, applying rotation before extracting the rectangle.

the image's width and height props can declare its natural size before it loads. the measured natural size takes over after load. changing src resets the crop for the new source.

controlled and uncontrolled state #

use defaultValue for an initial uncontrolled crop:

<Cropper.Root
	aspectRatio={1}
	defaultValue={{ rotation: 0, x: 120, y: 40, width: 640, height: 640 }}
	onValueChange={saveCrop}
>
	{/* ... */}
</Cropper.Root>

use value and onValueChange when another state owner controls the crop:

<Cropper.Root aspectRatio={1} value={crop} onValueChange={setCrop}>
	{/* ... */}
</Cropper.Root>

invalid or out-of-bounds values are legalized against the source size, aspect ratio, and zoom limits. changing only the viewport size leaves the source-pixel value untouched.

gestures #

  • pan — drag the image with one pointer. movement clamps at the source edges.
  • zoom — pinch with two pointers or use the wheel. pinch and wheel zoom hold the source point beneath the gesture in place.
  • rotate — call rotate() or setRotation() from useCropper. rotation preserves the framing and re-fits where necessary to keep the crop window covered.

interactive descendants such as buttons, links, and form controls are excluded from viewport drag handling. add data-cropper-control to any other descendant that should handle its own pointers.

configuration #

pass a partial config to Root. omitted options use DEFAULT_CONFIG:

import { Cropper, NO_INSETS } from '@oomfware/cropper';

<Cropper.Root
	aspectRatio={16 / 9}
	config={{
		insets: { ...NO_INSETS, top: 48, bottom: 48 },
		maxZoom: 8,
		wheelZoom: 'ctrl',
	}}
>
	{/* ... */}
</Cropper.Root>;
option default description
insets all 0 per-edge space in CSS px kept between the viewport and window
maxZoom 6 maximum zoom relative to the largest crop at the selected ratio
wheelSensitivity 0.0015 exponential zoom change per wheel delta unit
wheelZoom 'always' 'always', only with the ctrl key ('ctrl'), or disabled ('none')

insets is an { top, right, bottom, left } object. spread the exported NO_INSETS when changing only some edges.

wheelZoom: 'always' consumes wheel events over the viewport. use 'ctrl' when ordinary wheel scrolling should pass through and only trackpad pinch or ctrl-wheel should zoom.

styling and custom overlays #

all parts forward their native element props, including className, style, event handlers, and refs. Window also renders children, so grids and other guides can live inside it:

<Cropper.Window className="cropper-window">
	<div className="rule-of-thirds" />
</Cropper.Window>

the parts expose these attributes for styling:

selector meaning
[data-dragging] on the viewport a single-pointer drag is active
[data-pinching] on the viewport a two-pointer pinch is active
[data-cropper-image] on the image the positioned source image
[data-cropper-window] on the window the fixed crop rectangle

Window has pointer-events: none by default, so it does not interrupt gestures.

controls and live state #

useCropper returns the stable controller and useCropperState subscribes to live render state. pass a selector to re-render only when the slice you need changes; it must return a referentially stable value while its input is unchanged:

import { useCropper, useCropperState, useZoomControl } from '@oomfware/cropper';

const CropperControls = () => {
	const { rotate } = useCropper();
	const rotation = useCropperState((state) => state.value.rotation);
	const { disabled, fraction, setFraction, zoom } = useZoomControl();

	return (
		<div>
			<button type="button" onClick={() => rotate(-1)}>
				rotate left
			</button>
			<button type="button" onClick={() => rotate(1)}>
				rotate right
			</button>
			<input
				type="range"
				aria-label="zoom"
				disabled={disabled}
				min={0}
				max={1}
				step={0.01}
				value={fraction}
				onChange={(event) => setFraction(event.currentTarget.valueAsNumber)}
			/>
			<output>{Math.round(zoom * 100)}%</output>
			<output>{rotation * 90}°</output>
		</div>
	);
};

useZoomControl maps a linear 0–1 slider onto a logarithmic zoom scale. usePanControl('x') and usePanControl('y') provide the same fraction, setFraction, and disabled shape for pan sliders.

the controller also exposes setPan, setRotation, setValue, and setZoom for custom controls. call useCropperState() without a selector to subscribe to the complete CropperState snapshot.

the core engine #

for a custom renderer or another UI framework, drive CropperEngine directly. it has no DOM or React dependency: provide geometry and pointer input, then render its state snapshot.

import { CropperEngine } from '@oomfware/cropper';

const engine = new CropperEngine({ config: { maxZoom: 8 } });

engine.setGeometry({
	aspectRatio: 1,
	natural: { width: 1600, height: 1067 },
	viewport: { width: 600, height: 450 },
});

const unsubscribe = engine.subscribe(() => render(engine.getState()));

engine.pointerDown(1, { x: 300, y: 225 });
engine.pointerMove(1, { x: 340, y: 225 });
engine.pointerUp(1);

unsubscribe();
engine.destroy();

the package also exports its geometry types and helpers for renderers that need to share the same coordinate calculations.