Cut a region out of an image into a new file, turned, flipped, scaled or round.

@ui-sdk/core/image cuts a region out of an image and returns it as a new image file. Use it after someone picks a crop, like an avatar from a photo, and submit or upload the file it returns.

Crop an Image

cropImage takes an image, the region to keep and options for the file it writes. The region is in the image's own pixels, whatever size the image shows at on the page:

import { cropImage } from "@ui-sdk/core/image"

const file = await cropImage(photo, { x: 120, y: 40, width: 600, height: 600 })

file.name // "photo.png", from the photo's name
file.type // "image/png"

The image can be a file from an <input type="file">, a URL, an <img>, a <canvas> or an ImageBitmap. A URL from another origin needs CORS, or the browser won't let the page read its pixels. The promise resolves with a File, so FormData and fetch take it as it is.

Turn and Flip

Set rotation to turn the image a quarter turn clockwise at a time, and flipX or flipY to mirror it. The region's x and y count from the top left of the image as it shows after it's turned and flipped:

const file = await cropImage(photo, {
  x: 0,
  y: 0,
  width: 300,
  height: 400,
  rotation: 90,
  flipX: true,
})

So a 400 by 300 photo turned by 90 is 300 wide and 400 tall, and the region above keeps all of it.

Console
import * as React from "react"
import { cropImage, type ImageRotation } from "@ui-sdk/core/image"
import { useLog } from "./log"

const quarters = [
  ["#ef4444", 0, 0],
  ["#22c55e", 200, 0],
  ["#3b82f6", 0, 150],
  ["#eab308", 200, 150],
] as const

function paint(canvas: HTMLCanvasElement) {
  const context = canvas.getContext("2d")!
  for (const [color, x, y] of quarters) {
    context.fillStyle = color
    context.fillRect(x, y, 200, 150)
  }
}

function Example() {
  const log = useLog()
  const source = React.useRef<HTMLCanvasElement>(null)
  const [rotation, setRotation] = React.useState<ImageRotation>(0)
  const [flipX, setFlipX] = React.useState(false)
  const [circle, setCircle] = React.useState(false)
  const [result, setResult] = React.useState<{ url: string; file: File }>()

  React.useEffect(() => paint(source.current!), [])
  React.useEffect(
    () => () => result && URL.revokeObjectURL(result.url),
    [result]
  )

  async function crop() {
    const file = await cropImage(
      source.current!,
      { x: 0, y: 0, width: 150, height: 150, rotation, flipX },
      { width: 96, shape: circle ? "circle" : "rectangle" }
    )
    setResult({ url: URL.createObjectURL(file), file })
    log(`${file.name}, ${file.type}, ${file.size} bytes`)
  }

  return (
    <div data-slot="stack">
      <div data-slot="controls">
        <select
          aria-label="Rotation"
          value={rotation}
          onChange={(event) =>
            setRotation(Number(event.target.value) as ImageRotation)
          }
        >
          {[0, 90, 180, 270].map((degrees) => (
            <option key={degrees} value={degrees}>
              Turn {degrees}°
            </option>
          ))}
        </select>
        <label data-slot="label">
          <input
            type="checkbox"
            checked={flipX}
            onChange={(event) => setFlipX(event.target.checked)}
          />
          Flip
        </label>
        <label data-slot="label">
          <input
            type="checkbox"
            checked={circle}
            onChange={(event) => setCircle(event.target.checked)}
          />
          Circle
        </label>
      </div>
      <div data-slot="image-crop-stage">
        <canvas
          ref={source}
          width={400}
          height={300}
          aria-label="Four colored quarters"
          role="img"
          style={{
            transform: `scale(${flipX ? -1 : 1}, 1) rotate(${rotation}deg)`,
          }}
        />
      </div>
      <div data-slot="image-crop-output">
        <button type="button" data-variant="solid" onClick={crop}>
          Crop
        </button>
        {result && (
          <img
            data-slot="image-crop-result"
            src={result.url}
            alt="The cropped image"
          />
        )}
      </div>
    </div>
  )
}

export { Example }
Turn or flip the image, then press Crop. The top-left square of the turned image becomes a 96 pixel file. Check Circle for round corners.

The File

OptionWhat it sets
type"image/png" by default, or "image/jpeg" or "image/webp"
qualityFrom 0 to 1, for JPEG and WebP
widthThe width in pixels. Left out, it follows height at the region's ratio, or the region's width
heightThe height in pixels. Left out, it follows width at the region's ratio, or the region's height
shape"circle" keeps the ellipse inside the region and leaves the corners transparent
nameThe file's name, instead of the source's name with the new extension

Pass width alone to scale the crop, like a 256 pixel avatar from a much larger photo:

const avatar = await cropImage(photo, crop, {
  type: "image/webp",
  quality: 0.9,
  width: 256,
  shape: "circle",
})

A browser that can't write the type you ask for writes PNG instead, and the file's type and name say which it wrote. JPEG has no transparency, so a circle's corners turn black there. Use PNG or WebP for round crops.