# Image cropper

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

Canonical: https://hextaui.com/docs/ui-sdk-image-cropper
Last updated: 2026-11-13

> **Beta.** Built on Vercel’s UI SDK 0.1.0-beta.1 (`@ui-sdk/react`), a public beta. Its API may change.

```tsx title="components/examples/ui-sdk-image-cropper/demo.tsx"
"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 }
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/ui-sdk-image-cropper.json
```

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

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @ui-sdk/react@0.1.0-beta.1 @ui-sdk/core@0.1.0-beta.1 @tabler/icons-react class-variance-authority cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/ui-sdk-image-cropper.tsx"
"use client"

import * as React from "react"
import {
  IconFlipHorizontal,
  IconFlipVertical,
  IconRotateClockwise2,
} from "@tabler/icons-react"
import * as ImageCropperPrimitive from "@ui-sdk/react/image-cropper"
import { cva } from "class-variance-authority"
import { cn } from "cn"

import { buttonVariants } from "@/components/ui/button"

type ClassName<State> =
  string | ((state: State) => string | undefined) | undefined

function mergeClassName<State>(base: string, className: ClassName<State>) {
  return typeof className === "function"
    ? (state: State) => cn(base, className(state))
    : cn(base, className)
}

function ImageCropper({
  className,
  ...props
}: React.ComponentProps<typeof ImageCropperPrimitive.ImageCropper>) {
  return (
    <ImageCropperPrimitive.ImageCropper
      className={mergeClassName(
        "group/image-cropper flex w-full min-w-0 flex-col gap-3",
        className
      )}
      {...props}
    />
  )
}

function ImageCropperViewport({
  className,
  ...props
}: React.ComponentProps<typeof ImageCropperPrimitive.ImageCropperViewport>) {
  return (
    <ImageCropperPrimitive.ImageCropperViewport
      className={mergeClassName(
        "relative aspect-(--image-cropper-aspect-ratio,3/2) w-full touch-none overflow-hidden rounded-lg bg-muted p-3 select-none data-[mode=frame]:aspect-square data-[mode=frame]:p-8 motion-safe:data-loading:animate-pulse data-disabled:opacity-50",
        className
      )}
      {...props}
    />
  )
}

function ImageCropperImage({
  className,
  ...props
}: React.ComponentProps<typeof ImageCropperPrimitive.ImageCropperImage>) {
  return (
    <ImageCropperPrimitive.ImageCropperImage
      className={mergeClassName(
        "max-w-none transition-opacity duration-200 ease-out-quint data-error:opacity-0 data-loading:opacity-0 motion-reduce:transition-none",
        className
      )}
      {...props}
    />
  )
}

function ImageCropperSelection({
  className,
  ...props
}: React.ComponentProps<typeof ImageCropperPrimitive.ImageCropperSelection>) {
  return (
    <ImageCropperPrimitive.ImageCropperSelection
      className={mergeClassName(
        "shadow-[0_0_0_100vmax_--alpha(var(--color-black)/55%)] inset-ring-1 inset-ring-white/80 outline-none focus-visible:ring-3 focus-visible:inset-ring-2 focus-visible:ring-focus-ring focus-visible:inset-ring-white data-[mode=box]:cursor-move data-[mode=frame]:cursor-grab data-[mode=frame]:data-dragging:cursor-grabbing data-[shape=circle]:rounded-full forced-colors:border forced-colors:border-[Highlight] data-disabled:cursor-not-allowed",
        className
      )}
      {...props}
    />
  )
}

function ImageCropperGrid({
  className,
  children,
  ...props
}: React.ComponentProps<typeof ImageCropperPrimitive.ImageCropperGrid>) {
  return (
    <ImageCropperPrimitive.ImageCropperGrid
      className={mergeClassName(
        "overflow-hidden opacity-0 transition-opacity duration-150 ease-out-quint group-data-dragging/image-cropper:opacity-100 group-data-[shape=circle]/image-cropper:rounded-full motion-reduce:transition-none [&>span]:absolute [&>span]:bg-white/45",
        className
      )}
      {...props}
    >
      {children ?? (
        <>
          <span className="inset-y-0 left-1/3 w-px" />
          <span className="inset-y-0 left-2/3 w-px" />
          <span className="inset-x-0 top-1/3 h-px" />
          <span className="inset-x-0 top-2/3 h-px" />
        </>
      )}
    </ImageCropperPrimitive.ImageCropperGrid>
  )
}

const imageCropperHandleVariants = cva(
  "absolute z-10 outline-none after:absolute after:-inset-2.5 focus-visible:rounded-xs focus-visible:ring-3 focus-visible:ring-focus-ring forced-colors:border-[Highlight] pointer-coarse:after:-inset-4 data-disabled:cursor-not-allowed",
  {
    variants: {
      position: {
        nw: "-top-0.5 -left-0.5 size-4 cursor-nwse-resize rounded-tl-xs border-t-3 border-l-3 border-white",
        ne: "-top-0.5 -right-0.5 size-4 cursor-nesw-resize rounded-tr-xs border-t-3 border-r-3 border-white",
        se: "-right-0.5 -bottom-0.5 size-4 cursor-nwse-resize rounded-br-xs border-r-3 border-b-3 border-white",
        sw: "-bottom-0.5 -left-0.5 size-4 cursor-nesw-resize rounded-bl-xs border-b-3 border-l-3 border-white",
        n: "-top-0.5 left-1/2 h-0.75 w-6 -translate-x-1/2 cursor-ns-resize rounded-full bg-white forced-colors:border",
        s: "-bottom-0.5 left-1/2 h-0.75 w-6 -translate-x-1/2 cursor-ns-resize rounded-full bg-white forced-colors:border",
        e: "top-1/2 -right-0.5 h-6 w-0.75 -translate-y-1/2 cursor-ew-resize rounded-full bg-white forced-colors:border",
        w: "top-1/2 -left-0.5 h-6 w-0.75 -translate-y-1/2 cursor-ew-resize rounded-full bg-white forced-colors:border",
      },
    },
  }
)

function ImageCropperHandle({
  className,
  position,
  ...props
}: React.ComponentProps<typeof ImageCropperPrimitive.ImageCropperHandle>) {
  return (
    <ImageCropperPrimitive.ImageCropperHandle
      position={position}
      className={mergeClassName(
        imageCropperHandleVariants({ position }),
        className
      )}
      {...props}
    />
  )
}

const imageCropperHandlePositions = [
  "nw",
  "n",
  "ne",
  "e",
  "se",
  "s",
  "sw",
  "w",
] as const

function ImageCropperHandles() {
  return imageCropperHandlePositions.map((position) => (
    <ImageCropperHandle key={position} position={position} />
  ))
}

function ImageCropperZoom({
  className,
  ...props
}: React.ComponentProps<typeof ImageCropperPrimitive.ImageCropperZoom>) {
  return (
    <ImageCropperPrimitive.ImageCropperZoom
      className={mergeClassName(
        "h-5 w-full min-w-0 cursor-pointer appearance-none bg-transparent outline-none disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:h-8 [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:inset-ring-(length:--hairline) [&::-moz-range-thumb]:inset-ring-foreground/30 focus-visible:[&::-moz-range-thumb]:ring-3 focus-visible:[&::-moz-range-thumb]:ring-focus-ring pointer-coarse:[&::-moz-range-thumb]:size-6 [&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-foreground/10 dark:[&::-moz-range-track]:bg-foreground/15 [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-foreground/10 dark:[&::-webkit-slider-runnable-track]:bg-foreground/15 [&::-webkit-slider-thumb]:-mt-1.25 [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:inset-ring-(length:--hairline) [&::-webkit-slider-thumb]:inset-ring-foreground/30 [&::-webkit-slider-thumb]:transition-[box-shadow] [&::-webkit-slider-thumb]:duration-150 focus-visible:[&::-webkit-slider-thumb]:ring-3 focus-visible:[&::-webkit-slider-thumb]:ring-focus-ring pointer-coarse:[&::-webkit-slider-thumb]:-mt-2.25 pointer-coarse:[&::-webkit-slider-thumb]:size-6",
        className
      )}
      {...props}
    />
  )
}

function ImageCropperRotate({
  className,
  children,
  ...props
}: React.ComponentProps<typeof ImageCropperPrimitive.ImageCropperRotate>) {
  return (
    <ImageCropperPrimitive.ImageCropperRotate
      aria-label={children ? undefined : "Rotate"}
      className={mergeClassName(
        buttonVariants({
          variant: "outline",
          size: children ? "sm" : "icon-sm",
        }),
        className
      )}
      {...props}
    >
      {children ?? <IconRotateClockwise2 aria-hidden="true" />}
    </ImageCropperPrimitive.ImageCropperRotate>
  )
}

function ImageCropperFlip({
  className,
  children,
  direction = "horizontal",
  ...props
}: React.ComponentProps<typeof ImageCropperPrimitive.ImageCropperFlip>) {
  const Icon = direction === "vertical" ? IconFlipVertical : IconFlipHorizontal

  return (
    <ImageCropperPrimitive.ImageCropperFlip
      direction={direction}
      aria-label={
        children
          ? undefined
          : direction === "vertical"
            ? "Flip vertically"
            : "Flip horizontally"
      }
      className={mergeClassName(
        buttonVariants({
          variant: "outline",
          size: children ? "sm" : "icon-sm",
        }),
        className
      )}
      {...props}
    >
      {children ?? <Icon aria-hidden="true" />}
    </ImageCropperPrimitive.ImageCropperFlip>
  )
}

const createImageCropperController =
  ImageCropperPrimitive.createImageCropperController

export {
  ImageCropper,
  ImageCropperViewport,
  ImageCropperImage,
  ImageCropperSelection,
  ImageCropperGrid,
  ImageCropperHandle,
  ImageCropperHandles,
  ImageCropperZoom,
  ImageCropperRotate,
  ImageCropperFlip,
  createImageCropperController,
  imageCropperHandleVariants,
}
export type {
  ImageCrop,
  ImageCropperController,
  ImageCropperHandlePosition,
  ImageCropperMode,
  ImageCropperShape,
} from "@ui-sdk/react/image-cropper"
```

```tsx title="components/ui/button.tsx"
"use client"

import * as React from "react"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { IconAlertCircle, IconCircleCheck } from "@tabler/icons-react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import { Spinner } from "@/components/ui/spinner"
import {
  useButtonFeedback,
  type ButtonFeedbackOptions,
  type ButtonStatus,
} from "@/hooks/use-button-feedback"

const buttonVariants = cva(
  "group/button relative inline-flex items-center justify-center rounded-md bg-clip-padding text-sm font-medium whitespace-nowrap inset-ring-(length:--hairline) inset-ring-transparent transition-[color,background-color,box-shadow,opacity,translate,scale] duration-200 ease-out-quint outline-none select-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden active:duration-100 active:not-aria-[haspopup]:not-aria-busy:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-busy:cursor-progress aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:inset-ring-destructive motion-safe:active:not-aria-[haspopup]:not-aria-busy:scale-[0.97] dark:aria-invalid:ring-destructive/40 dark:aria-invalid:inset-ring-destructive/50 forced-colors:border [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/80",
        outline:
          "bg-background inset-ring-border hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30 dark:inset-ring-input dark:hover:bg-input/50",
        secondary:
          "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
        ghost:
          "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 forced-colors:border-0",
        "ghost-destructive":
          "text-destructive hover:bg-destructive/10 hover:text-destructive aria-expanded:bg-destructive/10 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 dark:hover:bg-destructive/20 forced-colors:border-0",
        destructive:
          "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/70",
        link: "text-primary underline-offset-4 hover:underline forced-colors:border-0",
        success:
          "bg-success text-success-foreground hover:bg-success/90 focus-visible:ring-success/70 focus-visible:inset-ring-success/40",
        error:
          "bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 motion-safe:animate-button-shake",
      },
      size: {
        default:
          "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
        xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-y-2.5 pointer-coarse:after:inset-x-0 pointer-coarse:in-data-[slot=button-group]:after:hidden has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
        sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-y-1.5 pointer-coarse:after:inset-x-0 pointer-coarse:in-data-[slot=button-group]:after:hidden has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5",
        lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
        icon: "size-9 shrink-0",
        "icon-xs":
          "size-6 shrink-0 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-2.5 pointer-coarse:in-data-[slot=button-group]:after:hidden [&_svg:not([class*='size-'])]:size-3",
        "icon-sm":
          "size-8 shrink-0 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5 pointer-coarse:in-data-[slot=button-group]:after:hidden",
        "icon-lg": "size-10 shrink-0",
        "icon-xl": "size-12 shrink-0 [&_svg:not([class*='size-'])]:size-5",
      },
      shape: {
        default: "",
        pill: "rounded-full in-data-[slot=button-group]:rounded-full",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
      shape: "default",
    },
  }
)

type ButtonVariant = Exclude<
  NonNullable<VariantProps<typeof buttonVariants>["variant"]>,
  "success" | "error"
>

type ButtonClickEvent = Parameters<
  NonNullable<ButtonPrimitive.Props["onClick"]>
>[0]

type ButtonProps = Omit<ButtonPrimitive.Props, "onClick"> &
  Omit<VariantProps<typeof buttonVariants>, "variant"> &
  ButtonFeedbackOptions & {
    variant?: ButtonVariant | null
    onClick?: (event: ButtonClickEvent) => unknown
    feedback?: boolean
    loading?: boolean
    status?: ButtonStatus
    loadingLabel?: React.ReactNode
    successLabel?: React.ReactNode
    errorLabel?: React.ReactNode | ((error: unknown) => React.ReactNode)
  }

const stateTransitionTime = 220
const statuses: ButtonStatus[] = ["idle", "loading", "success", "error"]
const quietVariants: ButtonVariant[] = ["ghost", "link"]
const quietStatusClasses: Partial<Record<ButtonStatus, string>> = {
  success: "text-success hover:text-success",
  error:
    "text-destructive hover:text-destructive motion-safe:animate-button-shake",
}

function isThenable(value: unknown): value is PromiseLike<unknown> {
  return (
    typeof value === "object" &&
    value !== null &&
    typeof (value as PromiseLike<unknown>).then === "function"
  )
}

function useStatusTransition(status: ButtonStatus) {
  const [shown, setShown] = React.useState(status)
  const [leaving, setLeaving] = React.useState<ButtonStatus | null>(null)
  const [transitionKey, setTransitionKey] = React.useState(0)

  if (status !== shown) {
    setLeaving(shown)
    setShown(status)
    setTransitionKey((key) => key + 1)
  }

  React.useEffect(() => {
    if (leaving === null) {
      return
    }
    const timer = setTimeout(() => setLeaving(null), stateTransitionTime)
    return () => clearTimeout(timer)
  }, [leaving, transitionKey])

  return { leaving, transitionKey }
}

function useLayerWidth(status: ButtonStatus, active: boolean) {
  const contentRef = React.useRef<HTMLSpanElement>(null)
  const layersRef = React.useRef(new Map<ButtonStatus, HTMLElement>())
  const observerRef = React.useRef<ResizeObserver | null>(null)
  const statusRef = React.useRef(status)

  const applyWidth = React.useCallback(() => {
    const content = contentRef.current
    const layer = layersRef.current.get(statusRef.current)
    if (!content || !layer) {
      return
    }
    const natural = parseFloat(getComputedStyle(layer).width)
    if (!Number.isFinite(natural)) {
      return
    }
    content.style.width = `${natural}px`
    if (!content.hasAttribute("data-measured")) {
      requestAnimationFrame(() => content.setAttribute("data-measured", ""))
    }
  }, [])

  React.useLayoutEffect(() => {
    statusRef.current = status
    if (active) {
      applyWidth()
    }
  }, [active, applyWidth, status])

  React.useLayoutEffect(() => {
    if (!active || typeof ResizeObserver === "undefined") {
      return
    }
    const observer = new ResizeObserver(applyWidth)
    observerRef.current = observer
    layersRef.current.forEach((layer) => observer.observe(layer))
    document.fonts?.addEventListener?.("loadingdone", applyWidth)
    return () => {
      observer.disconnect()
      observerRef.current = null
      document.fonts?.removeEventListener?.("loadingdone", applyWidth)
    }
  }, [active, applyWidth])

  const layerRef = React.useCallback((node: HTMLElement | null) => {
    if (!node) {
      return
    }
    const layer = node.dataset.layer as ButtonStatus
    layersRef.current.set(layer, node)
    observerRef.current?.observe(node)
    return () => {
      observerRef.current?.unobserve(node)
      if (layersRef.current.get(layer) === node) {
        layersRef.current.delete(layer)
      }
    }
  }, [])

  return { contentRef, layerRef }
}

function composeHandlers<Event>(
  first: ((event: Event) => void) | undefined,
  second: (event: Event) => void
) {
  return (event: Event) => {
    first?.(event)
    second(event)
  }
}

function Button({
  className,
  variant = "default",
  size = "default",
  shape = "default",
  children,
  onClick,
  disabled,
  focusableWhenDisabled,
  feedback,
  loading,
  status: statusProp,
  onStatusChange,
  onError,
  resetAfter,
  loadingLabel,
  successLabel = "Done",
  errorLabel = "Failed",
  onPointerEnter,
  onPointerLeave,
  onFocus,
  onBlur,
  ...props
}: ButtonProps) {
  const internal = useButtonFeedback({ resetAfter, onStatusChange, onError })
  const status = statusProp ?? (loading ? "loading" : internal.status)
  const requested =
    Boolean(feedback) || loading !== undefined || statusProp !== undefined
  const [enabled, setEnabled] = React.useState(requested)
  if (requested && !enabled) {
    setEnabled(true)
  }

  const { leaving, transitionKey } = useStatusTransition(status)
  const iconOnly = size?.startsWith("icon") ?? false
  const widthStatus =
    status === "loading" && loadingLabel === undefined ? "idle" : status
  const { contentRef, layerRef } = useLayerWidth(
    widthStatus,
    enabled && !iconOnly
  )
  const isLoading = status === "loading"
  const quiet = quietVariants.includes(variant ?? "default")
  const resolvedVariant =
    !quiet && status === "success"
      ? "success"
      : !quiet && status === "error"
        ? "error"
        : variant
  const resolvedErrorLabel =
    typeof errorLabel !== "function"
      ? errorLabel
      : internal.error !== undefined
        ? errorLabel(internal.error)
        : "Failed"

  const handleClick = (event: ButtonClickEvent) => {
    if (feedback && internal.isPending()) {
      return
    }
    const result = onClick?.(event)
    if (feedback && isThenable(result)) {
      internal.track(result)
    }
  }

  const layerContent = (layer: ButtonStatus, animated: boolean) => {
    if (layer === "idle") {
      return children
    }
    if (layer === "loading") {
      return (
        <>
          <Spinner aria-hidden size={null} animated={animated} />
          {iconOnly ? null : loadingLabel}
        </>
      )
    }
    if (layer === "success") {
      return (
        <>
          <IconCircleCheck
            className={cn(
              animated &&
                "motion-safe:[&>path:first-child]:animate-button-draw-circle motion-safe:[&>path:first-child]:[stroke-dasharray:57] motion-safe:[&>path:first-child]:[stroke-dashoffset:57] motion-safe:[&>path:last-child]:animate-button-draw-check motion-safe:[&>path:last-child]:[stroke-dasharray:9] motion-safe:[&>path:last-child]:[stroke-dashoffset:9]"
            )}
          />
          {iconOnly ? null : successLabel}
        </>
      )
    }
    return (
      <>
        <IconAlertCircle />
        {iconOnly ? null : resolvedErrorLabel}
      </>
    )
  }

  const button = (
    <ButtonPrimitive
      data-slot="button"
      data-shape={shape ?? "default"}
      data-status={enabled ? status : undefined}
      aria-busy={isLoading || undefined}
      disabled={disabled || isLoading}
      focusableWhenDisabled={focusableWhenDisabled ?? (isLoading || undefined)}
      className={cn(
        buttonVariants({ variant: resolvedVariant, size, shape }),
        quiet && quietStatusClasses[status],
        className
      )}
      onClick={handleClick}
      onPointerEnter={composeHandlers(
        onPointerEnter,
        internal.buttonProps.onPointerEnter
      )}
      onPointerLeave={composeHandlers(
        onPointerLeave,
        internal.buttonProps.onPointerLeave
      )}
      onFocus={composeHandlers(onFocus, internal.buttonProps.onFocus)}
      onBlur={composeHandlers(onBlur, internal.buttonProps.onBlur)}
      {...props}
    >
      {enabled ? (
        <span
          ref={contentRef}
          data-slot="button-content"
          className="relative inline-grid grid-cols-[minmax(0,1fr)] place-items-center gap-[inherit] overflow-x-clip *:col-start-1 *:row-start-1 motion-safe:data-measured:transition-[width] motion-safe:data-measured:duration-220 motion-safe:data-measured:ease-[cubic-bezier(0.2,0,0,1)]"
        >
          {statuses.map((layer) => {
            const active = layer === status
            const exiting = layer === leaving
            const namesButton =
              layer === "idle" && isLoading && loadingLabel === undefined

            return (
              <span
                key={active ? `${layer}-${transitionKey}` : layer}
                ref={layerRef}
                data-slot="button-layer"
                data-layer={layer}
                aria-hidden={active || namesButton ? undefined : true}
                className={cn(
                  "inline-flex items-center justify-center gap-[inherit] backface-hidden",
                  !active &&
                    !exiting &&
                    "pointer-events-none absolute opacity-0",
                  active &&
                    transitionKey > 0 &&
                    "origin-bottom motion-safe:animate-button-state-in motion-reduce:animate-in motion-reduce:animation-duration-150 motion-reduce:fade-in-0",
                  exiting &&
                    "pointer-events-none origin-top opacity-0 motion-safe:animate-button-state-out"
                )}
              >
                {layerContent(layer, active || exiting)}
              </span>
            )
          })}
        </span>
      ) : (
        children
      )}
    </ButtonPrimitive>
  )

  if (!enabled) {
    return button
  }

  const announcement =
    status === "loading"
      ? typeof loadingLabel === "string"
        ? loadingLabel
        : "Loading"
      : status === "success"
        ? typeof successLabel === "string"
          ? successLabel
          : "Done"
        : status === "error"
          ? typeof resolvedErrorLabel === "string"
            ? resolvedErrorLabel
            : "Failed"
          : ""

  return (
    <>
      {button}
      <span data-slot="button-status" role="status" className="sr-only">
        {announcement}
      </span>
    </>
  )
}

export { Button, buttonVariants, useButtonFeedback }
export type { ButtonProps, ButtonStatus, ButtonFeedbackOptions }
```

Update the import paths to match your project setup.

## Usage

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.

```css
@layer ui-sdk;

@import "tailwindcss";
```

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

```tsx
<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.

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

const file = await cropImage(src, crop, { width: 512 })
```

## Composition

```text
ImageCropper
├── ImageCropperViewport
│   ├── ImageCropperImage
│   └── ImageCropperSelection
│       ├── ImageCropperGrid
│       └── ImageCropperHandles
├── ImageCropperZoom
├── ImageCropperRotate
└── ImageCropperFlip
```

## Examples

### Avatar

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.

```tsx title="components/examples/ui-sdk-image-cropper/avatar.tsx"
"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 }
```

### Rotate and flip

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

```tsx title="components/examples/ui-sdk-image-cropper/rotate-flip.tsx"
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 }
```

### Upload

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

```tsx title="components/examples/ui-sdk-image-cropper/upload.tsx"
"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 }
```

### Controller

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

```tsx title="components/examples/ui-sdk-image-cropper/controlled.tsx"
"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 }
```

### Disabled

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

```tsx title="components/examples/ui-sdk-image-cropper/disabled.tsx"
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 }
```

## Keyboard

| Key | Action |
| --- | --- |
| `←` `→` `↑` `↓` | 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. |
| `R` | Turns the image a quarter clockwise, or back with Shift. |
| `Esc` | During a drag, puts the crop back where the drag started. |

## Accessibility

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.

## API reference

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.

### ImageCropper

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `src` | `string` | – |  |
| `crop` | `ImageCrop` | – | The crop in image pixels, with rotation, flipX and flipY. |
| `defaultCrop` | `ImageCrop` | – |  |
| `onCropChange` | `(crop, details) => void` | – |  |
| `onCropCommit` | `(crop, details) => void` | – | Runs once a drag, key press or wheel ends. |
| `mode` | `"box" \| "frame"` | `"box"` |  |
| `aspectRatio` | `number` | – |  |
| `shape` | `"rectangle" \| "circle"` | `"rectangle"` |  |
| `minWidth` | `number` | – |  |
| `minHeight` | `number` | – |  |
| `maxZoom` | `number` | `10` |  |
| `disabled` | `boolean` | `false` |  |
| `controller` | `ImageCropperController` | – | From createImageCropperController(). |

| Attribute | Description |
| --- | --- |
| `data-slot="image-cropper"` | Target the cropper in CSS. |
| `data-mode` | box or frame. |
| `data-shape` | rectangle or circle. |
| `data-loading` | Present until the image loads. |
| `data-error` | Present when the image failed to load. |
| `data-dragging` | Present while a pointer drags. |

### ImageCropperViewport

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| (props, state) => ReactElement` | – |  |

| Attribute | Description |
| --- | --- |
| `data-slot="image-cropper-viewport"` | Target the viewport in CSS. |
| `--image-cropper-aspect-ratio` | The image’s width over its height once it loads. The viewport takes it as its aspect ratio. |

### ImageCropperImage

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `alt` | `string` | – | Required. |
| `render` | `ReactElement \| (props, state) => ReactElement` | – |  |

### ImageCropperSelection

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `aria-label` | `string` | `"Crop"` |  |
| `render` | `ReactElement \| (props, state) => ReactElement` | – |  |

| Attribute | Description |
| --- | --- |
| `data-slot="image-cropper-selection"` | Target the selection in CSS. |

### ImageCropperGrid

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` | – | Defaults to lines in thirds. |

### ImageCropperHandle

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `position` | `"n" \| "e" \| "s" \| "w" \| "ne" \| "nw" \| "se" \| "sw"` | – | The edge or corner it resizes. |

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

### ImageCropperZoom

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

### ImageCropperRotate

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` | – | Defaults to an icon with the label Rotate. With children it’s a small text button. |

### ImageCropperFlip

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `direction` | `"horizontal" \| "vertical"` | `"horizontal"` |  |
| `children` | `ReactNode` | – | Defaults to an icon with an English label. |

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt

## Related

- [Button](https://hextaui.com/docs/button) - Buttons in every variant and size, with a built-in loading, success and error flow that skips the spinner for fast requests.
- [Video player](https://hextaui.com/docs/video-player) - A video player with a scrubbable seek bar, auto-hiding controls, keyboard shortcuts, speed, picture in picture and full screen.
