Skip to content

Early previewhamur is not on npm yet. The components are being built in the open, follow along on GitHub.

InputsReact

Image Cropper

A crop rectangle over an image, moved and resized by pointer, touch or keys, with zoom, rotation and flips.

Rendered by React 19.3.1

Loading React…
import * as React from 'react'
import { getImageCropperScreenFlip, type ImageCropperFlip } from '@hamur/core'
import { ImageCropper } from '@hamur/react/image-cropper'
import { Slider } from '@hamur/react/slider'
import photo from '../../assets/image-cropper-photo.jpg'

const HANDLES = [
  'top',
  'right',
  'bottom',
  'left',
  'top-left',
  'top-right',
  'bottom-left',
  'bottom-right',
] as const

export default function ImageCropperExample() {
  const actionsRef = React.useRef<ImageCropper.Root.Actions | null>(null)
  const [zoom, setZoom] = React.useState(1)
  const [rotation, setRotation] = React.useState(0)
  const [flip, setFlip] = React.useState<ImageCropperFlip>('none')
  const [result, setResult] = React.useState<string | null>(null)

  const turn = (by: number) => setRotation(180 - ((((180 - rotation - by) % 360) + 360) % 360))
  const flipAlong = (axis: 'horizontal' | 'vertical') => {
    const next = getImageCropperScreenFlip({ rotation, flip }, axis)
    setRotation(next.rotation)
    setFlip(next.flip)
  }
  const crop = async () => {
    const blob = await actionsRef.current!.toBlob()
    if (result) URL.revokeObjectURL(result)
    setResult(URL.createObjectURL(blob))
  }

  return (
    <div className="stack">
      <ImageCropper.Root
        className="image-cropper"
        zoom={zoom}
        rotation={rotation}
        flip={flip}
        actionsRef={actionsRef}
        onZoomChange={(next: number) => setZoom(next)}
        onRotationChange={(next: number) => setRotation(next)}
        onFlipChange={(next: ImageCropperFlip) => setFlip(next)}
      >
        <ImageCropper.Viewport className="image-cropper-viewport">
          <ImageCropper.Image
            src={photo}
            alt="A lake at sunset below purple mountains"
          />
          <ImageCropper.Area className="image-cropper-area">
            <ImageCropper.Grid className="image-cropper-grid" />
            {HANDLES.map((position) => (
              <ImageCropper.Handle
                key={position}
                className="image-cropper-handle"
                position={position}
              />
            ))}
          </ImageCropper.Area>
        </ImageCropper.Viewport>
        <ImageCropper.Status />
      </ImageCropper.Root>
      <Slider.Root
        className="slider"
        min={1}
        max={5}
        step={0.1}
        value={zoom}
        onValueChange={(next) => setZoom(next)}
      >
        <div className="slider-head">
          <Slider.Label>Zoom</Slider.Label>
          <span className="muted">{Math.round(zoom * 100)}%</span>
        </div>
        <Slider.Control className="slider-control">
          <Slider.Track className="slider-track">
            <Slider.Indicator className="slider-indicator" />
            <Slider.Thumb className="slider-thumb" aria-label="Zoom" />
          </Slider.Track>
        </Slider.Control>
      </Slider.Root>
      <div className="row image-cropper-toolbar">
        <button className="button" type="button" onClick={() => turn(-90)}>
          Rotate left
        </button>
        <button className="button" type="button" onClick={() => turn(90)}>
          Rotate right
        </button>
        <button className="button" type="button" onClick={() => flipAlong('horizontal')}>
          Flip horizontal
        </button>
        <button className="button" type="button" onClick={() => flipAlong('vertical')}>
          Flip vertical
        </button>
        <button className="button" type="button" onClick={crop}>
          Crop
        </button>
      </div>
      <p className="muted">
        Rotation: {rotation}°. Flip: {flip}.
      </p>
      {result ? (
        <img className="image-cropper-result" src={result} alt="The cropped image" />
      ) : null}
    </div>
  )
}
Not measured yetTests passing in React
Not measured yetSize
Not measured yetMemory
Not measured yetSpeed
[N] commits

Installation

npm i @hamur/react

Not on npm yet. The package goes out under the chosen name.

Anatomy

Import the component and assemble its parts:

<ImageCropper.Root>
  <ImageCropper.Viewport>
    <ImageCropper.Image />
    <ImageCropper.Area>
      <ImageCropper.Grid />
      <ImageCropper.Handle />
    </ImageCropper.Area>
  </ImageCropper.Viewport>
  <ImageCropper.Status />
</ImageCropper.Root>

Examples

Avatar

fixedArea with shape="circle" and aspectRatio 1: the round frame stays still while the photo pans and zooms under it.

ImageCropperAvatar.tsx
import * as React from 'react'
import type { ImageCropperCrop } from '@hamur/core'
import { ImageCropper } from '@hamur/react/image-cropper'
import { Slider } from '@hamur/react/slider'
import photo from '../../assets/image-cropper-photo.jpg'

export default function ImageCropperAvatarExample() {
  const [zoom, setZoom] = React.useState(1)
  const [value, setValue] = React.useState<ImageCropperCrop | null>(null)
  return (
    <div className="stack">
      <ImageCropper.Root
        className="image-cropper"
        fixedArea
        shape="circle"
        aspectRatio={1}
        zoom={zoom}
        labels={{ root: 'Profile photo', area: 'Profile photo crop' }}
        onZoomChange={(next: number) => setZoom(next)}
        onValueChange={(next: ImageCropperCrop | null) => setValue(next)}
      >
        <ImageCropper.Viewport className="image-cropper-viewport">
          <ImageCropper.Image
            src={photo}
            alt="A lake at sunset below purple mountains"
          />
          <ImageCropper.Area className="image-cropper-area" />
        </ImageCropper.Viewport>
        <ImageCropper.Status />
      </ImageCropper.Root>
      <Slider.Root
        className="slider"
        min={1}
        max={5}
        step={0.1}
        value={zoom}
        onValueChange={(next) => setZoom(next)}
      >
        <div className="slider-head">
          <Slider.Label>Zoom</Slider.Label>
          <span className="muted">{Math.round(zoom * 100)}%</span>
        </div>
        <Slider.Control className="slider-control">
          <Slider.Track className="slider-track">
            <Slider.Indicator className="slider-indicator" />
            <Slider.Thumb className="slider-thumb" aria-label="Zoom" />
          </Slider.Track>
        </Slider.Control>
      </Slider.Root>
      <p className="muted">
        {value ? `Square of ${value.width} px at ${value.x}, ${value.y}` : 'Loading the photo'}
      </p>
    </div>
  )
}

Controlled

A controlled value shown as numbers with the reason of each change, a select for the aspect ratio and a Reset button.

ImageCropperControlled.tsx
import * as React from 'react'
import type { ImageCropperCrop } from '@hamur/core'
import { ImageCropper } from '@hamur/react/image-cropper'
import photo from '../../assets/image-cropper-photo.jpg'

const RATIOS: Record<string, number | undefined> = {
  free: undefined,
  '1:1': 1,
  '16:9': 16 / 9,
  '4:3': 4 / 3,
}
const HANDLES = [
  'top',
  'right',
  'bottom',
  'left',
  'top-left',
  'top-right',
  'bottom-left',
  'bottom-right',
] as const

export default function ImageCropperControlledExample() {
  const actionsRef = React.useRef<ImageCropper.Root.Actions | null>(null)
  const [value, setValue] = React.useState<ImageCropperCrop | null>(null)
  const [reason, setReason] = React.useState('none yet')
  const [ratio, setRatio] = React.useState('free')
  return (
    <div className="stack">
      <label className="field">
        Aspect ratio
        <select className="input" value={ratio} onChange={(event) => setRatio(event.target.value)}>
          {Object.keys(RATIOS).map((key) => (
            <option key={key} value={key}>
              {key}
            </option>
          ))}
        </select>
      </label>
      <ImageCropper.Root
        className="image-cropper"
        value={value}
        aspectRatio={RATIOS[ratio]}
        actionsRef={actionsRef}
        onValueChange={( next: ImageCropperCrop | null, details: ImageCropper.Root.ChangeEventDetails, ) => { setValue(next) setReason(details.reason) }}
      >
        <ImageCropper.Viewport className="image-cropper-viewport">
          <ImageCropper.Image
            src={photo}
            alt="A lake at sunset below purple mountains"
          />
          <ImageCropper.Area className="image-cropper-area">
            {HANDLES.map((position) => (
              <ImageCropper.Handle
                key={position}
                className="image-cropper-handle"
                position={position}
              />
            ))}
          </ImageCropper.Area>
        </ImageCropper.Viewport>
      </ImageCropper.Root>
      <p className="muted">
        {value
          ? `x ${value.x}, y ${value.y}, width ${value.width}, height ${value.height}`
          : 'No crop yet'}
      </p>
      <p className="muted">Reason: {reason}</p>
      <div className="row">
        <button className="button" type="button" onClick={() => actionsRef.current?.reset()}>
          Reset
        </button>
      </div>
    </div>
  )
}

Form

A 16:9 crop in a Form with a name on the Field: Save shows the JSON the hidden input sends.

ImageCropperForm.tsx
import * as React from 'react'
import { Field } from '@hamur/react/field'
import { Form } from '@hamur/react/form'
import { ImageCropper } from '@hamur/react/image-cropper'
import photo from '../../assets/image-cropper-photo.jpg'

const HANDLES = [
  'top',
  'right',
  'bottom',
  'left',
  'top-left',
  'top-right',
  'bottom-left',
  'bottom-right',
] as const

export default function ImageCropperFormExample() {
  const [submitted, setSubmitted] = React.useState('')
  return (
    <Form
      className="stack"
      onFormSubmit={(_values, details) => {
        const form = details.event.target as HTMLFormElement
        setSubmitted([...new FormData(form)].map(([key, value]) => `${key}=${value}`).join('&'))
      }}
    >
      <Field.Root name="cover" className="field">
        <Field.Label>Cover photo</Field.Label>
        <ImageCropper.Root className="image-cropper" aspectRatio={16 / 9}>
          <ImageCropper.Viewport className="image-cropper-viewport">
            <ImageCropper.Image
              src={photo}
              alt="A lake at sunset below purple mountains"
            />
            <ImageCropper.Area className="image-cropper-area">
              <ImageCropper.Grid className="image-cropper-grid" />
              {HANDLES.map((position) => (
                <ImageCropper.Handle
                  key={position}
                  className="image-cropper-handle"
                  position={position}
                />
              ))}
            </ImageCropper.Area>
          </ImageCropper.Viewport>
        </ImageCropper.Root>
        <Field.Description className="muted">
          The crop is sent as JSON in a hidden input.
        </Field.Description>
      </Field.Root>
      <div className="row">
        <button className="button" type="submit">
          Save
        </button>
      </div>
      {submitted ? <pre className="output">{submitted}</pre> : null}
    </Form>
  )
}

API reference

42 props across 7 parts, in React syntax. className, style, render are listed once, under Shared props.

AreaImageCropper.Area

The crop, a slider that moves with the arrow keys and holds the grid and the handles. Rendered once the image and the viewport are measured. Renders a <div>.

Area data attributes
Data attributeDescription
data-draggingPresent while the crop area or a handle is being dragged.
data-resizingPresent while a handle is being dragged.
data-disabledPresent when the image cropper is disabled.
data-readonlyPresent when the image cropper is read-only.
data-shapeThe shape of the crop: rectangle or circle.
data-fixedPresent when the crop area is fixed and the image moves under it.
data-focus-visiblePresent when the crop area has keyboard focus.

GridImageCropper.Grid

Guide lines over the crop area, hidden from assistive technology. Renders a <div>.

Grid props
PropTypeDefault
rowsThe number of rows, written to --grid-rows.number3
columnsThe number of columns, written to --grid-columns.number3
Grid data attributes
Data attributeDescription
data-rowsThe number of rows.
data-columnsThe number of columns.
data-draggingPresent while the crop area or a handle is being dragged.
data-resizingPresent while a handle is being dragged.

HandleImageCropper.Handle

A handle that resizes the crop from an edge or a corner. Not rendered when the crop area is fixed. Renders a <div>.

Handle props
PropTypeDefault
positionrequiredThe edge or corner the handle resizes from, physical.ImageCropperHandlePosition—
tabIndexThe edge handle's tab index; 0 puts it in the Tab order.number-1
Handle data attributes
Data attributeDescription
data-positionThe edge or corner the handle resizes from.
data-draggingPresent while the handle is being dragged.
data-disabledPresent when the image cropper is disabled.
data-edgePresent on an edge handle.
data-cornerPresent on a corner handle.

ImageImageCropper.Image

The image to crop, placed by the zoom, the pan, the rotation and the flip. Renders an <img>.

Image props
PropTypeDefault
srcThe image: a URL, or a Blob or File shown through an object URL.string | Blob—
onLoadingStatusChangeCallback fired when the loading status changes.((status: ImageLoadingStatus) => void)—
Image data attributes
Data attributeDescription
data-loadingPresent while the image is loading.
data-errorPresent when the image failed to load.
data-flip-horizontalPresent when the flip is 'horizontal' or 'both'.
data-flip-verticalPresent when the flip is 'vertical' or 'both'.

RootImageCropper.Root

Groups the parts of the image cropper and holds the crop, the zoom, the rotation and the flip. and a hidden <input> beside it. Renders a <div>.

Root props
PropTypeDefault
valueThe crop, { x, y, width, height } in integer pixels of the oriented, flipped and rotatedImageCropperCrop | null—
defaultValueThe crop when the component is initially rendered, clamped once the image is measured.ImageCropperCrop | nullnull
onValueChangeEvent handler called when the crop changes.((value: ImageCropperCrop | null, eventDetails: ImageCropperRootChangeEventDetails) => void)—
onValueCommittedEvent handler called when an interaction ends: a pointer release, the release of the keys((value: ImageCropperCrop | null, eventDetails: ImageCropperRootCommitEventDetails) => void)—
zoomThe zoom, 1 being the image fitted in the viewport.number—
defaultZoomThe zoom when the component is initially rendered.number1
onZoomChangeEvent handler called when the zoom changes.((zoom: number, eventDetails: ImageCropperRootZoomChangeEventDetails) => void)—
minZoomThe smallest zoom.number1
maxZoomThe largest zoom.number5
zoomStepThe zoom a key adds or removes; the wheel and a pinch scale by it.number0.1
rotationThe rotation in degrees, clockwise.number—
defaultRotationThe rotation when the component is initially rendered.number0
onRotationChangeEvent handler called when the rotation changes.((rotation: number, eventDetails: ImageCropperRootZoomChangeEventDetails) => void)—
flipThe flip, applied before the rotation.ImageCropperFlip—
defaultFlipThe flip when the component is initially rendered.ImageCropperFlip'none'
onFlipChangeEvent handler called when the flip changes.((flip: ImageCropperFlip, eventDetails: ImageCropperRootZoomChangeEventDetails) => void)—
aspectRatioThe width / height ratio of the crop: a number locks it, { min, max } limits it.ImageCropperAspectRatio—
shapeThe shape of the crop.ImageCropperShape'rectangle'
fixedAreaWhether the crop area is fixed in the centre and the image moves under it.booleanfalse
minWidthThe smallest crop width in image pixels.number1
minHeightThe smallest crop height in image pixels.number1
maxWidthThe largest crop width in image pixels.numberInfinity
maxHeightThe largest crop height in image pixels.numberInfinity
restrictToImageWhether the crop must stay inside the rotated image.booleantrue
initialCoverageThe share of each side of the image the automatic crop covers, from 0 to 1.number0.8
stepThe distance an arrow key moves the crop, in image pixels.number1
largeStepThe distance Shift with an arrow key, Page Up and Page Down move the crop, in image pixels.number10
wheelZoomWhen the wheel zooms: with Ctrl or Cmd held, always, or never.ImageCropperWheelZoom'ctrl'
pinchZoomWhether two pointers on the viewport zoom the image.booleantrue
disabledWhether the component should ignore user interaction.booleanfalse
readOnlyWhether the user should be unable to change the crop.booleanfalse
nameIdentifies the field when a form is submitted.string—
labelsThe texts, in English by default: the names, the instructions, the value texts and thePartial<ImageCropperLabels>—
localeThe locale of the numbers in the value text and the status messages.Intl.LocalesArgument—
actionsRefA ref to imperative actions: setValue, reset, toBlob, toCanvas and getDetails.React.RefObject<ImageCropperActions | null>—
inputRefA ref to access the hidden input element.React.Ref<HTMLInputElement>—
Root data attributes
Data attributeDescription
data-disabledPresent when the image cropper is disabled.
data-readonlyPresent when the image cropper is read-only.
data-readyPresent when the image and the viewport are both measured.
data-draggingPresent while the crop area or a handle is being dragged.
data-panningPresent while the image is being panned or pinched.
data-fixedPresent when the crop area is fixed and the image moves under it.
data-shapeThe shape of the crop: rectangle or circle.
data-loadingPresent while the image is loading.
data-errorPresent when the image failed to load.
data-validPresent when the crop is valid (when wrapped in Field.Root).
data-invalidPresent when the crop is invalid (when wrapped in Field.Root).
data-dirtyPresent when the crop has changed from its first value (when wrapped in Field.Root).
data-touchedPresent when focus has left the image cropper (when wrapped in Field.Root).
data-filledPresent when the image cropper has a crop (when wrapped in Field.Root).
data-focusedPresent when the crop area or a handle is focused (when wrapped in Field.Root).

StatusImageCropper.Status

A visually hidden live region that announces the crop after a change settles, and the image's loading and error. Renders a <div>.

ViewportImageCropper.Viewport

The box the image is fitted in. Clips the image and holds the crop area. Renders a <div>.

Viewport data attributes
Data attributeDescription
data-readyPresent when the image and the viewport are both measured.
data-draggingPresent while the crop area or a handle is being dragged.
data-panningPresent while the image is being panned or pinched.
data-zoomedPresent when the zoom is above 1.

Shared props

Every part that renders an element takes these.

Shared props
PropType
classNameCSS class applied to the element, or a function that returns a class based on the component's state.string | ((state: State) => string | undefined)
styleStyle applied to the element, or a function that returns a style object based on the component's state.React.CSSProperties | ((state: State) => React.CSSProperties | undefined)
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, State>

Keyboard and accessibility

Keyboard

Keyboard
KeysDoesPart
ArrowLeftArrowRightMoves the crop left or right by the step; with Shift by largeStepArea
ArrowUpArrowDownMoves the crop up or down; the keys stay physical in a right-to-left pageArea
HomeEndMoves the crop to the left or right edge of the imageArea
PageUpPageDownMoves the crop up or down by largeStepArea
AltArrowRightAltArrowDownWidens or heightens the crop, anchored at its top-left; ArrowLeft and ArrowUp shrink itArea
-Zooms in or out by zoomStep about the crop's centreArea
EscapeDuring a drag, puts the crop back where it was and keeps a Dialog openArea
EscapeWithout a drag, is left to what is around the cropperArea
ArrowUpArrowDownOn the top or bottom edge Handle, moves that edgeHandle
TabStops on the Area once; the edge Handles are reached with tabIndex 0Handle

Accessibility

What the parts set
PartSetsSo that
Rootrole="group" named by the Field's label or labels, and the hidden input of the formthe cropper is one named control in the page and in the form
Arearole="slider" with the role description "crop area", one Tab stop, aria-valuenow the crop's x and aria-valuetext the whole rectangle, and instructionsa screen reader hears the size and the position of the crop on every key, and how to move, resize and zoom it
Handlethe four edges: named sliders out of the Tab order with the range their edge can reach; the four corners: aria-hidden, pointer onlya touch screen reader can still resize the crop one edge at a time, and the corners are not read four more times
Imagethe native img with the app's alt, never hidden, and draggable="false"the picture keeps its text alternative, and a drag never turns into the browser's own image drag
Gridaria-hidden="true" and no pointer eventsthe thirds are a picture, not something to read or press
Statusa visually hidden polite role="status", empty at firsta zoom, turn or flip made with another control is announced once, after 500 ms
Viewportno role; touch-action that lets the page scroll unless the image can pan or pincha one-finger swipe on a picture that cannot move still scrolls the page

Focus order

  1. The Area is the only Tab stop; the edge Handles take focus from a press or from script, or join the Tab order with tabIndex 0.
  2. A press on the Area or on the image focuses the Area, and a press on an edge Handle focuses that Handle, without scrolling the page.
  3. Nothing takes focus on mount, on a change of src, or on a form reset.