Trang này chưa có bản tiếng Việt. Nội dung dưới đây là bản tiếng Anh.
Composites
Cropper
A crop frame over a picture: drag to move it, scroll or pinch to zoom, or use the arrow keys and + / -. In Comitor apps it sits between choosing an image and uploading it — a profile photo, a company logo, a project cover. It reports a rectangle and nothing more; drawing the pixels and sending the file stay with the app.
- Import
import { Cropper, CropperImage, CropperArea } from '@comitor/ui'— the main entry. No optional peer to install: it depends on nothing beyond the package’s required peers, which is why it lives here and not beside the uploader.- Runtime
- A client component: the props that matter are callbacks, and
useCropperthrows outside aCropper. The server render is safe —devicePixelRatiois read lazily, never at import. - Origin
- Ported in
2.1.0from Dice UI (MIT, © 2024 Sadman Sakib) onto the package’s own tokens and strings, with several input bugs fixed on the way — listed under What the port guarantees. Release notes: the changelog.
Anatomy
Three parts. Cropper renders two boxes: an outer wrapper that takes your className and is size-full — so in a parent without a height it collapses to nothing — and inside it the interactive element, the one Tab reaches and every key and pointer event drives. CropperArea draws the frame and dims everything around it.
import { Cropper, CropperArea, CropperImage } from '@comitor/ui'
<Cropper // outer div gets className — it is size-full, so give it a height
aspectRatio={1} // width ÷ height of the frame
shape="circle" // rounds the frame only; the area you get back is still a square
onCropAreaChange={(area, pixels) => …}
className="min-h-72"
> // inner div: role="application", tabIndex 0 — Tab reaches it, keys drive it
<CropperImage src={src} alt="…" /> // or <CropperVideo src={src} />
<CropperArea /> // the frame and the dimmed surround — renders once the media has loaded
</Cropper>
// after the cropper element: hidden key instructions (aria-describedby) and a polite live region for zoomTwo coordinate systems come back out. crop is where the picture sits on screen, in CSS pixels from the centre — the same numbers point at a different part of the picture once the layout changes. croppedAreaPixels is the rectangle in the image’s own pixels, the only number worth saving or drawing; croppedArea is the same rectangle in percent.
A profile picture
aspectRatio={1} and shape="circle", with the zoom held in state so a Slider can drive it. The circle on the right is not a mock-up: every onCropAreaChange draws croppedAreaPixels from the real <img> onto a <canvas> with drawImage — the same pixels a save would upload. Drag, scroll, or focus the cropper and use the keys.

loading…
onCropComplete × 0
Two callbacks, two jobs. onCropAreaChange fires at most once per animation frame for any change — the first load, a drag in progress, a zoom set by the slider — so it suits a live preview and is the one to keep for Save. onCropComplete fires once when a gesture ends; the counter moves on release, on key-up and a quarter-second after a scroll, but never for the first load or a slider move. It is the place for expensive work. Notice too that this demo re-renders on every frame of a drag — exactly what used to snap an uncontrolled Dice UI cropper back to the centre.
A 16:9 cover
Project covers and workspace banners want a wide frame. aspectRatio can change while the cropper is mounted — the frame is recomputed and the picture clamped back inside it — and withGrid adds rule-of-thirds lines. The badge in the corner reads the cropper’s own state with useCropper; it renders inside the cropper, so it is aria-hidden.

croppedAreaPixels — loading…
If your server resizes images anyway, the rectangle is all it needs: post the original file with croppedAreaPixels and crop there, and the browser never re-encodes the picture.
The numbers, and the keyboard
Fully controlled: crop and zoom live in state, and Reset writes them back. Tab to the cropper and press → twenty times — crop.x moves 20 pixels, one per press (Shift moves 0.2). In Dice UI the same twenty presses moved the picture 1 pixel in total. + and - zoom around the centre in steps of 0.1, and each key-up closes the interaction, so the completion counter moves with you.

- crop
- x 0.0 · y 0.0
- zoom
- 1.50
- area %
- …
- area px
- …
- complete
- × 0
Crop first, then upload
ImageUploadField from @comitor/ui/uploader uploads the moment a file is chosen — there is no step in between to crop. A cropping flow is built one level down, on useFileUpload from the same entry: your own file input makes an object URL, the cropper shows it, and Save draws croppedAreaPixels onto a canvas, encodes it with toBlob, and hands the result to upload.start() as a File. The server sees an ordinary multipart POST — the same route and the same checks Upload describes. The two meet in app code on purpose: the uploader entry pulls in Uppy, the cropper pulls in nothing.
'use client'
import { useEffect, useRef, useState } from 'react'
import {
Button, Cropper, CropperArea, CropperImage, ResponsiveDialog, ResponsiveDialogClose,
ResponsiveDialogContent, ResponsiveDialogDescription, ResponsiveDialogFooter,
ResponsiveDialogHeader, ResponsiveDialogTitle, toast, type CropperAreaData,
} from '@comitor/ui'
import { useFileUpload } from '@comitor/ui/uploader'
const SIZE = 512 // what the server keeps — the canvas resizes for free
export function AvatarPicker({ onSaved }: { onSaved: (image: string) => void }) {
const [source, setSource] = useState<string | null>(null) // object URL of the chosen file
const area = useRef<CropperAreaData | null>(null)
const image = useRef<HTMLImageElement>(null)
const input = useRef<HTMLInputElement>(null)
const upload = useFileUpload<{ image: string }>({
endpoint: '/api/account/avatar',
maxFileSize: 2 * 1024 * 1024,
allowedFileTypes: ['image/jpeg', 'image/png'],
onUploaded: ({ body }) => {
onSaved(body.image)
setSource(null)
},
onError: ({ message }) => toast(message, { variant: 'destructive' }),
})
// An object URL pins the whole file in memory until it is revoked.
useEffect(() => () => { if (source) URL.revokeObjectURL(source) }, [source])
async function save() {
if (!image.current || !area.current) return
const { x, y, width, height } = area.current
const canvas = document.createElement('canvas')
canvas.width = SIZE
canvas.height = SIZE
canvas.getContext('2d')?.drawImage(image.current, x, y, width, height, 0, 0, SIZE, SIZE)
// A type the browser cannot encode silently becomes PNG — trust blob.type, not the argument.
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.9))
if (blob) upload.start([new File([blob], blob.type === 'image/png' ? 'avatar.png' : 'avatar.jpg', { type: blob.type })])
}
return (
<>
<input
ref={input}
type="file"
accept="image/png, image/jpeg, image/webp"
className="sr-only"
tabIndex={-1}
onChange={(event) => {
const file = event.target.files?.[0]
if (file) {
area.current = null // the old picture's rectangle means nothing for the new one
setSource(URL.createObjectURL(file)) // same-origin: the canvas stays untainted
}
event.target.value = '' // choosing the same file again must fire change again
}}
/>
<Button variant="outline" onClick={() => input.current?.click()}>Change picture</Button>
<ResponsiveDialog open={source !== null} onOpenChange={(open) => { if (!open) setSource(null) }}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Crop your picture</ResponsiveDialogTitle>
<ResponsiveDialogDescription>Drag to move it. Scroll, pinch, or press + and - to zoom.</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
{source ? (
<Cropper
aspectRatio={1}
shape="circle"
className="min-h-72 rounded-xl"
// On phones this dialog is a vaul Drawer: without this, dragging the picture
// downwards also drags the sheet shut.
data-vaul-no-drag=""
// Keep the LATEST area — onCropComplete skips the first load.
onCropAreaChange={(_, pixels) => { area.current = pixels }}
>
<CropperImage ref={image} src={source} alt="The picture you chose" />
<CropperArea />
</Cropper>
) : null}
<ResponsiveDialogFooter>
<ResponsiveDialogClose asChild>
<Button variant="outline">Cancel</Button>
</ResponsiveDialogClose>
<Button loading={upload.busy} onClick={() => void save()}>Save</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
</>
)
}Note the data-vaul-no-drag on the cropper. On a phone, Responsive Dialog renders a vaul drawer, and vaul listens for pointer moves anywhere in the sheet: without the attribute, a downward drag on the picture pans it and pulls the sheet shut.
What the port guarantees
Behaviour you can rely on that the Dice UI original did not give you. The numbers are the package’s own, recorded in its source next to each fix.
- Arrow keys move the picture every time. Upstream cached the clamped position under a key rounded to 4 ÷ devicePixelRatio pixels; a 1-pixel step always fell into the previous cell and got the previous answer back — twenty presses moved it exactly 1 pixel. The port caches only the limits, which depend on size, zoom and rotation, and clamps the real position.
- An uncontrolled cropper survives its parent re-rendering. Upstream wrote the defaults into the props (
crop = { x: 0, y: 0 },zoom = 1), so the sync effect saw a “new” prop on every render and put the picture back in the centre — right after the user moved it, wheneveronCropCompleteset state in the parent. That is the avatar flow exactly. Defaults now seed the initial state only, and only props you pass are synced. onCropCompletefires after a wheel zoom. Upstream’s wheel listener was re-attached on every zoom change, and the cleanup cancelled the 250ms timer that ends a wheel interaction — measured: one notch, a 400ms wait, zero calls, withisDraggingstuck at true.- Zoom stays under the pointer. Upstream refreshed the cropper’s page position only on mousedown and touchstart, so a scroll before any drag — or after the page had scrolled — anchored the zoom at stale coordinates, (0, 0) at first, and the picture slid away from the cursor by the cropper’s distance from the top-left corner of the window.
- Zoom from the keyboard. + and - zoom by 0.1 around the centre — as do = and _, so on a US layout both keys work with or without Shift — and each step is announced: “Zoom 110%”. Upstream zoomed by wheel and pinch only, which left keyboard users no way to change it (WCAG 2.1.1). With Ctrl, ⌘ or Alt held the keys are left to the browser’s own zoom.
- The cropper says what it is. Upstream was a
<div tabIndex={0}>with no role, name or description. The port gives itrole="application", a name, a role description and the key instructions;applicationrather thangroupbecause NVDA and JAWS in browse mode would swallow the arrow keys before they reached the handler. - A visible focus ring. Upstream set
outline-noneand drew nothing (WCAG 2.4.7). The ring is drawn on the cropper’s::after, above the picture and the frame, inset by 2px because both wrappers clip. - Safe on the server. Upstream read
window.devicePixelRatiointo a module constant at import time: it touchedwindowon load, was 1 on the server against 2 on a 2× screen — a hydration mismatch in the picture’s transform withsnapPixels— and never noticed browser zoom. It is now read when needed, and renders as 1 until hydration is done. - Colours that hold on any picture. The surround is dimmed with the dialog overlay’s
ink/60(ink/80in dark) instead of a hard-coded black, and the frame ispaper: over the worst case, a white pixel under the overlay, it measures 4.65:1 in light and 9.60:1 in dark. Neither token changes with theme or palette, so the numbers hold everywhere.
Language
Four strings, all for assistive technology, in the cropper namespace: label, roleDescription, instructions and zoomStatus(zoom) — a function, because the percentage sits inside the sentence. DEFAULT_CROPPER_LABELS is the English table (since 3.0.0); the Vietnamese one is comitorMessagesVi.cropper. The labels prop beats the nearest ComitorLocaleProvider, which beats English — and aria-label passed straight to the cropper beats all three. Switch the language, then focus the cropper and press +:

- aria-label
- aria-roledescription
- instructions (aria-describedby)
- live region, after + or - in the cropper
- —
The picture’s alt is yours and moves with the page’s language. How to set the provider once for a whole app: Get started → Set the language.
Props
Cropper. The types CropperAreaData, CropperPoint, CropperSize, CropperMediaSize, CropperShape, CropperObjectFit and CropperLabels are exported alongside.
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
aspectRatio | number | 4 / 3 | Width ÷ height of the frame. The frame is the largest rectangle of that ratio that fits inside both the media and the container, recomputed when the ratio or the container size changes. |
shape | 'rectangle' | 'circle' | 'rectangle' | Rounds the frame and nothing else — the area you get back is the bounding rectangle. Mask the circle yourself, in CSS or on the canvas. |
objectFit | 'contain' | 'cover' | 'horizontal-cover' | 'vertical-cover' | 'contain' | How the media sits in the container at zoom 1: all of it visible, covering the container, or matching the container's width or height. |
crop | CropperPoint | — | Controlled offset of the media from the centre, in CSS pixels on screen — not image pixels. Omit it and the cropper keeps its own position. |
zoom | number | — | Controlled zoom, where 1 is fitted. Uncontrolled, it starts at 1. |
minZoom | number | 1 | Lowest zoom reachable by wheel, pinch or keys. |
maxZoom | number | 3 | Highest zoom reachable by wheel, pinch or keys. |
zoomSpeed | number | 1 | Wheel sensitivity: each wheel event moves the zoom by deltaY × zoomSpeed ÷ 200. |
rotation | number | — | Controlled rotation in degrees; uncontrolled, it starts at 0. Changes through this prop, a two-finger twist or a Safari trackpad gesture — no key rotates. |
keyboardStep | number | 1 | CSS pixels one arrow press moves the media. Shift moves a fifth of it. |
allowOverflow | boolean | false | Lets the media move so the frame passes its edges; the area can then include empty space. |
preventScrollZoom | boolean | false | Stops wheel zoom, so the wheel scrolls the page again while the pointer is over the cropper. Keys and pinch still zoom. |
withGrid | boolean | false | Rule-of-thirds lines inside the frame. Decorative. |
labels | Partial<CropperLabels> | cropper namespace | Per-instance strings: label, roleDescription, instructions and zoomStatus(zoom). Missing keys come from the provider, then English. |
onCropAreaChange | (croppedArea: CropperAreaData, croppedAreaPixels: CropperAreaData) => void | — | At most once per animation frame after any change of position, zoom, rotation, media or frame size — including the first load and changes you make through props. croppedArea is in percent of the media, croppedAreaPixels in the media's own pixels. |
onCropComplete | (croppedArea: CropperAreaData, croppedAreaPixels: CropperAreaData) => void | — | Once at the end of each interaction: pointer release, touch end, key-up of an arrow or zoom key, 250ms after the last wheel event. Not on load, and not for prop changes. |
onCropChange | (crop: CropperPoint) => void | — | Every position change. |
onZoomChange | (zoom: number) => void | — | Every zoom change, already clamped. |
onRotationChange | (rotation: number) => void | — | Every rotation change. |
onCropSizeChange | (cropSize: CropperSize) => void | — | The frame's size in CSS pixels, each time it is recomputed. |
onMediaLoaded | (mediaSize: CropperMediaSize) => void | — | Rendered and natural size, once the media has loaded and been measured. |
onInteractionStart / onInteractionEnd | () => void | — | Bracket every drag, touch, burst of wheel events and key press. |
onWheelZoom | (event: WheelEvent) => void | — | Runs before each wheel zoom; call event.preventDefault() to veto that one. |
className | string | — | Lands on the outer wrapper — relative size-full overflow-hidden — so this is where the height goes: min-h-72, aspect-video, or a sized parent. |
...rest | div props | — | ref, aria-label, aria-roledescription, aria-describedby (merged with the instructions), data-* and handlers land on the role=application element. |
CropperImage and CropperVideo — everything else goes to the <img> or <video>.
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
alt | string | — | Not enforced by the type, but always needed: the picture is the content being edited. Describe it, in the page's language. |
crossOrigin | 'anonymous' | 'use-credentials' | — | Needed for media from another origin that you will draw to a canvas — and the server must answer with CORS headers. Object URLs of a chosen file are same-origin. |
objectFit | CropperObjectFit | the root's | Overrides the root's objectFit for this media element. |
snapPixels | boolean | false | Rounds the transform to device pixels — crisper edges at rest. Reads devicePixelRatio after hydration. |
ref | Ref<HTMLImageElement | HTMLVideoElement> | — | The real img or video element — the source you pass to drawImage. |
CropperVideo defaults | video props | autoPlay loop muted playsInline | Plays muted, looped and inline with no controls. The area comes back in the video's own pixels (videoWidth × videoHeight), for cropping on the server. Every video attribute can be overridden. |
CropperArea.
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
shape | CropperShape | the root's | Overrides the root's shape. |
withGrid | boolean | the root's | Overrides the root's withGrid. |
snapPixels | boolean | false | Rounds the frame's width and height to whole pixels. |
rendering | — | — | Renders nothing until the media has loaded and the frame size is known, so there is no empty frame to flash. |
The hook — for decoration inside the cropper. Anything outside it reads the callbacks instead.
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
useCropper(selector) | (state: CropperState) => T | — | Reads the nearest Cropper's store: crop, zoom, rotation, mediaSize, cropSize, isDragging, isWheelZooming. Throws outside a Cropper. |
Known limitations
- It produces a rectangle, never pixels. Drawing, encoding and uploading are the app’s job, as above.
shape="circle"changes only the frame: the area is the bounding square, so round it in CSS or clip the canvas before encoding. - Pictures from another origin taint the canvas.
drawImagestill works, buttoBlobthrows a SecurityError. Serve them with CORS headers and setcrossOrigin="anonymous"onCropperImage, or use an object URL of the chosen file. - Rotation needs more than one drawImage. With
rotationset, the area is measured on the rotated bounding box, so rotate the canvas before drawing. No key rotates — a rotation control is yours to build. onCropCompletemisses the first load and prop changes. Nor does it close a trackpad pinch in desktop Safari, which arrives as gesture events rather than wheel events. Keep the latestonCropAreaChangefor Save, or a picture nobody touched — or zoomed only with your slider — has no area.- The wheel belongs to the cropper. Over it, scrolling zooms and the page does not move. In a long form, pass
preventScrollZoomand give zoom a slider. - Touch never scrolls the page from the cropper. It is
touch-action: none; on a phone, keep some page outside it to scroll by. - Inside a drawer, the sheet drags too. Put
data-vaul-no-dragon the cropper, as in the upload example.
Accessibility
- The cropper element is
role="application"withtabIndex=0, named bylabels.label(“Image crop area”), described as “image cropper” througharia-roledescription, and linked to the key instructions byaria-describedby. Passaria-labelto say what is being cropped — “Profile picture”, “Company logo”. - Keyboard: Tab reaches the cropper. ← ↑ → ↓ move the picture by
keyboardSteppixels, a fifth of that with Shift; + / = zoom in and - / _ zoom out by 0.1. Holding a key repeats it; releasing it ends the interaction. - Each keyboard zoom is announced through a polite live region (“Zoom 110%”). Wheel and pinch zoom are visual gestures and are not announced.
- The focus ring is
outline-ring, 2px, drawn inside the cropper on::afterso the picture cannot cover it. - Give
CropperImagea realalt: the picture is the content being edited. - It is not a form control: nothing is posted and no input is rendered. The rectangle leaves through the callbacks, and the app sends it — or the cropped file — itself.
- What the app still owns: names for its own controls (the zoom slider above takes
aria-label, which a single-thumbSliderputs on its thumb since3.0.1), a Cancel, a visible and announced result after the upload (“Profile picture updated”), and a rotation control if rotation matters. - Contrast is measured against the picture, not the page: the frame is
paperover aninkoverlay, 4.65:1 at worst; the thirds grid is decoration and is allowed to disappear on a white sky.
Related
- Upload —
useFileUpload,ImageUploadFieldand the server route the cropped file goes to. - Responsive Dialog — the usual home for a crop step: a dialog on desktop, a sheet on phones.
- HR — employee photos on profiles and directories.
- Social — profile pictures and cover images.
- Commerce — product photos cut to one shape for a catalogue grid.
- Slider and Avatar — the zoom control, and where the result ends up.