# Image Upload

> An image grid with optional cropping and client-side compression.

A shadcn/ui block from the [@uploadcn](https://uploadcn.dev) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uploadcn/image-upload).

- Page, with a live preview: https://shadcn.exchange/r/uploadcn/image-upload
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uploadcn/image-upload-light-99ab2cd48ad4e859.png
- Registry item: https://uploadcn.dev/r/new-york-v4/image-upload.json
- Categories: upload, image
- Listed: 2026-10-14, source last changed 2026-10-14

## Install

```bash
npx shadcn@latest add https://uploadcn.dev/r/new-york-v4/image-upload.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@uploadcn/core`
- Registry items: `@uploadcn/image-cropper`, `@uploadcn/upload`, `@uploadcn/use-crop-queue`

## Files

### `registry/default/blocks/image-upload.tsx`

```tsx
"use client"

import * as React from "react"
import {
  type UploadAdapter,
  type UploadItem as UploadItemData,
} from "@uploadcn/core"
import { compressImage } from "@uploadcn/core/image"

import { IconPlaceholder } from "@/components/icon-placeholder"
import { useCropQueue } from "@/registry/default/hooks/use-crop-queue"
import { ImageCropper } from "@/registry/default/ui/image-cropper"
import {
  Upload,
  UploadDropzone,
  UploadDropzoneDescription,
  UploadDropzoneHeader,
  UploadDropzoneMedia,
  UploadDropzoneTitle,
  UploadItem,
  UploadItemActions,
  UploadItemContent,
  UploadItemMedia,
  UploadItemProgress,
  UploadItemStatus,
  UploadList,
} from "@/registry/default/ui/upload"

interface ImageUploadProps<TResult> {
  adapter?: UploadAdapter<TResult>
  /** Open a crop dialog for every image. Pass `{ aspect }` to fix the ratio. */
  crop?: boolean | { aspect?: number }
  /** Resize and re-encode images in the browser before uploading. */
  compress?: boolean
  /** Longest edge after compression or cropping. Default 2048. */
  maxWidth?: number
  maxHeight?: number
  /** Encoder quality, 0–1. Default 0.82. */
  quality?: number
  /** Output format. Defaults to WebP when compressing. */
  type?: "image/webp" | "image/jpeg" | "image/avif" | "image/png"
  maxFiles?: number
  /** Maximum size of the original file. Default 20 MB. */
  maxSize?: number
  /** Minimum dimensions of the original image. */
  minWidth?: number
  minHeight?: number
  onSuccess?: (item: UploadItemData<TResult>) => void
  className?: string
}

/** An image grid with optional cropping and in-browser compression. */
function ImageUpload<TResult = unknown>({
  adapter,
  crop = false,
  compress = false,
  maxWidth = 2048,
  maxHeight = 2048,
  quality = 0.82,
  type,
  maxFiles = 12,
  maxSize = 20 * 1000 * 1000,
  minWidth,
  minHeight,
  onSuccess,
  className,
}: ImageUploadProps<TResult>) {
  const crops = useCropQueue()
  const { request } = crops
  const output = React.useMemo(
    () => ({
      maxWidth,
      maxHeight,
      quality,
      type: type ?? (compress ? ("image/webp" as const) : undefined),
    }),
    [maxWidth, maxHeight, quality, type, compress]
  )

  const transform = React.useCallback(
    async (file: File) => {
      let result = crop ? await request(file) : file
      if (compress) result = await compressImage(result, output)
      return result
    },
    [crop, compress, request, output]
  )

  return (
    <Upload
      adapter={adapter}
      accept="image/*"
      maxFiles={maxFiles}
      maxSize={maxSize}
      image={minWidth || minHeight ? { minWidth, minHeight } : undefined}
      transform={crop || compress ? transform : undefined}
      onSuccess={onSuccess}
      className={className}
    >
      <UploadDropzone size="sm">
        <UploadDropzoneHeader>
          <UploadDropzoneMedia variant="icon">
            <IconPlaceholder
              lucide="ImagePlusIcon"
              tabler="IconPhotoPlus"
              hugeicons="ImageAdd01Icon"
              phosphor="ImageIcon"
              remixicon="RiImageAddLine"
            />
          </UploadDropzoneMedia>
          <UploadDropzoneTitle>Add images</UploadDropzoneTitle>
          <UploadDropzoneDescription>
            PNG, JPG, WebP or AVIF
            {crop ? " · you'll crop each one" : ""}
          </UploadDropzoneDescription>
        </UploadDropzoneHeader>
      </UploadDropzone>
      <UploadList variant="grid">
        <UploadItem variant="tile">
          <UploadItemMedia variant="cover" />
          <UploadItemActions />
          <UploadItemContent>
            <UploadItemStatus />
            <UploadItemProgress />
          </UploadItemContent>
        </UploadItem>
      </UploadList>
      <ImageCropper
        file={crops.current}
        aspect={typeof crop === "object" ? crop.aspect : undefined}
        output={output}
        onCrop={crops.complete}
        onCancel={crops.skip}
        description={
          crops.pending > 1
            ? `${crops.pending - 1} more image(s) waiting.`
            : undefined
        }
      />
    </Upload>
  )
}

export { ImageUpload, type ImageUploadProps }
```
