Patterns
Common upload UI compositions built entirely from Dropzone and FileCard — no extra components required.
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 · 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
PDF, TXT, or DOCX up to 8 MB
"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> );}Image gallery
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+DropZoneAreacomposition 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