HextaUI

BetaBuilt on Vercel’s UI SDK 0.1.0-beta.1. Its API may change.

Image cropper

An image cropper on Vercel's UI SDK with a resizable selection, aspect ratios, a round avatar frame, zoom, rotate and flip.

  • Component
  • 10 parts
  • 6 examples
Depends on
  • @tabler/icons-react
  • @ui-sdk/core
  • @ui-sdk/react
  • class-variance-authority
A rowing boat on a green mountain lake
"use client"

import * as React from "react"
import { cropImage } from "@ui-sdk/react/image"

import { Button } from "@/components/ui/button"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import {
  ImageCropper,
  ImageCropperGrid,
  ImageCropperHandles,
  ImageCropperImage,
  ImageCropperSelection,
  ImageCropperViewport,
  type ImageCrop,
} from "@/components/ui/ui-sdk-image-cropper"

const photo =
  "https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=1200&q=80&auto=format&fit=crop"

const ratios = [
  { label: "Free", value: "free", ratio: undefined },
  { label: "Square", value: "square", ratio: 1 },
  { label: "Wide", value: "wide", ratio: 16 / 9 },
]

function UiSdkImageCropperDemo() {
  const [ratio, setRatio] = React.useState("wide")
  const [crop, setCrop] = React.useState<ImageCrop>()
  const [result, setResult] = React.useState<string>()

  React.useEffect(
    () => () => {
      if (result) URL.revokeObjectURL(result)
    },
    [result]
  )

  async function save() {
    if (!crop) return
    const file = await cropImage(photo, crop, { width: 480 })
    setResult(URL.createObjectURL(file))
  }

  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <ToggleGroup
        aria-label="Aspect ratio"
        value={[ratio]}
        onValueChange={(next) => {
          if (next[0]) setRatio(next[0])
        }}
        variant="outline"
        size="sm"
      >
        {ratios.map((item) => (
          <ToggleGroupItem key={item.value} value={item.value}>
            {item.label}
          </ToggleGroupItem>
        ))}
      </ToggleGroup>
      <ImageCropper
        aria-label="Photo"
        src={photo}
        aspectRatio={ratios.find((item) => item.value === ratio)?.ratio}
        minWidth={80}
        minHeight={80}
        crop={crop}
        onCropChange={setCrop}
      >
        <ImageCropperViewport>
          <ImageCropperImage alt="A rowing boat on a green mountain lake" />
          <ImageCropperSelection>
            <ImageCropperGrid />
            <ImageCropperHandles />
          </ImageCropperSelection>
        </ImageCropperViewport>
      </ImageCropper>
      <div className="flex items-center gap-3">
        <Button size="sm" onClick={save} disabled={!crop}>
          Crop
        </Button>
        {crop && (
          <span className="text-sm text-muted-foreground tabular-nums">
            {Math.round(crop.width)} × {Math.round(crop.height)}
          </span>
        )}
      </div>
      {result && (
        <img
          src={result}
          alt="The cropped photo"
          className="w-full rounded-lg"
        />
      )}
    </div>
  )
}

export { UiSdkImageCropperDemo }
pnpm dlx shadcn@latest add https://hextaui.com/r/ui-sdk-image-cropper.json

Adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

UI SDK adds a little CSS of its own in a cascade layer named ui-sdk. Declare that layer at the top of your global stylesheet, above the Tailwind import, so your classes win over it.

@layer ui-sdk;

@import "tailwindcss";
import {
  ImageCropper,
  ImageCropperGrid,
  ImageCropperHandles,
  ImageCropperImage,
  ImageCropperSelection,
  ImageCropperViewport,
} from "@/components/ui/ui-sdk-image-cropper"
<ImageCropper aria-label="Cover photo" src={src} aspectRatio={3}>
  <ImageCropperViewport>
    <ImageCropperImage alt="Your cover photo" />
    <ImageCropperSelection>
      <ImageCropperGrid />
      <ImageCropperHandles />
    </ImageCropperSelection>
  </ImageCropperViewport>
</ImageCropper>

The crop is a box in the image’s own pixels, with its rotation and flip. Pass it to cropImage to get a file you can upload. An image from another site needs CORS, or the browser won’t let the page read its pixels.

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

const file = await cropImage(src, crop, { width: 512 })
ImageCropper
├── ImageCropperViewport
│   ├── ImageCropperImage
│   └── ImageCropperSelection
│       ├── ImageCropperGrid
│       └── ImageCropperHandles
├── ImageCropperZoom
├── ImageCropperRotate
└── ImageCropperFlip

With mode="frame", the selection stays still and you pan and zoom the image under it. shape="circle" rounds it, so pass shape: "circle" to cropImage as well.

"use client"

import * as React from "react"
import { IconUser } from "@tabler/icons-react"
import { cropImage } from "@ui-sdk/react/image"

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  ImageCropper,
  ImageCropperImage,
  ImageCropperRotate,
  ImageCropperSelection,
  ImageCropperViewport,
  ImageCropperZoom,
  type ImageCrop,
} from "@/components/ui/ui-sdk-image-cropper"

const photo =
  "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1200&q=80&auto=format&fit=crop"

function UiSdkImageCropperAvatar() {
  const [crop, setCrop] = React.useState<ImageCrop>()
  const [avatar, setAvatar] = React.useState<string>()

  React.useEffect(
    () => () => {
      if (avatar) URL.revokeObjectURL(avatar)
    },
    [avatar]
  )

  async function save() {
    if (!crop) return
    const file = await cropImage(photo, crop, { width: 128, shape: "circle" })
    setAvatar(URL.createObjectURL(file))
  }

  return (
    <div className="flex w-full max-w-xs flex-col gap-3">
      <ImageCropper
        aria-label="Profile photo"
        src={photo}
        mode="frame"
        aspectRatio={1}
        shape="circle"
        maxZoom={4}
        crop={crop}
        onCropChange={setCrop}
      >
        <ImageCropperViewport>
          <ImageCropperImage alt="Snowy peaks above a sea of clouds" />
          <ImageCropperSelection />
        </ImageCropperViewport>
        <div className="flex items-center gap-2">
          <ImageCropperZoom />
          <ImageCropperRotate />
        </div>
      </ImageCropper>
      <div className="flex items-center gap-3">
        <Button size="sm" onClick={save} disabled={!crop}>
          Save avatar
        </Button>
        <Avatar>
          {avatar && <AvatarImage src={avatar} alt="Your new avatar" />}
          <AvatarFallback>
            <IconUser />
          </AvatarFallback>
        </Avatar>
      </div>
    </div>
  )
}

export { UiSdkImageCropperAvatar }

ImageCropperRotate turns the image a quarter clockwise and ImageCropperFlip mirrors it. Without children they’re icon buttons with English labels.

import {
  ImageCropper,
  ImageCropperFlip,
  ImageCropperGrid,
  ImageCropperHandles,
  ImageCropperImage,
  ImageCropperRotate,
  ImageCropperSelection,
  ImageCropperViewport,
} from "@/components/ui/ui-sdk-image-cropper"

const photo =
  "https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=1200&q=80&auto=format&fit=crop"

function UiSdkImageCropperRotateFlip() {
  return (
    <ImageCropper
      aria-label="Photo"
      src={photo}
      aspectRatio={4 / 5}
      className="max-w-sm"
    >
      <ImageCropperViewport>
        <ImageCropperImage alt="A rowing boat on a green mountain lake" />
        <ImageCropperSelection>
          <ImageCropperGrid />
          <ImageCropperHandles />
        </ImageCropperSelection>
      </ImageCropperViewport>
      <div className="flex gap-2">
        <ImageCropperRotate />
        <ImageCropperFlip />
        <ImageCropperFlip direction="vertical" />
      </div>
    </ImageCropper>
  )
}

export { UiSdkImageCropperRotateFlip }

Crop a photo from a file input with an object URL. Give the cropper a key so a new photo starts a fresh crop.

"use client"

import * as React from "react"

import { Input } from "@/components/ui/input"
import {
  ImageCropper,
  ImageCropperGrid,
  ImageCropperHandles,
  ImageCropperImage,
  ImageCropperSelection,
  ImageCropperViewport,
} from "@/components/ui/ui-sdk-image-cropper"

function UiSdkImageCropperUpload() {
  const [src, setSrc] = React.useState<string>()

  React.useEffect(
    () => () => {
      if (src) URL.revokeObjectURL(src)
    },
    [src]
  )

  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <Input
        type="file"
        accept="image/*"
        aria-label="Choose a photo"
        onChange={(event) => {
          const file = event.target.files?.[0]
          if (file) setSrc(URL.createObjectURL(file))
        }}
      />
      {src ? (
        <ImageCropper key={src} aria-label="Photo" src={src} aspectRatio={1}>
          <ImageCropperViewport>
            <ImageCropperImage alt="The photo you chose" />
            <ImageCropperSelection>
              <ImageCropperGrid />
              <ImageCropperHandles />
            </ImageCropperSelection>
          </ImageCropperViewport>
        </ImageCropper>
      ) : (
        <p className="text-sm text-muted-foreground">
          Choose a photo to crop it.
        </p>
      )}
    </div>
  )
}

export { UiSdkImageCropperUpload }

onCropCommit runs once a drag or key press ends, so save there. A controller from createImageCropperController() sets the crop from anywhere.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  createImageCropperController,
  ImageCropper,
  ImageCropperGrid,
  ImageCropperHandles,
  ImageCropperImage,
  ImageCropperSelection,
  ImageCropperViewport,
  type ImageCrop,
} from "@/components/ui/ui-sdk-image-cropper"

const photo =
  "https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=1200&q=80&auto=format&fit=crop"

const start = { x: 120, y: 80, width: 480, height: 480 }

function UiSdkImageCropperControlled() {
  const [cropper] = React.useState(createImageCropperController)
  const [saved, setSaved] = React.useState<ImageCrop | null>(null)

  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <ImageCropper
        aria-label="Photo"
        src={photo}
        aspectRatio={1}
        defaultCrop={start}
        controller={cropper}
        onCropCommit={setSaved}
      >
        <ImageCropperViewport>
          <ImageCropperImage alt="A rowing boat on a green mountain lake" />
          <ImageCropperSelection>
            <ImageCropperGrid />
            <ImageCropperHandles />
          </ImageCropperSelection>
        </ImageCropperViewport>
      </ImageCropper>
      <div className="flex items-center gap-3">
        <Button
          variant="outline"
          size="sm"
          onClick={() => cropper.setCrop(start)}
        >
          Reset
        </Button>
        <span className="text-sm text-muted-foreground tabular-nums">
          {saved
            ? `Saved ${Math.round(saved.width)} × ${Math.round(saved.height)} at ${Math.round(saved.x)}, ${Math.round(saved.y)}`
            : "Move the crop to save it."}
        </span>
      </div>
    </div>
  )
}

export { UiSdkImageCropperControlled }

A disabled cropper can’t be focused or changed, and its zoom and buttons are disabled.

import {
  ImageCropper,
  ImageCropperHandles,
  ImageCropperImage,
  ImageCropperRotate,
  ImageCropperSelection,
  ImageCropperViewport,
  ImageCropperZoom,
} from "@/components/ui/ui-sdk-image-cropper"

const photo =
  "https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=1200&q=80&auto=format&fit=crop"

function UiSdkImageCropperDisabled() {
  return (
    <ImageCropper
      aria-label="Photo"
      src={photo}
      aspectRatio={16 / 9}
      disabled
      className="max-w-md"
    >
      <ImageCropperViewport>
        <ImageCropperImage alt="A rowing boat on a green mountain lake" />
        <ImageCropperSelection>
          <ImageCropperHandles />
        </ImageCropperSelection>
      </ImageCropperViewport>
      <div className="flex items-center gap-2">
        <ImageCropperZoom />
        <ImageCropperRotate />
      </div>
    </ImageCropper>
  )
}

export { UiSdkImageCropperDisabled }
KeyAction
←→↑↓Moves the selection, or on a handle, moves its edges.
Shift←Moves ten times as far.
+-Zooms in or out about the crop’s middle.
RTurns the image a quarter clockwise, or back with Shift.
EscDuring a drag, puts the crop back where the drag started.

The cropper is a group named by aria-label. The selection and each handle are sliders that say the crop in words, like “300 by 300 pixels, at 50, 0”, and the zoom says its level, like “125%”. Those names are in English, so pass aria-label to each part to name it in your page’s language.

The grid only fades in while you drag, and not at all when motion is reduced. Handles get a larger hit area on touch screens, the page doesn’t scroll during a drag, and in forced colors mode the selection gets a system-colored border.

Each part wraps the UI SDK part of the same name and accepts all of its props, including render and a className that can be a function of the part’s state.

PropTypeDefault
src
string–
cropThe crop in image pixels, with rotation, flipX and flipY.
ImageCrop–
defaultCrop
ImageCrop–
onCropChange
(crop, details) => void–
onCropCommitRuns once a drag, key press or wheel ends.
(crop, details) => void–
mode
"box" | "frame""box"
aspectRatio
number–
shape
"rectangle" | "circle""rectangle"
minWidth
number–
minHeight
number–
maxZoom
number10
disabled
booleanfalse
controllerFrom createImageCropperController().
ImageCropperController–
AttributeDescription
data-slot="image-cropper"Target the cropper in CSS.
data-modebox or frame.
data-shaperectangle or circle.
data-loadingPresent until the image loads.
data-errorPresent when the image failed to load.
data-draggingPresent while a pointer drags.
PropTypeDefault
render
ReactElement | (props, state) => ReactElement–
AttributeDescription
data-slot="image-cropper-viewport"Target the viewport in CSS.
--image-cropper-aspect-ratioThe image’s width over its height once it loads. The viewport takes it as its aspect ratio.
PropTypeDefault
altRequired.
string–
render
ReactElement | (props, state) => ReactElement–
PropTypeDefault
aria-label
string"Crop"
render
ReactElement | (props, state) => ReactElement–
AttributeDescription
data-slot="image-cropper-selection"Target the selection in CSS.
PropTypeDefault
childrenDefaults to lines in thirds.
ReactNode–
PropTypeDefault
positionThe edge or corner it resizes.
"n" | "e" | "s" | "w" | "ne" | "nw" | "se" | "sw"–

ImageCropperHandles renders all eight. Handles don’t show in frame mode.

A native range input from 1, the largest crop, to maxZoom.

PropTypeDefault
childrenDefaults to an icon with the label Rotate. With children it’s a small text button.
ReactNode–
PropTypeDefault
direction
"horizontal" | "vertical""horizontal"
childrenDefaults to an icon with an English label.
ReactNode–