Marble UI
← Components

Image Cropper

Preview: Default

Crop box over an image: drag to move, drag corners to resize, optional fixed aspect. Keyboard: arrow keys move the box, Alt+arrow keys resize it from the bottom-right corner (Shift = 10% steps for both). Values are fractions of the image (0–1) so they're resolution independent; `onChange` reports them.

Import

import { ImageCropper } from '@marbleui/ui'

Props

<ImageCropper>

PropTypeDefaultDescription
alt *requiredstring—
src *requiredstring—
aspectnumber—
classNamestring—
onChange(crop: CropRect) => void—

Usage notes

Crop box over an image, for avatars and cover photos. Pair it with cropToDataUrl to produce the cropped image.

const [crop, setCrop] = useState<CropRect | null>(null)
<ImageCropper src={photoUrl} alt="Profile photo to crop" aspect={1} onChange={setCrop} />
<Button isDisabled={!crop} onPress={async () => crop && setAvatar(await cropToDataUrl(photoUrl, crop, 256))}>Save photo</Button>
  • The crop is reported as fractions of the image (x, y, width, height, 0–1). onChange also fires on mount and once the image loads. It’s uncontrolled: the starting box can’t be set from props.
  • aspect is width ÷ height (1, 16 / 9); leave it out for a free crop.
  • The crop box is a focusable slider (its value is the horizontal position; aria-valuetext gives the whole box). Arrow keys move it and Alt+arrow keys resize it from the bottom-right corner (1%, Shift for 10%).
  • cropToDataUrl(src, crop, outWidth = 512, crossOrigin = 'anonymous') returns a PNG data URL. Images from another origin load in CORS mode, so their host must send Access-Control-Allow-Origin; otherwise it rejects with an error that says so. Same-origin, blob and data URLs always work.
Example source · show
import { useState } from 'react'
import { Button } from '../button'
import { demoImage } from '../lib/demo-images'
import { SegmentedControl, Segment } from '../segmented-control'
import { cropToDataUrl, ImageCropper, type CropRect } from './image-cropper'

const src = demoImage(2, 900, 600)
function Demo() {
  const [aspect, setAspect] = useState<string>('free')
  const [crop, setCrop] = useState<CropRect | null>(null)
  const [out, setOut] = useState<string | null>(null)
  return (
    <div className="flex flex-col gap-3">
      <SegmentedControl aria-label="Aspect ratio" value={aspect} onChange={(k) => setAspect(String(k))}><Segment id="free">Free</Segment><Segment id="1">1:1</Segment><Segment id="1.778">16:9</Segment></SegmentedControl>
      <div className="flex items-start gap-4">
        <ImageCropper src={src} alt="Landscape to crop" aspect={aspect === 'free' ? undefined : Number(aspect)} onChange={setCrop} className="w-80" />
        <div className="flex flex-col gap-2"><Button size="sm" isDisabled={!crop} onPress={async () => crop && setOut(await cropToDataUrl(src, crop, 240))}>Crop</Button>{out && <img src={out} alt="Cropped result" className="w-32 rounded-md border border-border" />}</div>
      </div>
    </div>
  )
}
export const styles = { Default: Demo }

Source: packages/ui/src/image-cropper/image-cropper.tsx

Catalog

Category
media
Priority
Extra
Primitive
custom