An image grid with optional cropping and client-side compression.
"use client"
import { ImageUpload } from "@/components/image-upload"
export default function ImageUploadExample() {
return <ImageUpload compress maxWidth={2000} maxFiles={8} />
}
Installation
npx shadcn@latest add @uploadcn/image-uploadUsage
import { ImageUpload } from "@/components/image-upload"<ImageUpload crop compress maxWidth={2000} />Cropping happens inside the upload pipeline: each image waits in the validating state while its crop dialog is open, and several images are cropped one after another. Skipping a crop uploads the original.
Props
Prop
Type
Examples
Image crop
"use client"
import { ImageUpload } from "@/components/image-upload"
export default function ImageCropExample() {
return (
<ImageUpload
crop={{ aspect: 16 / 9 }}
compress
maxWidth={1920}
maxFiles={4}
/>
)
}
Image compression
"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>
)
}