Image uploads

Preview, crop, rotate, resize and compress images in the browser.

"use client"

import { ImageUpload } from "@/components/image-upload"

export default function ImageCropExample() {
  return (
    <ImageUpload
      crop={{ aspect: 16 / 9 }}
      compress
      maxWidth={1920}
      maxFiles={4}
    />
  )
}

All processing uses native browser APIs (createImageBitmap and canvas) from @uploadcn/core/image, no image library is shipped.

Compress before upload

Plug imageTransform into any uploader. Non-images, GIFs and SVGs pass through untouched.

import { imageTransform } from "@uploadcn/core/image"

<Upload adapter={adapter} accept="image/*" transform={imageTransform({ maxWidth: 2000, quality: 0.82, type: "image/webp" })} />

compressImage returns the original when re-encoding wouldn't make it smaller.

Crop

ImageUpload crop and AvatarUpload open the Image Cropper inside the upload pipeline. To crop programmatically:

import { transformImage } from "@uploadcn/core/image"

const output = await transformImage(file, {
  crop: { x: 120, y: 40, width: 800, height: 800 },
  rotate: 90,
  flipHorizontal: false,
  maxWidth: 512,
  type: "image/webp",
  quality: 0.9,
})

EXIF and privacy

  • Orientation from EXIF is applied before any other operation, so phone photos are never sideways.
  • Re-encoding strips metadata, including GPS location: which is usually what you want for user uploads. Upload the original file if you need to keep it.

Validation

<Upload accept="image/*" image={{ minWidth: 400, minHeight: 400, maxWidth: 8000 }} />

Previews

UploadPreview and useFilePreview(file, { thumbnailSize }) downscale large images once for display, so a gallery of 40 photos doesn't decode 40 full-resolution images.

"use client"

import * as React from "react"
import { formatBytes } from "@uploadcn/core"
import { useFilePreview, useImageCompression } from "@uploadcn/react"

import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"

export default function ImageCompressionExample() {
  const [quality, setQuality] = React.useState(0.75)
  const [original, setOriginal] = React.useState<File | null>(null)
  const [result, setResult] = React.useState<File | null>(null)
  const { compress, isCompressing, stats } = useImageCompression({
    maxWidth: 1600,
    maxHeight: 1600,
    quality,
    type: "image/webp",
  })
  const preview = useFilePreview(result)
  const inputRef = React.useRef<HTMLInputElement>(null)

  async function run(file: File) {
    setOriginal(file)
    setResult(await compress(file))
  }

  return (
    <div className="flex flex-col gap-4">
      <div className="flex flex-wrap items-center gap-4">
        <Button variant="outline" onClick={() => inputRef.current?.click()}>
          Choose an image
        </Button>
        <input
          ref={inputRef}
          type="file"
          accept="image/*"
          className="sr-only"
          tabIndex={-1}
          aria-hidden
          onChange={(event) => {
            const file = event.target.files?.[0]
            if (file) void run(file)
            event.target.value = ""
          }}
        />
        <div className="flex min-w-48 flex-1 items-center gap-3 text-sm">
          <span className="text-muted-foreground">Quality</span>
          <input
            type="range"
            aria-label="Quality"
            min={0.3}
            max={1}
            step={0.05}
            value={quality}
            onChange={(event) => setQuality(Number(event.target.value))}
            className="h-1.5 w-full cursor-pointer accent-primary"
          />
          <span className="w-10 tabular-nums">
            {Math.round(quality * 100)}%
          </span>
        </div>
        {original ? (
          <Button
            size="sm"
            variant="secondary"
            disabled={isCompressing}
            onClick={() => void run(original)}
          >
            Recompress
          </Button>
        ) : null}
      </div>
      <div className="relative flex min-h-48 items-center justify-center overflow-hidden rounded-xl border bg-muted/40">
        {isCompressing ? <Spinner /> : null}
        {!isCompressing && preview ? (
          // eslint-disable-next-line @next/next/no-img-element -- local object URL
          <img
            src={preview}
            alt="Compressed result"
            className="max-h-80 object-contain"
          />
        ) : null}
        {!isCompressing && !preview ? (
          <p className="text-sm text-muted-foreground">
            Images are resized to 1600px and encoded as WebP in your browser.
          </p>
        ) : null}
      </div>
      {stats ? (
        <p className="text-sm tabular-nums" aria-live="polite">
          {formatBytes(stats.before)} →{" "}
          <strong>{formatBytes(stats.after)}</strong>
          <span className="text-muted-foreground">
            {" "}
            · {Math.round(stats.saved * 100)}% smaller
          </span>
        </p>
      ) : null}
    </div>
  )
}

On this page