Upload

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

terminal
npx shadcn@latest add @shelf-ui/dropzone
terminal
pnpm dlx shadcn@latest add @shelf-ui/dropzone
terminal
yarn dlx shadcn@latest add @shelf-ui/dropzone
terminal
bunx shadcn@latest add @shelf-ui/dropzone

Usage

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 &middot; 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 through DropzoneMessage and rootError.
  • Upload errors from your onDropFile handler. Shape those with shapeUploadError, then render them in FileCardInfo.

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 a DropZoneArea.
  • <DropzoneMessage> reads rootError from context and renders it. Returns null when 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

ParameterTypeRequiredDefaultDescription
onDropFile(file: File) => Promise<{ status: "success"; result: TResult } | { status: "error"; error: TError }>YesAsync 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.
autoRetrybooleanNofalseAutomatically retry failed uploads with back-off. Retries are cancelled automatically on unmount.
maxRetryCountnumberNo3Maximum retry attempts per file.
shiftOnMaxFilesbooleanNofalseRemove oldest files (atomically, alongside the new ones being added) when maxFiles is exceeded, instead of showing an error.
shapeUploadError(error: TError) => stringNoStringConverts raw error objects into user-friendly strings before storing in state.
onRootError(error: string) => voidNoCalled when a root validation error occurs.
onFileUploaded(result: TResult, fileId: string) => voidNoCalled after each individual file upload succeeds.
onAllUploaded() => voidNoCalled once when every file in the current batch has settled (success or error).

Return Value

PropertyTypeDescription
fileStatusesFileStatus[]Array of files with their current status, error, result, and retry count.
isDragActivebooleantrue while a file is being dragged over the drop zone.
isInvalidbooleantrue when a root validation error exists.
rootErrorstring | undefinedCurrent root validation error message.
getRootPropsfunctionProps to spread onto the drag area element.
getInputPropsfunctionProps to spread onto the hidden file input.
onRemove(id: string) => voidRemoves a file from the queue.
onRetry(id: string) => voidRetries a failed file upload. No-op if the file isn't in an "error" state.
removeFile(id: string) => voidAlias for onRemove.
retryFile(id: string) => voidAlias for onRetry.
clearAll() => voidRemoves every file from the queue.
retryFailed() => voidRetries every failed file that still has retry attempts remaining.
canRetry(id: string) => booleanReturns true if the file has not yet exceeded maxRetryCount.
open() => voidProgrammatically 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

ComponentRendersDescription
DropzoneContext provider. Spread the useDropzone() return value onto it.
DropZoneAreadivThe drag target. Accepts asChild or a render prop for element composition.
DropzoneTriggerbuttonClickable trigger that renders the hidden file input and opens the picker.
DropzoneMessagepRenders the root validation error. Returns null when there is no error.
DropzoneDescriptionpHelper text — accepted formats, size limits, etc.

Accessibility

  • Keyboard navigationDropzoneTrigger is fully accessible via Tab, Enter, and Space. It is the only interactive element; DropZoneArea is intentionally not focusable.
  • Screen readers — Errors are surfaced via aria-invalid and ARIA live regions built into react-dropzone.
  • Focus rings — Visible focus indicators are styled via focus-visible utilities.

Performance

  • Object URL cleanup — Prefer FileCardPreview showImageThumbnail or useFileObjectUrl from 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

On this page