@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()orsetRotation()fromuseCropper. 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.