Dropzone
A headless drag-and-drop zone with upload state management, validation, and retry logic.
This component handles drag-and-drop mechanics, native file input synchronization, validation, and upload state management. It does not render the file list itself. Use FileCard — a small compound component for a single file row — mapped over fileStatuses to build your queue, or bring your own components entirely.
Dependencies
react-dropzone— Powers the underlying drag-and-drop state machine and file validation.lucide-react— For standard icons.
Installation
npx shadcn@latest add @shelf-ui/dropzonepnpm dlx shadcn@latest add @shelf-ui/dropzoneyarn dlx shadcn@latest add @shelf-ui/dropzonebunx shadcn@latest add @shelf-ui/dropzoneUsage
The Dropzone exports a headless hook and compound components. You control the drag area and the file display.
"use client";import { CloudUpload, FileText, Sparkles } from "lucide-react";import { DropZoneArea, Dropzone, DropzoneDescription, DropzoneMessage, DropzoneTrigger, useDropzone,} from "@/components/shelf-ui/dropzone";import { FileCard, FileCardActions, FileCardInfo, FileCardPreview, FileCardProgress,} from "@/components/shelf-ui/file-card";import { Badge } from "@/components/ui/badge";import { Button } from "@/components/ui/button";async function fakeUpload(file: File) { await new Promise((resolve) => setTimeout(resolve, 1400)); if (Math.random() < 0.12) { return { status: "error" as const, error: "Upload failed. Try again." }; } return { status: "success" as const, result: file.name };}export function DropzoneBasicDemo() { const dropzone = useDropzone({ onDropFile: fakeUpload, validation: { maxSize: 5 * 1024 * 1024, maxFiles: 5, accept: { "image/*": [], "application/pdf": [".pdf"] }, }, }); const hasFiles = dropzone.fileStatuses.length > 0; const successCount = dropzone.fileStatuses.filter( (f) => f.status === "success" ).length; return ( <div className="w-full max-w-md space-y-4"> <Dropzone {...dropzone}> <DropZoneArea className="group relative overflow-hidden transition-all duration-300 hover:border-primary/50"> <DropzoneTrigger className="flex h-full w-full flex-col items-center justify-center gap-4 px-6 py-12 text-center"> {/* Icon with animation */} <div className="relative flex size-16 items-center justify-center rounded-2xl border-2 border-muted-foreground/30 border-dashed bg-muted/50 shadow-sm transition-all duration-300 group-hover:-translate-y-1 group-hover:border-primary/50 group-hover:bg-primary/5 group-hover:shadow-md"> <CloudUpload className="size-7 text-muted-foreground transition-all duration-300 group-hover:scale-110 group-hover:text-primary" /> <Sparkles className="absolute -top-1 -right-1 size-4 text-primary opacity-0 transition-all duration-300 group-hover:opacity-100" /> </div> {/* Text content */} <div className="relative space-y-2"> <p className="font-semibold text-sm leading-none"> Drop files here or{" "} <span className="text-primary underline-offset-4 transition-all hover:underline"> browse </span> </p> <DropzoneDescription className="text-xs"> Images & PDFs · Up to 5 MB each </DropzoneDescription> </div> </DropzoneTrigger> </DropZoneArea> <DropzoneMessage className="mt-2" /> </Dropzone> {hasFiles && ( <div className="fade-in slide-in-from-top-2 animate-in space-y-3 duration-300"> {/* Header with stats */} <div className="flex items-center justify-between rounded-lg border bg-muted/40 px-4 py-2.5"> <div className="flex items-center gap-2"> <p className="font-medium text-foreground text-sm"> Upload Queue </p> {successCount > 0 && ( <Badge className="fade-in zoom-in animate-in duration-200" variant="default" > {successCount} completed </Badge> )} </div> <Badge variant="secondary">{dropzone.fileStatuses.length}</Badge> </div> {/* File list */} <div className="space-y-2"> {dropzone.fileStatuses.map((status, index) => ( <div className="slide-in-from-top-2 fade-in animate-in duration-300" key={status.id} style={{ animationDelay: `${index * 50}ms` }} > <FileCard canRetry={dropzone.canRetry(status.id)} className="shadow-sm transition-shadow hover:shadow-md" fileStatus={status} onRemove={() => dropzone.onRemove(status.id)} onRetry={() => dropzone.onRetry(status.id)} > <FileCardPreview renderPreview={(file) => file.type.startsWith("image/") ? undefined : ( <div className="flex size-10 items-center justify-center rounded-lg bg-muted"> <FileText className="size-5 text-muted-foreground" /> </div> ) } showImageThumbnail /> <FileCardInfo /> <div className="flex flex-1 flex-col justify-center gap-1.5"> <FileCardProgress /> </div> <FileCardActions /> </FileCard> </div> ))} </div> {/* Clear all button */} {dropzone.fileStatuses.length > 1 && ( <Button className="w-full" onClick={() => dropzone.clearAll()} size="sm" variant="outline" > Clear All </Button> )} </div> )} </div> );}Connecting to your API
The dropzone does not upload files on its own; it manages the state around your API call. The production setup usually looks like this, where onDropFile handles your upload API, and you handle deletions in your UI by calling your API before calling dropzone.onRemove(id):
function UploadComponent() {
const dropzone = useDropzone({
// 1. Upload to your server
onDropFile: async (file) => {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
});
if (response.ok) {
const data = await response.json();
return { status: "success", result: data.url };
}
return { status: "error", error: "Upload failed" };
},
validation: {
maxFiles: 10,
maxSize: 10 * 1024 * 1024,
},
});
// 2. Delete from your server (optional)
const handleRemove = async (id: string, fileUrl: string) => {
// Fire API call to delete from server
await fetch("/api/upload", {
method: "DELETE",
body: JSON.stringify({ url: fileUrl }),
});
// Remove from UI
dropzone.onRemove(id);
};
return (
<Dropzone {...dropzone}>
<DropZoneArea>
<DropzoneTrigger>Upload files</DropzoneTrigger>
</DropZoneArea>
{dropzone.fileStatuses.map((status) => (
<FileCard
key={status.id}
fileStatus={status}
onRemove={() => handleRemove(status.id, status.result)}
// dropzone.onRetry already automatically calls your onDropFile API again!
onRetry={() => dropzone.onRetry(status.id)}
/>
))}
</Dropzone>
);
}Form Integration (React Hook Form)
Each file is uploaded immediately on drop. onFileUploaded fires per file with its result — use it to push the returned URL (or ID) into your form state. The form itself submits normally via handleSubmit.
import { useForm } from "react-hook-form";
type FormValues = {
title: string;
attachments: string[]; // uploaded file URLs returned by your server
};
function FormWithUpload() {
const form = useForm<FormValues>({
defaultValues: { title: "", attachments: [] },
});
const dropzone = useDropzone({
onDropFile: uploadToServer, // your fetch function
// Called once per file after a successful upload.
// Append the returned URL to the "attachments" field in the form.
onFileUploaded: (url) => {
const current = form.getValues("attachments") ?? [];
form.setValue("attachments", [...current, url]);
},
});
function onSubmit(values: FormValues) {
// values.attachments contains the uploaded file URLs
console.log(values);
}
return (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<input
{...form.register("title", { required: true })}
placeholder="Title"
className="w-full rounded-md border px-3 py-2"
/>
<Dropzone {...dropzone}>
<DropZoneArea>
<DropzoneTrigger>Upload attachments</DropzoneTrigger>
<DropzoneDescription>PNG, JPG, PDF up to 10MB</DropzoneDescription>
</DropZoneArea>
<DropzoneMessage />
</Dropzone>
<button type="submit" className="rounded-md bg-primary px-4 py-2 text-primary-foreground">
Submit
</button>
</form>
);
}Layout Examples
Single File Upload (Avatar / Profile Picture)
Use shiftOnMaxFiles: true to automatically replace the previous file instead of showing an error. When the new file would exceed maxFiles, the oldest file is dropped first — this happens atomically, so it's safe even if the user drops files in rapid succession.
Profile Photo
JPG, PNG or WebP · Max 2 MB
"use client";import { Camera, Check, Loader2, User, X } from "lucide-react";import { DropZoneArea, Dropzone, DropzoneMessage, DropzoneTrigger, useDropzone,} from "@/components/shelf-ui/dropzone";import { useFileObjectUrl } from "@/components/shelf-ui/file-card";import { Button } from "@/components/ui/button";async function fakeUpload(file: File) { await new Promise((resolve) => setTimeout(resolve, 1200)); return { status: "success" as const, result: file.name };}export function DropzoneAvatarDemo() { const dropzone = useDropzone({ onDropFile: fakeUpload, validation: { maxFiles: 1, maxSize: 2 * 1024 * 1024, accept: { "image/*": [] }, }, shiftOnMaxFiles: true, }); const current = dropzone.fileStatuses[0]; const isPending = current?.status === "pending"; const previewUrl = useFileObjectUrl(current?.file); return ( <div className="flex flex-col items-center gap-6"> <Dropzone {...dropzone}> <DropZoneArea className="rounded-full border-0 bg-transparent p-0 ring-0"> <DropzoneTrigger aria-label={ previewUrl ? "Change profile photo" : "Upload profile photo" } className="group relative size-32 place-items-center overflow-hidden rounded-full border-0 bg-transparent p-0 ring-2 ring-border ring-offset-2 ring-offset-background transition-all hover:ring-primary focus-visible:ring-primary focus-visible:ring-offset-2" > <div className="absolute inset-0 overflow-hidden rounded-full bg-linear-to-br from-muted to-muted/80"> {previewUrl ? ( <div aria-hidden="true" className="absolute inset-0 bg-center bg-cover transition-transform duration-500 group-hover:scale-110" style={{ backgroundImage: `url("${previewUrl}")` }} /> ) : ( <div className="flex size-full items-center justify-center"> <User className="size-12 text-muted-foreground/40" strokeWidth={1.5} /> </div> )} {isPending && ( <div className="absolute inset-0 flex items-center justify-center rounded-full bg-background/80 backdrop-blur-sm"> <Loader2 className="size-8 animate-spin text-primary" /> </div> )} </div> <div className="absolute inset-0 flex flex-col items-center justify-center gap-1 rounded-full bg-gradient-to-t from-black/70 via-black/40 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100 group-focus-visible:opacity-100"> <div className="flex size-10 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm"> <Camera className="size-5 text-white" /> </div> <span className="font-medium text-[11px] text-white/95"> {previewUrl ? "Change" : "Upload"} </span> </div> </DropzoneTrigger> </DropZoneArea> <DropzoneMessage className="mt-3 text-center" /> </Dropzone> <div className="flex flex-col items-center gap-3 text-center"> <div> <p className="font-semibold text-sm">Profile Photo</p> <p className="text-muted-foreground text-xs"> JPG, PNG or WebP · Max 2 MB </p> </div> {previewUrl && ( <div className="fade-in slide-in-from-bottom-2 flex animate-in gap-2 duration-300"> <Button onClick={() => dropzone.clearAll()} size="sm" variant="outline" > <X className="mr-1.5 size-3.5" /> Remove </Button> <Button size="sm" variant="default"> <Check className="mr-1.5 size-3.5" /> Save </Button> </div> )} </div> </div> );}Multiple File Upload
Use validation.maxFiles to cap the queue, map fileStatuses to FileCard, and use clearAll / retryFailed for batch-level controls. This demo also shows the practical error path: name a file with fail in it to see retry UI.
"use client";import { Files, RotateCcw, Trash2, Upload } from "lucide-react";import { DropZoneArea, Dropzone, DropzoneDescription, DropzoneMessage, DropzoneTrigger, useDropzone,} from "@/components/shelf-ui/dropzone";import { FileCard, FileCardActions, FileCardInfo, FileCardPreview, FileCardProgress,} from "@/components/shelf-ui/file-card";import { Badge } from "@/components/ui/badge";import { Button } from "@/components/ui/button";async function uploadDocument(file: File) { await new Promise((resolve) => setTimeout(resolve, 900 + Math.random() * 700) ); if (file.name.toLowerCase().includes("fail")) { return { status: "error" as const, error: "Server rejected this file. Rename it and retry.", }; } return { status: "success" as const, result: undefined, };}export function DropzoneMultipleFilesDemo() { const dropzone = useDropzone({ onDropFile: uploadDocument, validation: { maxFiles: 8, maxSize: 10 * 1024 * 1024, accept: { "application/pdf": [".pdf"], "image/*": [], "text/plain": [".txt"], }, }, }); const total = dropzone.fileStatuses.length; const completed = dropzone.fileStatuses.filter( (file) => file.status === "success" ).length; const failed = dropzone.fileStatuses.filter( (file) => file.status === "error" ).length; return ( <div className="flex w-full max-w-md flex-col gap-4"> <Dropzone {...dropzone}> <DropZoneArea className="group overflow-hidden rounded-xl transition-all hover:border-primary/60"> <DropzoneTrigger className="flex w-full flex-col items-center justify-center gap-4 px-6 py-10 text-center"> <div className="flex size-12 items-center justify-center rounded-xl border bg-muted/60 shadow-sm transition-all group-hover:-translate-y-0.5 group-hover:bg-primary/5"> <Upload className="size-5 text-muted-foreground transition-colors group-hover:text-primary" /> </div> <div className="flex flex-col gap-1"> <p className="font-semibold text-sm">Upload a batch</p> <DropzoneDescription className="text-xs"> Images, PDFs, and text files up to 10 MB </DropzoneDescription> </div> </DropzoneTrigger> </DropZoneArea> <DropzoneMessage className="mt-2" /> </Dropzone> {total > 0 ? ( <div className="flex flex-col gap-3 rounded-xl border bg-muted/20 p-3"> <div className="flex items-center justify-between gap-3"> <div className="flex min-w-0 items-center gap-2"> <Files className="size-4 shrink-0 text-muted-foreground" /> <p className="truncate font-semibold text-sm">Upload queue</p> <Badge variant="secondary"> {completed}/{total} </Badge> {failed > 0 && ( <Badge variant="destructive">{failed} failed</Badge> )} </div> <div className="flex items-center gap-1"> {failed > 0 && ( <Button aria-label="Retry failed uploads" onClick={dropzone.retryFailed} size="icon-xs" type="button" variant="ghost" > <RotateCcw className="size-3" /> </Button> )} <Button aria-label="Clear all uploads" onClick={dropzone.clearAll} size="icon-xs" type="button" variant="ghost" > <Trash2 className="size-3" /> </Button> </div> </div> <div className="flex flex-col gap-2"> {dropzone.fileStatuses.map((status) => ( <FileCard canRetry={dropzone.canRetry(status.id)} fileStatus={status} key={status.id} onRemove={() => dropzone.onRemove(status.id)} onRetry={() => dropzone.onRetry(status.id)} > <div className="flex w-full items-start gap-3"> <FileCardPreview showImageThumbnail /> <div className="flex min-w-0 flex-1 flex-col gap-1.5"> <FileCardInfo /> <FileCardProgress /> </div> <FileCardActions /> </div> </FileCard> ))} </div> </div> ) : null} </div> );}Image Grid Preview
A gallery-style layout for image-heavy uploads, where thumbnails matter more than filenames or progress text.
"use client";import { CheckCircle2, ImageIcon, Loader2, Plus, Trash2, X,} from "lucide-react";import Image from "next/image";import { useEffect, useRef } from "react";import { DropZoneArea, Dropzone, DropzoneMessage, DropzoneTrigger, useDropzone,} from "@/components/shelf-ui/dropzone";import { Badge } from "@/components/ui/badge";import { Button } from "@/components/ui/button";import { cn } from "@/lib/utils";async function fakeUpload(file: File) { await new Promise((resolve) => setTimeout(resolve, 1000 + Math.random() * 600) ); return { status: "success" as const, result: file.name };}// Stable object URL per file — prevents creating a new URL on every renderfunction useStableObjectUrls( files: { id: string; file: File; status: string }[]) { const urlsRef = useRef<Map<string, string>>(new Map()); // Revoke URLs for removed files useEffect(() => { const currentIds = new Set(files.map((f) => f.id)); for (const [id, url] of urlsRef.current) { if (!currentIds.has(id)) { URL.revokeObjectURL(url); urlsRef.current.delete(id); } } }, [files]); // Revoke all on unmount useEffect( () => () => { for (const url of urlsRef.current.values()) { URL.revokeObjectURL(url); } }, [] ); return (file: File, id: string) => { if (!urlsRef.current.has(id)) { urlsRef.current.set(id, URL.createObjectURL(file)); } return urlsRef.current.get(id) ?? ""; };}export function DropzoneGridDemo() { const MAX = 6; const dropzone = useDropzone({ onDropFile: fakeUpload, validation: { maxFiles: MAX, maxSize: 5 * 1024 * 1024, accept: { "image/*": [] }, }, }); const getUrl = useStableObjectUrls(dropzone.fileStatuses); const count = dropzone.fileStatuses.length; const successCount = dropzone.fileStatuses.filter( (s) => s.status === "success" ).length; return ( <div className="w-full max-w-md space-y-4"> <Dropzone {...dropzone}> {/* Drop zone — only show when not at limit */} {count < MAX && ( <DropZoneArea className="group rounded-xl transition-all duration-200"> <DropzoneTrigger className="flex w-full flex-col items-center gap-4 py-8 text-center"> <div className="flex size-12 items-center justify-center rounded-xl border bg-muted/60 shadow-sm transition-all duration-200 group-hover:-translate-y-0.5 group-hover:bg-muted group-hover:shadow"> <ImageIcon className="size-5 text-muted-foreground transition-colors group-hover:text-foreground" /> </div> <div className="space-y-1"> <p className="font-semibold text-sm"> Upload images{" "} <span className="text-primary underline-offset-2 hover:underline"> or browse </span> </p> <p className="text-muted-foreground text-xs"> Up to {MAX} images · Max 5 MB each </p> </div> </DropzoneTrigger> </DropZoneArea> )} <DropzoneMessage className="mt-1" /> {count > 0 && ( <div className="space-y-3"> {/* Header */} <div className="flex items-center justify-between"> <div className="flex items-center gap-2"> <p className="font-semibold text-sm">Gallery</p> <Badge variant="secondary"> {successCount}/{count} uploaded </Badge> </div> <Button className="h-7 gap-1.5 text-xs" onClick={() => dropzone.clearAll()} size="sm" variant="ghost" > <Trash2 className="size-3" /> Clear all </Button> </div> {/* Grid */} <div className="grid grid-cols-3 gap-2"> {dropzone.fileStatuses.map((status) => { const url = getUrl(status.file, status.id); const isPending = status.status === "pending"; const isSuccess = status.status === "success"; return ( <div className="group relative aspect-square overflow-hidden rounded-xl border bg-muted shadow-sm" key={status.id} > <Image alt={status.file.name} className={cn( "m-0! object-cover transition-transform duration-300 group-hover:scale-105", isPending && "opacity-60 grayscale" )} fill sizes="(max-width: 768px) 33vw, 120px" src={url} /> {/* Hover overlay */} <div className="absolute inset-0 bg-black/0 transition-colors duration-200 group-hover:bg-black/25" /> {/* Status badges */} {isPending && ( <div className="absolute inset-0 flex items-center justify-center"> <div className="flex size-8 items-center justify-center rounded-full bg-background/80 shadow backdrop-blur-sm"> <Loader2 className="size-4 animate-spin text-primary" /> </div> </div> )} {isSuccess && ( <div className="absolute top-1.5 left-1.5 flex size-5 items-center justify-center rounded-full bg-background/80 opacity-0 shadow backdrop-blur-sm transition-opacity group-hover:opacity-100"> <CheckCircle2 className="size-3 text-primary" /> </div> )} {/* Remove */} <Button aria-label={`Remove ${status.file.name}`} className="absolute top-1.5 right-1.5 size-6 rounded-full bg-background/80 opacity-0 shadow backdrop-blur-sm transition-opacity group-hover:opacity-100" onClick={() => dropzone.onRemove(status.id)} size="icon-xs" type="button" variant="ghost" > <X className="size-3" /> </Button> </div> ); })} {count < MAX && ( <DropZoneArea className="aspect-square rounded-xl border-0 p-0"> <DropzoneTrigger className="group/add flex size-full cursor-pointer items-center justify-center rounded-xl border border-dashed bg-muted/30 p-0 transition-colors hover:border-primary/50 hover:bg-primary/5"> <Plus className="size-5 text-muted-foreground transition-colors group-hover/add:text-primary" /> </DropzoneTrigger> </DropZoneArea> )} {Array.from({ length: MAX }, (_, slotIndex) => slotIndex) .slice(count + (count < MAX ? 1 : 0)) .map((slot) => ( <div className="aspect-square rounded-xl border border-dashed bg-muted/20" key={`placeholder-${slot}`} /> ))} </div> </div> )} </Dropzone> </div> );}This gallery keeps a stable object URL per file and revokes URLs when files are removed. For row-based queues, FileCardPreview showImageThumbnail gives you the same cleanup automatically.
Error Handling & Retry
Custom Error Messages
There are two error surfaces:
- Root validation errors from
react-dropzone, such as invalid file type, too many files, or file size. These appear throughDropzoneMessageandrootError. - Upload errors from your
onDropFilehandler. Shape those withshapeUploadError, then render them inFileCardInfo.
Error-shaped queue
Try dropping a file with "quota" or "virus" in its name to see a shaped upload error.
"use client";import { AlertCircle, FileWarning, Upload } from "lucide-react";import { DropZoneArea, Dropzone, DropzoneDescription, DropzoneMessage, DropzoneTrigger, useDropzone,} from "@/components/shelf-ui/dropzone";import { FileCard, FileCardActions, FileCardInfo, FileCardPreview, FileCardProgress,} from "@/components/shelf-ui/file-card";import { Badge } from "@/components/ui/badge";interface UploadError { code: "virus_scan_failed" | "quota_exceeded" | "network"; detail?: string;}async function guardedUpload(file: File) { await new Promise((resolve) => setTimeout(resolve, 900)); if (file.name.toLowerCase().includes("quota")) { return { status: "error" as const, error: { code: "quota_exceeded" as const }, }; } if (file.name.toLowerCase().includes("virus")) { return { status: "error" as const, error: { code: "virus_scan_failed" as const, detail: "The scan service blocked this file.", }, }; } return { status: "success" as const, result: file.name };}function getUploadErrorMessage(error: UploadError) { if (error.code === "quota_exceeded") { return "Storage limit reached. Remove a file before uploading more."; } if (error.code === "virus_scan_failed") { return "Security scan failed. Choose a different file."; } return "Upload interrupted. Check your connection and retry.";}export function DropzoneCustomErrorsDemo() { const dropzone = useDropzone<string, UploadError>({ onDropFile: guardedUpload, shapeUploadError: getUploadErrorMessage, validation: { maxFiles: 4, maxSize: 2 * 1024 * 1024, accept: { "application/pdf": [".pdf"], "image/*": [], }, }, }); const failed = dropzone.fileStatuses.filter( (file) => file.status === "error" ).length; return ( <div className="flex w-full max-w-md flex-col gap-4"> <Dropzone {...dropzone}> <DropZoneArea className="group rounded-xl transition-all hover:border-primary/60 aria-invalid:border-destructive"> <DropzoneTrigger className="flex w-full flex-col items-center justify-center gap-4 px-6 py-10 text-center"> <div className="flex size-12 items-center justify-center rounded-xl border bg-muted/60 shadow-sm transition-all group-hover:bg-primary/5"> {dropzone.isInvalid ? ( <AlertCircle className="size-5 text-destructive" /> ) : ( <Upload className="size-5 text-muted-foreground transition-colors group-hover:text-primary" /> )} </div> <div className="flex flex-col gap-1"> <p className="font-semibold text-sm">Validated upload</p> <DropzoneDescription className="text-xs"> PDFs and images only, max 2 MB </DropzoneDescription> </div> </DropzoneTrigger> </DropZoneArea> <DropzoneMessage className="mt-2" /> </Dropzone> <div className="rounded-xl border bg-muted/20 p-3"> <div className="mb-3 flex items-center justify-between gap-3"> <div className="flex items-center gap-2"> <FileWarning className="size-4 text-muted-foreground" /> <p className="font-semibold text-sm">Error-shaped queue</p> </div> {failed > 0 && <Badge variant="destructive">{failed} failed</Badge>} </div> {dropzone.fileStatuses.length > 0 ? ( <div className="flex flex-col gap-2"> {dropzone.fileStatuses.map((status) => ( <FileCard canRetry={dropzone.canRetry(status.id)} fileStatus={status} key={status.id} onRemove={() => dropzone.onRemove(status.id)} onRetry={() => dropzone.onRetry(status.id)} > <FileCardPreview showImageThumbnail /> <div className="flex min-w-0 flex-1 flex-col gap-1.5"> <FileCardInfo /> <FileCardProgress /> </div> <FileCardActions /> </FileCard> ))} </div> ) : ( <p className="text-muted-foreground text-sm"> Try dropping a file with "quota" or "virus" in its name to see a shaped upload error. </p> )} </div> </div> );}const dropzone = useDropzone({
onDropFile: uploadToServer,
validation: { maxSize: 5 * 1024 * 1024 },
shapeUploadError: (error) => {
if (error.code === "file-too-large") {
return "This file is too large. Please choose a file under 5MB.";
}
if (error.code === "file-invalid-type") {
return "This file type is not supported.";
}
return "Something went wrong. Please try again.";
},
});Automatic Retry
Failed uploads retry automatically with a back-off delay. Each retry waits 1000ms × attemptNumber. Retries are automatically cancelled if the component unmounts before they fire, and removed files are not retried.
"use client";import { RotateCcw, ShieldCheck, UploadCloud } from "lucide-react";import { useRef } from "react";import { DropZoneArea, Dropzone, DropzoneDescription, DropzoneMessage, DropzoneTrigger, useDropzone,} from "@/components/shelf-ui/dropzone";import { FileCard, FileCardActions, FileCardInfo, FileCardPreview, FileCardProgress,} from "@/components/shelf-ui/file-card";import { Badge } from "@/components/ui/badge";import { Button } from "@/components/ui/button";export function DropzoneAutoRetryDemo() { const attemptsRef = useRef(new Map<string, number>()); const dropzone = useDropzone({ autoRetry: true, maxRetryCount: 3, onDropFile: async (file) => { await new Promise((resolve) => setTimeout(resolve, 700)); const attempts = (attemptsRef.current.get(file.name) ?? 0) + 1; attemptsRef.current.set(file.name, attempts); if (attempts < 3) { return { status: "error" as const, error: `Temporary network error. Attempt ${attempts} of 3.`, }; } return { status: "success" as const, result: file.name }; }, validation: { maxFiles: 3, maxSize: 5 * 1024 * 1024, }, }); const pending = dropzone.fileStatuses.filter( (file) => file.status === "pending" ).length; const failed = dropzone.fileStatuses.filter( (file) => file.status === "error" ).length; const completed = dropzone.fileStatuses.filter( (file) => file.status === "success" ).length; return ( <div className="flex w-full max-w-md flex-col gap-4"> <Dropzone {...dropzone}> <DropZoneArea className="group rounded-xl transition-all hover:border-primary/60"> <DropzoneTrigger className="flex w-full flex-col items-center justify-center gap-4 px-6 py-10 text-center"> <div className="flex size-12 items-center justify-center rounded-xl border bg-muted/60 shadow-sm transition-all group-hover:-translate-y-0.5 group-hover:bg-primary/5"> <UploadCloud className="size-5 text-muted-foreground transition-colors group-hover:text-primary" /> </div> <div className="flex flex-col gap-1"> <p className="font-semibold text-sm">Auto-retry upload</p> <DropzoneDescription className="text-xs"> The first two attempts fail, then the retry succeeds </DropzoneDescription> </div> </DropzoneTrigger> </DropZoneArea> <DropzoneMessage className="mt-2" /> </Dropzone> {dropzone.fileStatuses.length > 0 ? ( <div className="flex flex-col gap-3 rounded-xl border bg-muted/20 p-3"> <div className="flex items-center justify-between gap-3"> <div className="flex items-center gap-2"> <ShieldCheck className="size-4 text-muted-foreground" /> <p className="font-semibold text-sm">Retry state</p> </div> <div className="flex items-center gap-1"> {pending > 0 && ( <Badge variant="secondary">{pending} pending</Badge> )} {failed > 0 && ( <Badge variant="destructive">{failed} failed</Badge> )} {completed > 0 && <Badge>{completed} done</Badge>} </div> </div> <div className="flex flex-col gap-2"> {dropzone.fileStatuses.map((status) => ( <FileCard canRetry={dropzone.canRetry(status.id)} fileStatus={status} key={status.id} onRemove={() => dropzone.onRemove(status.id)} onRetry={() => dropzone.onRetry(status.id)} > <FileCardPreview /> <div className="flex min-w-0 flex-1 flex-col gap-1.5"> <FileCardInfo /> <FileCardProgress /> <p className="text-muted-foreground text-xs"> Attempts: {status.tries} </p> </div> <FileCardActions /> </FileCard> ))} </div> {failed > 0 ? ( <Button onClick={dropzone.retryFailed} size="sm" type="button" variant="outline" > <RotateCcw className="size-3.5" /> Retry failed now </Button> ) : null} </div> ) : null} </div> );}const dropzone = useDropzone({
onDropFile: uploadToServer,
autoRetry: true,
maxRetryCount: 3,
onFileUploaded: (result, fileId) => {
console.log(`File ${fileId} uploaded:`, result);
},
onAllUploaded: () => {
console.log("All files uploaded!");
},
});Advanced: Composition & Drag Element
The Dropzone follows a compound component pattern. File list rendering is entirely delegated to you.
Dropzone (Provider)
├── DropzoneMessage (root-level validation errors)
├── DropZoneArea (drag target)
│ └── DropzoneTrigger (clickable trigger + hidden file input)
│ └── DropzoneDescription (helper text)How the pieces connect:
useDropzone()runs your upload logic and manages all state.<Dropzone {...dropzone}>is a context provider — it makes that state available to every subcomponent below it.<DropZoneArea>listens for drag events (onDragEnter,onDragLeave,onDrop) and applies the active/invalid styles automatically.<DropzoneTrigger>renders a hidden<input type="file">alongside a<button>. Clicking the button opens the file picker, even when the trigger is used without aDropZoneArea.<DropzoneMessage>readsrootErrorfrom context and renders it. Returnsnullwhen there is no error.<DropzoneDescription>is a plain<p>for hint text (accepted formats, size limits, etc.).
Customising the Drag Element
DropZoneArea renders a <div> that owns all the drag event handlers. Any elements you put inside it are regular children — you can nest whatever you like without any special props:
// Just nest it — no special props needed
<DropZoneArea>
<section>
<DropzoneTrigger>Click or drag to upload</DropzoneTrigger>
</section>
</DropZoneArea>When to use asChild or render
asChild and the render prop exist for one specific situation: you need to eliminate the wrapper <div> entirely so that your element directly receives the drag handlers.
If neither applies, stick with the default.
Both asChild and render are provided as conveniences for complex layouts. Use whichever pattern matches your project's style.
API Reference
useDropzone
The core hook that manages state, validation, and upload lifecycle.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
onDropFile | (file: File) => Promise<{ status: "success"; result: TResult } | { status: "error"; error: TError }> | Yes | — | Async upload handler. Return { status: "success", result } on success or { status: "error", error } on failure. |
validation | { accept?, minSize?, maxSize?, maxFiles? } | No | {} | Rules passed to react-dropzone. accept maps MIME types to extension arrays. |
autoRetry | boolean | No | false | Automatically retry failed uploads with back-off. Retries are cancelled automatically on unmount. |
maxRetryCount | number | No | 3 | Maximum retry attempts per file. |
shiftOnMaxFiles | boolean | No | false | Remove oldest files (atomically, alongside the new ones being added) when maxFiles is exceeded, instead of showing an error. |
shapeUploadError | (error: TError) => string | No | String | Converts raw error objects into user-friendly strings before storing in state. |
onRootError | (error: string) => void | No | — | Called when a root validation error occurs. |
onFileUploaded | (result: TResult, fileId: string) => void | No | — | Called after each individual file upload succeeds. |
onAllUploaded | () => void | No | — | Called once when every file in the current batch has settled (success or error). |
Return Value
| Property | Type | Description |
|---|---|---|
fileStatuses | FileStatus[] | Array of files with their current status, error, result, and retry count. |
isDragActive | boolean | true while a file is being dragged over the drop zone. |
isInvalid | boolean | true when a root validation error exists. |
rootError | string | undefined | Current root validation error message. |
getRootProps | function | Props to spread onto the drag area element. |
getInputProps | function | Props to spread onto the hidden file input. |
onRemove | (id: string) => void | Removes a file from the queue. |
onRetry | (id: string) => void | Retries a failed file upload. No-op if the file isn't in an "error" state. |
removeFile | (id: string) => void | Alias for onRemove. |
retryFile | (id: string) => void | Alias for onRetry. |
clearAll | () => void | Removes every file from the queue. |
retryFailed | () => void | Retries every failed file that still has retry attempts remaining. |
canRetry | (id: string) => boolean | Returns true if the file has not yet exceeded maxRetryCount. |
open | () => void | Programmatically opens the native file picker. |
FileStatus
interface FileStatus<TResult, TError> {
id: string;
file: File;
status: "pending" | "success" | "error";
tries: number; // number of upload attempts so far
result?: TResult; // present when status === "success"
error?: TError; // present when status === "error"
}Components
| Component | Renders | Description |
|---|---|---|
Dropzone | — | Context provider. Spread the useDropzone() return value onto it. |
DropZoneArea | div | The drag target. Accepts asChild or a render prop for element composition. |
DropzoneTrigger | button | Clickable trigger that renders the hidden file input and opens the picker. |
DropzoneMessage | p | Renders the root validation error. Returns null when there is no error. |
DropzoneDescription | p | Helper text — accepted formats, size limits, etc. |
Accessibility
- Keyboard navigation —
DropzoneTriggeris fully accessible viaTab,Enter, andSpace. It is the only interactive element;DropZoneAreais intentionally not focusable. - Screen readers — Errors are surfaced via
aria-invalidand ARIA live regions built into react-dropzone. - Focus rings — Visible focus indicators are styled via
focus-visibleutilities.
Performance
- Object URL cleanup — Prefer
FileCardPreview showImageThumbnailoruseFileObjectUrlfrom FileCard for local previews. Both revoke object URLs automatically. - Large lists — Use a virtualization library (e.g.,
react-window) when rendering hundreds of file entries.
Last Updated on Jun 25, 2026