Image cropper
A picture under a frame to drag, zoom, nudge and turn — by pointer, wheel, pinch or keys — with aspect presets, a round mask, and cropImage() to cut the frame's area to a Blob.
The picture sits under a frame that keeps the chosen aspect, on the dark planes as media is, with the outside of the frame under the overlay — the backdrop token, which darkens in either theme — and a thirds grid while it is dragged (it fades in and out, and simply appears under reduced motion). At zoom 1 the picture just covers the frame; it can never be moved so far that the frame shows past its edge, and turning it a quarter or changing the aspect pulls it back inside. Every action has a key: with the crop area focused — a group named Crop area, whose description says how to work it — the arrow keys move the picture 10px (50 with Shift), and + and − zoom by a tenth between minZoom and maxZoom. The arrows are physical on purpose: the picture is not a line of text, so ArrowRight moves it right in a right-to-left page too. The pointer drags it, the wheel zooms at the pointer — listened to natively, so the page does not scroll from under it — and two fingers pinch. A zoom slider and Rotate sit under it, and aspects adds a segmented control of the shapes the reader may pick. onCropChange hands back the source pixels the frame holds, measured in the picture as turned, after every change; cropImage(src, area, { rotation, width, height }) draws the turned source on a canvas and resolves with a Blob, WebP by default (a browser that cannot encode WebP hands back a PNG, and the Blob's type says which). Nothing leaves the browser to be cropped: send the Blob where your app keeps pictures, or show it from URL.createObjectURL. A picture from another server needs CORS headers or the canvas refuses to export it. While the picture loads the stage says so; a picture that cannot be loaded — a missing file, a format the browser cannot decode — says that in the stage with the controls off, instead of leaving an empty frame. In a page where cropping is occasional, load the cropper with a dynamic import when the reader first opens it.
Install
npx shadcn@latest add @vibra/image-cropperNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { CropIcon } from "lucide-react"
import { fileArtFor } from "@/lib/file-art"
import { Button } from "@/components/ui/button"
import { cropImage, ImageCropper, type CropArea, type Rotation } from "@/components/ui/image-cropper"
const PICTURE = fileArtFor("hero-banner-v2.png")
const ASPECTS = [
{ label: "16:9", value: 16 / 9 },
{ label: "4:3", value: 4 / 3 },
{ label: "Square", value: 1 },
]
type Cut = { url: string; width: number; height: number; kilobytes: number; format: string }
export default function ImageCropperDemo() {
const [area, setArea] = React.useState<CropArea | null>(null)
const [rotation, setRotation] = React.useState<Rotation>(0)
const [cut, setCut] = React.useState<Cut | null>(null)
// The crop lives at an object URL for as long as it is shown.
React.useEffect(() => () => {
if (cut) URL.revokeObjectURL(cut.url)
}, [cut])
async function crop() {
if (!area) return
const width = 640
const height = Math.round((width * area.height) / area.width)
const blob = await cropImage(PICTURE, area, { rotation, width, height })
setCut({ url: URL.createObjectURL(blob), width, height, kilobytes: Math.max(1, Math.round(blob.size / 1024)), format: blob.type.replace("image/", "").toUpperCase() })
}
return (
// Side by side only where the example itself has room, whatever the window.
<div className="@container w-full max-w-xl">
<div className="grid gap-4 @lg:grid-cols-[18rem_1fr] @lg:items-start">
<ImageCropper src={PICTURE} alt="Hero banner, version 2" aspects={ASPECTS} rotation={rotation} onRotationChange={setRotation} onCropChange={setArea} />
<div className="flex min-w-0 flex-col gap-3">
<Button size="sm" className="self-start" disabled={!area} onClick={crop}>
<CropIcon aria-hidden="true" />
Crop
</Button>
<p role="status" className="text-xs text-muted-foreground tabular-nums">
{cut
? `Cropped to ${cut.width} × ${cut.height}, ${cut.kilobytes} KB ${cut.format}.`
: area
? `The frame holds ${area.width} × ${area.height} pixels of the picture.`
: "Loading the picture…"}
</p>
{cut ? (
// eslint-disable-next-line @next/next/no-img-element -- the crop itself, at its object URL
<img src={cut.url} alt="The cropped picture" width={cut.width} height={cut.height} className="h-auto w-full rounded-md ring-1 ring-border" />
) : null}
</div>
</div>
</div>
)
}Round, for an avatar
"use client"
import * as React from "react"
import { fileArtFor } from "@/lib/file-art"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { cropImage, ImageCropper, type CropArea } from "@/components/ui/image-cropper"
const PICTURE = fileArtFor("logo-light.png")
/** A round frame for an avatar: cropped square at 256 × 256, drawn round. */
export default function ImageCropperRound() {
const [area, setArea] = React.useState<CropArea | null>(null)
const [avatar, setAvatar] = React.useState<string | null>(null)
React.useEffect(() => () => {
if (avatar) URL.revokeObjectURL(avatar)
}, [avatar])
async function use() {
if (!area) return
const blob = await cropImage(PICTURE, area, { width: 256, height: 256 })
setAvatar(URL.createObjectURL(blob))
}
return (
<div className="flex w-full max-w-xs flex-col gap-3">
<ImageCropper src={PICTURE} alt="Workspace logo" shape="round" aspect={1} defaultZoom={1.4} onCropChange={setArea} />
<div className="flex items-center gap-3">
<Avatar className="size-10">
{avatar ? <AvatarImage src={avatar} alt="Your new picture" /> : null}
<AvatarFallback>NW</AvatarFallback>
</Avatar>
<p role="status" className="min-w-0 flex-1 text-xs text-muted-foreground">
{avatar ? "Your new picture, 256 × 256." : "Frame the logo, then use it."}
</p>
<Button size="sm" variant="outline" disabled={!area} onClick={use}>
Use picture
</Button>
</div>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| src | string | — | The picture — a URL, or an object URL for a file the reader picked. |
| alt | string | — | What the picture is. |
| aspect | number | 1 | Width over height of the frame; with aspects, the one chosen first. |
| aspects | { label: string; value: number }[] | — | Shapes the reader may pick between, drawn as a segmented control named Aspect ratio. |
| shape | "rect" | "round" | "rect" | A round frame for an avatar; the crop is still the square around it. |
| zoom | number | — | Controls the zoom; pair it with onZoomChange. |
| defaultZoom | number | minZoom | Where the zoom starts when it is not controlled. |
| onZoomChange | (zoom: number) => void | — | Hears every zoom the reader asks for, rounded to hundredths. |
| minZoom | number | 1 | 1 just covers the frame. |
| maxZoom | number | 4 | The furthest in the reader can zoom. |
| rotation | 0 | 90 | 180 | 270 | — | Controls the turn; pair it with onRotationChange. |
| onRotationChange | (rotation: Rotation) => void | — | Hears each quarter turn Rotate asks for. |
| onCropChange | (area: CropArea) => void | — | The source pixels the frame holds — { x, y, width, height } in the turned picture — once the picture is in and after every change. |
| cropImage() | (src, area, { rotation?, width, height, type?, quality? }) => Promise<Blob> | — | Cuts the area out of the picture, turned, scaled to width × height, and encodes it: image/webp at 0.92 unless told otherwise. |
| cropAreaFor() | (state: CropState, image: Size, frame: Size) => CropArea | — | The crop math the component runs, for a server or a test that wants the area without the component. |
| clampOffset() | (state: CropState, image: Size, frame: Size) => CropState | — | The state with the picture pulled back inside the frame's reach. |
| rotatedSize() | (image: Size, rotation: Rotation) => Size | — | A picture's size once turned: a quarter turn swaps its sides. |
Dependencies
Source
"use client"
import * as React from "react"
import { RotateCwIcon, ZoomInIcon, ZoomOutIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { SegmentedControl } from "@/components/ui/segmented-control"
import { Slider } from "@/components/ui/slider"
import { clampOffset, cropAreaFor, cropScale, zoomAround, type CropArea, type CropState, type Rotation } from "./crop"
import { CropFrame, CropStatus } from "./frame"
export { clampOffset, cropAreaFor, cropImage, cropScale, rotatedSize, zoomAround } from "./crop"
export type { CropArea, CropImageOptions, CropState, Rotation } from "./crop"
export type ImageCropperProps = Omit<React.ComponentProps<"div">, "onChange"> & {
/** The picture to crop — a URL, or an object URL for a file the reader picked. */
src: string
alt: string
/** Width over height of the frame. */
aspect?: number
/** Shapes the reader may pick between, as a segmented control; the first is chosen unless `aspect` names another. */
aspects?: { label: string; value: number }[]
shape?: "rect" | "round"
zoom?: number
defaultZoom?: number
onZoomChange?: (zoom: number) => void
minZoom?: number
maxZoom?: number
rotation?: Rotation
onRotationChange?: (rotation: Rotation) => void
/** The source pixels the frame holds, after every change — hand it to `cropImage`. */
onCropChange?: (area: CropArea) => void
}
type Size = { width: number; height: number }
type Point = { x: number; y: number }
/** The room round the frame inside the stage, so the dimmed picture shows what the crop cuts away. */
const MARGIN = 24
const STEP = 10
const LEAP = 50
const ZOOM_STEP = 0.1
const ORIGIN: Point = { x: 0, y: 0 }
/** The largest frame of the aspect that fits the stage less its margin. */
function frameIn(stage: Size, aspect: number): Size | null {
const room = { width: stage.width - MARGIN * 2, height: stage.height - MARGIN * 2 }
if (room.width <= 0 || room.height <= 0) return null
return room.width / room.height > aspect ? { width: room.height * aspect, height: room.height } : { width: room.width, height: room.width / aspect }
}
const distance = (a: Point, b: Point) => Math.hypot(a.x - b.x, a.y - b.y)
/**
* A picture under a frame: drag it, zoom it with the wheel, a pinch, the
* slider or + and −, nudge it with the arrow keys, turn it a quarter at a
* time. The outside of the frame is dimmed, and a thirds grid shows while it
* is dragged. It reports the source pixels the frame holds; `cropImage` cuts
* them out.
*/
function ImageCropper({
src,
alt,
aspect,
aspects,
shape = "rect",
zoom: zoomProp,
defaultZoom,
onZoomChange,
minZoom = 1,
maxZoom = 4,
rotation: rotationProp,
onRotationChange,
onCropChange,
className,
...props
}: ImageCropperProps) {
const descriptionId = React.useId()
const stageRef = React.useRef<HTMLDivElement>(null)
const pictureRef = React.useRef<HTMLImageElement>(null)
const clampZoom = (value: number) => Math.round(Math.min(maxZoom, Math.max(minZoom, value)) * 100) / 100
const [stage, setStage] = React.useState<Size | null>(null)
// Each keyed by the picture it belongs to, so a new src starts clean.
const [picture, setPicture] = React.useState<(Size & { src: string }) | null>(null)
const [failed, setFailed] = React.useState<string | null>(null)
const [moved, setMoved] = React.useState({ src, ...ORIGIN })
const [ownZoom, setOwnZoom] = React.useState(() => clampZoom(defaultZoom ?? minZoom))
const [ownRotation, setOwnRotation] = React.useState<Rotation>(0)
const [chosen, setChosen] = React.useState(() => aspect ?? aspects?.[0]?.value ?? 1)
const [dragging, setDragging] = React.useState(false)
const zoom = zoomProp ?? ownZoom
const rotation = rotationProp ?? ownRotation
const loaded = picture?.src === src ? picture : null
const broken = failed === src
const frame = stage ? frameIn(stage, aspects ? chosen : (aspect ?? 1)) : null
const ready = Boolean(loaded && frame)
const raw: CropState = { zoom, rotation, offset: moved.src === src ? { x: moved.x, y: moved.y } : ORIGIN }
const state = loaded && frame ? clampOffset(raw, loaded, frame) : raw
const area = loaded && frame ? cropAreaFor(state, loaded, frame) : null
const scale = loaded && frame ? cropScale(state, loaded, frame) : 0
const measure = React.useCallback(() => {
const node = stageRef.current
if (!node) return
const { width, height } = node.getBoundingClientRect()
setStage((current) => (current?.width === width && current.height === height ? current : { width, height }))
}, [])
React.useLayoutEffect(() => {
measure()
const node = stageRef.current
if (!node || typeof ResizeObserver === "undefined") return
const observer = new ResizeObserver(() => measure())
observer.observe(node)
return () => observer.disconnect()
}, [measure])
const settle = React.useCallback(() => {
// A picture that loaded, or failed, before this page hydrated sent its
// event to nobody: read where it stands instead.
const node = pictureRef.current
if (!node?.complete || !node.getAttribute("src")) return
if (node.naturalWidth > 0) setPicture({ src, width: node.naturalWidth, height: node.naturalHeight })
else setFailed(src)
}, [src])
React.useLayoutEffect(() => settle(), [settle])
// The area is reported once it is known and after every change, from an
// effect, so a parent that re-renders on it does not do so mid-render.
const report = React.useRef(onCropChange)
React.useLayoutEffect(() => {
report.current = onCropChange
})
const { x, y, width, height }: Partial<CropArea> = area ?? {}
React.useEffect(() => {
if (x !== undefined && y !== undefined && width !== undefined && height !== undefined) report.current?.({ x, y, width, height })
}, [x, y, width, height])
const place = (next: CropState) => {
if (!loaded || !frame) return
const { offset } = clampOffset(next, loaded, frame)
setMoved({ src, ...offset })
}
const zoomTo = (value: number, point: Point = ORIGIN) => {
const next = clampZoom(value)
if (!ready || next === zoom) return
place(zoomAround(state, next, point))
if (zoomProp === undefined) setOwnZoom(next)
onZoomChange?.(next)
}
const rotate = () => {
const next = ((rotation + 90) % 360) as Rotation
if (rotationProp === undefined) setOwnRotation(next)
onRotationChange?.(next)
}
/** A point on screen, measured from the frame's centre — which is the stage's. */
const fromCentre = (point: Point): Point => {
const box = stageRef.current?.getBoundingClientRect()
return box ? { x: point.x - box.left - box.width / 2, y: point.y - box.top - box.height / 2 } : ORIGIN
}
// The wheel listens natively: React's own wheel handler is passive, and a
// zoom that also scrolled the page would move the cropper out from under it.
// A smooth wheel moves in steps far under the hundredth the zoom is kept
// to, so the gesture's own zoom is kept unrounded here and only the zoom
// it rounds to is applied and reported; it starts again from the zoom on
// show whenever that has moved some other way.
const wheel = React.useRef<(event: WheelEvent) => void>(() => {})
const wheeled = React.useRef<number | null>(null)
React.useLayoutEffect(() => {
wheel.current = (event) => {
if (!ready) return
event.preventDefault()
// In pixels, whatever the wheel counts in: a line is 16px, a page the stage.
const pixels = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? (stage?.height ?? 0) : 1)
const from = wheeled.current !== null && clampZoom(wheeled.current) === zoom ? wheeled.current : zoom
const next = Math.min(maxZoom, Math.max(minZoom, from * Math.exp(-pixels * 0.0015)))
wheeled.current = next
zoomTo(next, fromCentre({ x: event.clientX, y: event.clientY }))
}
})
React.useEffect(() => {
const node = stageRef.current
if (!node) return
const onWheel = (event: WheelEvent) => wheel.current(event)
node.addEventListener("wheel", onWheel, { passive: false })
return () => node.removeEventListener("wheel", onWheel)
}, [])
// One pointer drags; two pinch. Each gesture is measured from where it
// began, so events that land between renders lose nothing.
const pointers = React.useRef(new Map<number, Point>())
const gesture = React.useRef<{ from: Point; offset: Point } | { span: number; zoom: number } | null>(null)
const begin = () => {
const [first, second] = [...pointers.current.values()]
gesture.current = second ? { span: distance(first, second), zoom } : first ? { from: first, offset: state.offset } : null
}
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (!ready || (event.pointerType === "mouse" && event.button !== 0)) return
event.currentTarget.setPointerCapture?.(event.pointerId)
pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
begin()
}
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (!pointers.current.has(event.pointerId)) return
pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
const [first, second] = [...pointers.current.values()]
const now = gesture.current
if (now && "from" in now && !second) {
setDragging(true)
place({ ...state, offset: { x: now.offset.x + first.x - now.from.x, y: now.offset.y + first.y - now.from.y } })
} else if (now && "span" in now && second) {
zoomTo((now.zoom * distance(first, second)) / now.span, fromCentre({ x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 }))
}
}
const onPointerEnd = (event: React.PointerEvent<HTMLDivElement>) => {
if (!pointers.current.delete(event.pointerId)) return
event.currentTarget.releasePointerCapture?.(event.pointerId)
begin()
if (pointers.current.size === 0) setDragging(false)
}
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
// A key with Ctrl, Meta or Alt held is the browser's and the page's — its
// own zoom, back and forward — never the cropper's.
if (!ready || event.target !== event.currentTarget || event.altKey || event.ctrlKey || event.metaKey) return
// Physical keys on purpose: the picture is not a line of text, so
// ArrowRight moves it right in a right-to-left page too.
const step = event.shiftKey ? LEAP : STEP
const moves: Record<string, Point> = { ArrowRight: { x: step, y: 0 }, ArrowLeft: { x: -step, y: 0 }, ArrowDown: { x: 0, y: step }, ArrowUp: { x: 0, y: -step } }
const move = moves[event.key]
if (move) place({ ...state, offset: { x: state.offset.x + move.x, y: state.offset.y + move.y } })
else if (event.key === "+" || event.key === "=") zoomTo(zoom + ZOOM_STEP)
else if (event.key === "-" || event.key === "_") zoomTo(zoom - ZOOM_STEP)
else return
event.preventDefault()
}
return (
<div data-slot="image-cropper" data-shape={shape} data-status={broken ? "error" : loaded ? "ready" : "loading"} className={cn("flex w-full flex-col gap-3", className)} {...props}>
<div
ref={stageRef}
data-slot="image-cropper-stage"
data-dragging={dragging || undefined}
role="group"
aria-label="Crop area"
aria-describedby={descriptionId}
aria-disabled={!ready || undefined}
tabIndex={0}
onKeyDown={onKeyDown}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerEnd}
onPointerCancel={onPointerEnd}
className="group/stage relative aspect-[4/3] w-full cursor-grab touch-none overflow-hidden rounded-lg select-none focus-ring aria-disabled:cursor-default data-dragging:cursor-grabbing"
>
{/* The picture on the dark planes, as media is, so the overlay round
the frame darkens in either theme; the focus outline stays on the
stage, in the page's own accent. */}
<div className="dark absolute inset-0 bg-background text-foreground">
{/* Plain <img> rather than next/image: the cropper reads the picture's
own pixel size and scales it itself; an optimised copy would be
neither. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={pictureRef}
src={src}
alt={alt}
draggable={false}
onLoad={(event) => setPicture({ src, width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight })}
onError={() => setFailed(src)}
className={cn("pointer-events-none absolute top-1/2 left-1/2 max-w-none -translate-x-1/2 -translate-y-1/2 select-none", !ready && "invisible")}
style={loaded && frame ? { width: loaded.width * scale, height: loaded.height * scale, transform: `translate(${state.offset.x}px, ${state.offset.y}px) rotate(${rotation}deg)` } : undefined}
/>
{ready && frame ? <CropFrame width={frame.width} height={frame.height} shape={shape} /> : null}
{broken || !loaded ? <CropStatus broken={broken} /> : null}
</div>
</div>
<p id={descriptionId} className="sr-only">
Drag the picture, or use the arrow keys, to move it in the frame; Shift moves it further. Plus and minus zoom.
</p>
<div className="flex items-center gap-2">
<ZoomOutIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
<Slider
aria-label="Zoom"
min={minZoom}
max={maxZoom}
step={0.05}
value={zoom}
disabled={!ready}
onValueChange={(next) => zoomTo(typeof next === "number" ? next : next[0])}
getAriaValueText={(_, value) => `${Math.round(value * 100)}%`}
className="min-w-0 flex-1"
/>
<ZoomInIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
<Button variant="outline" size="sm" className="ms-2" disabled={!ready} onClick={rotate}>
<RotateCwIcon aria-hidden="true" />
Rotate
</Button>
</div>
{aspects ? (
<SegmentedControl
aria-label="Aspect ratio"
size="sm"
options={aspects.map((option) => ({ value: String(option.value), label: option.label }))}
value={String(chosen)}
onValueChange={(value) => setChosen(Number(value))}
className="self-start"
/>
) : null}
</div>
)
}
export { ImageCropper }/** A quarter turn, clockwise. */
export type Rotation = 0 | 90 | 180 | 270
/** The part of the picture the frame holds, in the source's own pixels — after its rotation. */
export type CropArea = { x: number; y: number; width: number; height: number }
/**
* Where the picture sits under the frame: how far it is zoomed past covering
* the frame (1 covers it exactly), how far it has been moved from the centre
* in screen pixels, and how far it is turned.
*/
export type CropState = { zoom: number; offset: { x: number; y: number }; rotation: Rotation }
type Size = { width: number; height: number }
/** The picture's size once turned: a quarter turn swaps its sides. */
export function rotatedSize(image: Size, rotation: Rotation): Size {
return rotation % 180 === 0 ? { width: image.width, height: image.height } : { width: image.height, height: image.width }
}
/** Screen pixels per source pixel: just enough to cover the frame at zoom 1, and `zoom` times that. */
export function cropScale(state: CropState, image: Size, frame: Size): number {
const source = rotatedSize(image, state.rotation)
return Math.max(frame.width / source.width, frame.height / source.height) * state.zoom
}
/** The source pixels the frame holds, in whole pixels and never past the picture's edge. */
export function cropAreaFor(state: CropState, image: Size, frame: Size): CropArea {
const source = rotatedSize(image, state.rotation)
const scale = cropScale(state, image, frame)
const width = Math.min(source.width, Math.round(frame.width / scale))
const height = Math.min(source.height, Math.round(frame.height / scale))
const x = (source.width - frame.width / scale) / 2 - state.offset.x / scale
const y = (source.height - frame.height / scale) / 2 - state.offset.y / scale
return {
x: Math.min(source.width - width, Math.max(0, Math.round(x))),
y: Math.min(source.height - height, Math.max(0, Math.round(y))),
width,
height,
}
}
/** A negative zero (or NaN) read as the zero it means: the frame is centred, not -0px off it. */
const tidy = (value: number) => value || 0
/**
* The state with the picture moved back inside the frame's reach: it can
* travel half the amount it overhangs the frame each way, and a side that
* only just covers cannot move at all. The state itself when nothing moved.
*/
export function clampOffset(state: CropState, image: Size, frame: Size): CropState {
const source = rotatedSize(image, state.rotation)
const scale = cropScale(state, image, frame)
const reach = (length: number, frameLength: number) => Math.max(0, (length * scale - frameLength) / 2)
const clamp = (value: number, limit: number) => tidy(Math.min(limit, Math.max(-limit, value)))
const x = clamp(state.offset.x, reach(source.width, frame.width))
const y = clamp(state.offset.y, reach(source.height, frame.height))
return x === state.offset.x && y === state.offset.y ? state : { ...state, offset: { x, y } }
}
/**
* The state zoomed to `zoom` around a point given from the frame's centre,
* in screen pixels: the part of the picture under that point stays under it,
* as a wheel or a pinch expects. Not clamped — pass the result to
* `clampOffset`.
*/
export function zoomAround(state: CropState, zoom: number, point: { x: number; y: number }): CropState {
const ratio = zoom / state.zoom
return {
...state,
zoom,
offset: { x: tidy(point.x - (point.x - state.offset.x) * ratio), y: tidy(point.y - (point.y - state.offset.y) * ratio) },
}
}
export type CropImageOptions = {
/** The turn the area was measured under; the source is turned the same way before it is cut. */
rotation?: Rotation
/** The size of the picture handed back, in pixels: the area is scaled to it. */
width: number
height: number
/** A browser that cannot encode the type hands back a PNG; the Blob's own type says which. */
type?: "image/webp" | "image/png" | "image/jpeg"
/** 0–1, for WebP and JPEG. */
quality?: number
}
/** The picture at `src`, decoded. A picture from another server needs CORS headers, or the canvas will refuse to export it. */
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image()
image.crossOrigin = "anonymous"
image.onload = () => resolve(image)
image.onerror = () => reject(new Error(`The picture at ${src} could not be loaded.`))
image.src = src
})
}
/**
* Cuts `area` out of the picture at `src`, turned by `rotation`, scaled to
* `width` × `height`, and encodes it — WebP by default. Runs in the browser
* only: it draws on a canvas, which is also why the picture never has to
* leave the page to be cropped.
*/
export async function cropImage(src: string, area: CropArea, options: CropImageOptions): Promise<Blob> {
const { rotation = 0, width, height, type = "image/webp", quality = 0.92 } = options
const image = await loadImage(src)
const canvas = document.createElement("canvas")
canvas.width = width
canvas.height = height
const context = canvas.getContext("2d")
if (!context) throw new Error("This browser cannot draw on a canvas.")
context.imageSmoothingQuality = "high"
// Into the area's own space — the turned picture's pixels — then turn the
// source about the turned picture's centre and draw it there.
context.scale(width / area.width, height / area.height)
context.translate(-area.x, -area.y)
const turned = rotatedSize({ width: image.naturalWidth, height: image.naturalHeight }, rotation)
context.translate(turned.width / 2, turned.height / 2)
context.rotate((rotation * Math.PI) / 180)
context.drawImage(image, -image.naturalWidth / 2, -image.naturalHeight / 2)
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("The crop could not be encoded."))), type, quality)
})
}import { ImageOffIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Spinner } from "@/components/ui/spinner"
/**
* The frame over the picture. Its outside lies under the overlay — the
* token every backdrop is drawn with, which darkens in either theme where
* the ink at half strength would wash a dark page's picture white — drawn as
* the frame's spread shadow, so a round frame dims everything outside its
* circle. A thirds grid shows inside it while the stage is dragged (the
* stage is the `stage` group), fading in and out, and simply appearing under
* reduced motion. It is drawn on the dark planes, so its lines are light.
*/
function CropFrame({ width, height, shape }: { width: number; height: number; shape: "rect" | "round" }) {
return (
<div
data-slot="image-cropper-frame"
aria-hidden="true"
className={cn(
"pointer-events-none absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 overflow-hidden shadow-[0_0_0_100vmax_var(--overlay)] ring-1 ring-foreground/70",
shape === "round" ? "rounded-full" : "rounded-sm"
)}
style={{ width, height }}
>
{["start-1/3", "start-2/3"].map((at) => (
<span key={at} className={cn("absolute inset-y-0 w-px bg-foreground/60 opacity-0 transition-opacity duration-(--duration-base) group-data-dragging/stage:opacity-100", at)} />
))}
{["top-1/3", "top-2/3"].map((at) => (
<span key={at} className={cn("absolute inset-x-0 h-px bg-foreground/60 opacity-0 transition-opacity duration-(--duration-base) group-data-dragging/stage:opacity-100", at)} />
))}
</div>
)
}
/** What the stage says while the picture loads, and when it cannot be loaded. */
function CropStatus({ broken }: { broken: boolean }) {
return broken ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1 p-4 text-center">
<ImageOffIcon aria-hidden="true" className="mb-1 size-5 text-muted-foreground" />
<p className="text-sm font-medium">This picture can't be loaded</p>
<p className="text-xs text-muted-foreground">The file is missing or is not an image.</p>
</div>
) : (
<div className="absolute inset-0 grid place-items-center">
<Spinner aria-label="Loading picture" className="size-5 text-muted-foreground" />
</div>
)
}
export { CropFrame, CropStatus }