Uploads

Patterns

Common upload UI compositions built entirely from Dropzone and FileCard — no extra components required.

This page is recipes, not components. Everything below is built from Dropzone and FileCard — nothing new to install.

Trigger-only (no drag-and-drop surface)

DropzoneTrigger already renders the hidden <input type="file"> and owns the click handling, so it doesn't need to live inside a DropZoneArea. Skip the drop target entirely for a plain "Choose file" button, useful in compact forms or table cells where a large drag area doesn't fit.

Type your message here…

"use client";import { Paperclip, Send } from "lucide-react";import {  Dropzone,  DropzoneMessage,  DropzoneTrigger,  useDropzone,} from "@/components/shelf-ui/dropzone";import {  FileCard,  FileCardActions,  FileCardInfo,  FileCardPreview,  useFileObjectUrl,} 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, 800));  return { status: "success" as const, result: undefined };}function AttachmentPreview({ file }: Readonly<{ file: File }>) {  const objectUrl = useFileObjectUrl(    file.type.startsWith("image/") ? file : undefined  );  if (objectUrl) {    return (      // biome-ignore lint/performance/noImgElement: object URLs cannot be optimized by next/image      <img        alt={file.name}        className="size-full object-cover"        height={32}        src={objectUrl}        width={32}      />    );  }  return <Paperclip className="size-3 text-muted-foreground" />;}// No DropZoneArea — trigger-only mode. Perfect for chat interfaces// and comment boxes where a large drag target doesn't fit the design.export function TriggerOnlyDemo() {  const dropzone = useDropzone({    onDropFile: fakeUpload,    validation: { maxFiles: 5, maxSize: 10 * 1024 * 1024 },  });  const hasFiles = dropzone.fileStatuses.length > 0;  return (    <div className="w-full max-w-md space-y-3">      {/* Simulated chat / form input surface with enhanced styling */}      <div className="overflow-hidden rounded-xl border bg-linear-to-br from-background to-muted/20 shadow-lg transition-shadow duration-200 hover:shadow-xl">        {/* Input area */}        <div className="min-h-24 p-4">          <p className="select-none text-muted-foreground/50 text-sm">            Type your message here…          </p>        </div>        {/* Attachments preview */}        {hasFiles && (          <div className="border-t bg-muted/30 px-4 py-2">            <div className="flex items-center gap-2">              <Badge className="h-5 text-[10px]" variant="secondary">                {dropzone.fileStatuses.length} attached              </Badge>              {dropzone.fileStatuses.slice(0, 3).map((status) => (                <div                  className="flex size-8 items-center justify-center overflow-hidden rounded-md border bg-background shadow-sm"                  key={status.id}                >                  <AttachmentPreview file={status.file} />                </div>              ))}              {dropzone.fileStatuses.length > 3 && (                <div className="flex size-8 items-center justify-center rounded-md border bg-muted font-medium text-[10px] text-muted-foreground">                  +{dropzone.fileStatuses.length - 3}                </div>              )}            </div>          </div>        )}        {/* Action bar with enhanced buttons */}        <div className="flex items-center justify-between border-t bg-background/50 px-3 py-2.5 backdrop-blur-sm">          <Dropzone {...dropzone}>            <DropzoneTrigger className="group gap-2 font-medium text-muted-foreground text-xs transition-all hover:text-foreground hover:shadow-sm">              <Paperclip className="size-3.5 transition-transform group-hover:-rotate-12" />              Attach files            </DropzoneTrigger>            <DropzoneMessage />          </Dropzone>          <Button            className="gap-2 shadow-sm transition-all hover:shadow-md"            size="sm"            type="button"          >            Send            <Send className="size-3.5" />          </Button>        </div>      </div>      {/* Detailed attachments list with animation */}      {hasFiles && (        <div className="space-y-2 rounded-xl border bg-muted/20 p-3">          <div className="flex items-center justify-between px-1">            <p className="font-semibold text-muted-foreground text-xs uppercase tracking-wider">              Attachments            </p>            <Button              className="h-6 text-xs"              onClick={() => dropzone.clearAll()}              size="sm"              variant="ghost"            >              Clear all            </Button>          </div>          <div className="space-y-1.5">            {dropzone.fileStatuses.map((status, index) => (              <FileCard                className="group transition-all duration-200 hover:border-primary/50 hover:shadow-sm"                fileStatus={status}                key={status.id}                onRemove={() => dropzone.onRemove(status.id)}                style={{                  animation: `fadeIn 0.3s ease-out ${index * 0.08}s both`,                }}              >                <div className="flex w-full items-center gap-3">                  <FileCardPreview showImageThumbnail />                  <FileCardInfo maxNameLength={28} />                  <FileCardActions hideRetry />                </div>              </FileCard>            ))}          </div>        </div>      )}      <style jsx>{`        @keyframes fadeIn {          from {            opacity: 0;            transform: scale(0.95);          }          to {            opacity: 1;            transform: scale(1);          }        }      `}</style>    </div>  );}

This is the closest thing to a standalone "file input" component in Shelf UI — rather than a separate primitive, it's DropzoneTrigger used on its own.

Avatar / single-file replace

Use maxFiles: 1 plus shiftOnMaxFiles: true when a new file should replace the old one automatically. The trigger can render the live preview, so the upload control and the current value are the same element.

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

Empty and error states

fileStatuses is just an array — when it's empty, nothing renders by default. Most upload UIs benefit from an explicit empty state instead of a blank gap, and from making isInvalid/rootError visible at a glance rather than relying solely on DropzoneMessage text.

No files uploaded yet

Your uploads will appear here

"use client";import { Files, Sparkles, Upload } from "lucide-react";import {  DropZoneArea,  Dropzone,  DropzoneMessage,  DropzoneTrigger,  useDropzone,} from "@/components/shelf-ui/dropzone";import {  FileCard,  FileCardActions,  FileCardInfo,  FileCardProgress,} from "@/components/shelf-ui/file-card";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));  return { status: "success" as const, result: file.name };}export function EmptyStateDemo() {  const dropzone = useDropzone({    onDropFile: fakeUpload,    validation: { maxFiles: 3, maxSize: 2 * 1024 * 1024 },  });  const hasFiles = dropzone.fileStatuses.length > 0;  const remaining = 3 - 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}>        <DropZoneArea className="group relative overflow-hidden rounded-xl transition-all duration-300 hover:border-primary/50 hover:shadow-md">          {/* Animated gradient background */}          <div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/0 via-primary/5 to-primary/0 opacity-0 transition-opacity duration-500 group-hover:opacity-100" />          <DropzoneTrigger className="relative flex w-full flex-col items-center justify-center gap-4 px-6 py-12 text-center">            <div className="relative">              <div className="flex size-14 items-center justify-center rounded-2xl border-2 bg-linear-to-br from-background to-muted/60 shadow-lg transition-all duration-300 group-hover:-translate-y-1 group-hover:shadow-xl">                <Upload className="size-6 text-muted-foreground transition-all duration-300 group-hover:scale-110 group-hover:text-primary" />              </div>              {/* Sparkle decoration */}              <div className="absolute -top-1 -right-1 opacity-0 transition-all duration-300 group-hover:opacity-100">                <Sparkles className="size-4 animate-pulse text-primary" />              </div>            </div>            <div className="space-y-2">              <p className="font-semibold text-base">Drop your files here</p>              <p className="text-muted-foreground text-sm">                or{" "}                <span className="font-medium text-primary underline-offset-4 transition-all group-hover:underline">                  click to browse                </span>              </p>              <p className="text-muted-foreground/70 text-xs">                Maximum 3 files · 2 MB each              </p>            </div>          </DropzoneTrigger>        </DropZoneArea>        <DropzoneMessage className="mt-2" />      </Dropzone>      {/* File list or empty state */}      <div        className={cn(          "rounded-xl border transition-all duration-300",          hasFiles            ? "bg-gradient-to-br from-muted/30 to-muted/10 p-4 shadow-sm"            : "border-dashed bg-muted/20"        )}      >        {hasFiles ? (          <div className="space-y-3">            <div className="flex items-center justify-between px-1">              <div className="flex items-center gap-2">                <p className="font-semibold text-sm">Upload Queue</p>                <Badge className="h-5 text-[10px]" variant="secondary">                  {successCount}/{dropzone.fileStatuses.length}                </Badge>              </div>              {remaining > 0 ? (                <Badge className="text-xs" variant="outline">                  {remaining} slot{remaining === 1 ? "" : "s"} left                </Badge>              ) : (                <Badge className="text-xs">Full</Badge>              )}            </div>            <div className="space-y-2">              {dropzone.fileStatuses.map((status, index) => (                <FileCard                  canRetry={dropzone.canRetry(status.id)}                  className="transition-all duration-200 hover:border-primary/50 hover:shadow-sm"                  fileStatus={status}                  key={status.id}                  onRemove={() => dropzone.onRemove(status.id)}                  onRetry={() => dropzone.onRetry(status.id)}                  style={{                    animation: `slideInRight 0.3s ease-out ${index * 0.1}s both`,                  }}                >                  <div className="flex w-full items-center gap-3">                    <FileCardInfo />                    <FileCardActions />                  </div>                  <FileCardProgress />                </FileCard>              ))}            </div>            <Button              className="mt-2 h-8 w-full text-xs"              onClick={() => dropzone.clearAll()}              size="sm"              variant="ghost"            >              Clear all files            </Button>          </div>        ) : (          <div className="flex flex-col items-center gap-3 py-10 text-center">            <div className="flex size-12 items-center justify-center rounded-xl border-2 border-dashed bg-muted/40">              <Files className="size-5 text-muted-foreground" />            </div>            <div className="space-y-1">              <p className="font-medium text-muted-foreground text-sm">                No files uploaded yet              </p>              <p className="text-muted-foreground/60 text-xs">                Your uploads will appear here              </p>            </div>          </div>        )}      </div>      <style jsx>{`        @keyframes slideInRight {          from {            opacity: 0;            transform: translateX(20px);          }          to {            opacity: 1;            transform: translateX(0);          }        }      `}</style>    </div>  );}

DropzoneMessage already renders rootError automatically — the empty-state block above is additive, not a replacement. Use it when you want the queue area itself to communicate state, not just the drop zone.

Documents-only queue (no thumbnails)

When every file is the same general type (PDFs, contracts, resumes), thumbnails and progress bars can add visual noise without adding information. Compose FileCard with only FileCardInfo and FileCardActions for a denser list.

Contract packet

Dense queue for document-heavy workflows

0/6
"use client";import { FileText, FolderOpen } from "lucide-react";import {  DropZoneArea,  Dropzone,  DropzoneDescription,  DropzoneMessage,  DropzoneTrigger,  useDropzone,} from "@/components/shelf-ui/dropzone";import {  FileCard,  FileCardActions,  FileCardInfo,} 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, 700));  return { status: "success" as const, result: file.name };}export function DocumentsOnlyQueueDemo() {  const dropzone = useDropzone({    onDropFile: uploadDocument,    validation: {      maxFiles: 6,      maxSize: 8 * 1024 * 1024,      accept: {        "application/pdf": [".pdf"],        "text/plain": [".txt"],        "application/vnd.openxmlformats-officedocument.wordprocessingml.document":          [".docx"],      },    },  });  const hasFiles = dropzone.fileStatuses.length > 0;  return (    <div className="flex w-full max-w-md flex-col gap-3">      <div className="flex items-center justify-between rounded-xl border bg-muted/20 p-3">        <div className="flex items-center gap-3">          <div className="flex size-9 items-center justify-center rounded-md bg-background">            <FolderOpen className="size-4 text-muted-foreground" />          </div>          <div className="flex flex-col gap-0.5">            <p className="font-semibold text-sm">Contract packet</p>            <p className="text-muted-foreground text-xs">              Dense queue for document-heavy workflows            </p>          </div>        </div>        <Badge variant="secondary">{dropzone.fileStatuses.length}/6</Badge>      </div>      <Dropzone {...dropzone}>        <DropZoneArea className="rounded-xl">          <DropzoneTrigger className="flex w-full items-center justify-center gap-2 px-4 py-5 text-sm">            <FileText className="size-4 text-muted-foreground" />            Add documents          </DropzoneTrigger>          <DropzoneDescription className="px-4 pb-4 text-center text-xs">            PDF, TXT, or DOCX up to 8 MB          </DropzoneDescription>        </DropZoneArea>        <DropzoneMessage className="mt-2" />      </Dropzone>      {hasFiles ? (        <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-center gap-3">                <FileCardInfo maxNameLength={36} />                <FileCardActions />              </div>            </FileCard>          ))}          <Button            onClick={dropzone.clearAll}            size="sm"            type="button"            variant="ghost"          >            Clear queue          </Button>        </div>      ) : null}    </div>  );}

Batch upload queue

Use this when the user expects to upload several independent files, see partial progress, clear the queue, and retry only failed items.

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

For image-heavy workflows, skip row-based cards and show a grid where thumbnails are the primary information. This pattern keeps object URLs stable and revokes them when files are removed.

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

Choosing a pattern

A quick way to decide which composition fits:

  • Need drag-and-drop and multiple files — use the default Dropzone + DropZoneArea composition from the Usage example.
  • Need a single file, no drag-and-drop, fits in a tight space — Trigger-only, above.
  • Need exactly one file that replaces itself (avatar, logo, cover image) — Avatar pattern, above.
  • Need a gallery of images — Image gallery, above.
  • Need a document checklist with no visual fluff — Documents-only queue, above.
  • Need retry and clear controls for a batch — Batch upload queue, above.

None of these require new component code — they're all useDropzone configuration plus a choice of which FileCard subcomponents to render.

Last Updated on Jun 22, 2026

On this page