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>
)
}