Drag-and-drop multi-file uploader with size and type validation, built on react-dropzone.
Installation
pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/file-dropzone.jsonInstalls to @/components/ui/file-dropzone.
Manual installation
Install the dependencies:
pnpm add class-variance-authority@^0.7.1 lucide-react@^1.48.0 react-dropzone@^14.2.9Add the components it builds on: button.
Copy the source into your project:
'use client'
import { cva, type VariantProps } from 'class-variance-authority'
import { CloudUploadIcon, FileIcon, ImageUpIcon, Trash2Icon } from 'lucide-react'
import * as React from 'react'
import { useDropzone } from 'react-dropzone'
import { Button } from '@/components/ui/button'
// NOTE: UI strings are hardcoded in English. This is a template component — wire it to your app's i18n when adopting it.
import { useFile } from '@/hooks/use-file'
import { cn } from '@/lib/utils'
const fileDropzoneVariants = cva('w-full space-y-4', {
variants: {
size: {
sm: '',
default: '',
},
},
defaultVariants: {
size: 'default',
},
})
const fileDropzoneIconVariants = cva('text-muted-foreground transition-colors', {
variants: {
size: {
sm: 'size-8',
default: 'size-10',
},
},
defaultVariants: {
size: 'default',
},
})
const fileDropzoneTextVariants = cva('font-medium text-primary', {
variants: {
size: {
sm: 'text-sm',
default: 'text-base',
},
},
defaultVariants: {
size: 'default',
},
})
export type FileDropzoneIcon = 'cloud-upload' | 'image-upload'
interface Variants
extends VariantProps<typeof fileDropzoneVariants>,
VariantProps<typeof fileDropzoneIconVariants>,
VariantProps<typeof fileDropzoneTextVariants> {}
interface FileDropzoneProps extends Variants {
value: File[]
onChange: (files: File[]) => void
maxFiles?: number
maxFileSize?: number
className?: string
icon?: FileDropzoneIcon
/** Accessible name for surrounding landmark region */
regionLabel?: string
/** Accessible label for the interactive drop area */
ariaLabel?: string
/**
* Ids of the elements that name the drop area, usually the field's label (which carries
* "required"). Takes precedence over `ariaLabel`.
*/
'aria-labelledby'?: string
/** Extra ids describing the drop area, e.g. the form's help text and error message */
'aria-describedby'?: string
/** Marks the drop area invalid from outside, e.g. a required field left empty */
'aria-invalid'?: React.AriaAttributes['aria-invalid']
/** Formatter for remove-button aria-label */
getRemoveFileAriaLabel?: (fileName: string) => string
/** If true, the drop area will fill its container vertically */
fullHeight?: boolean
/** Array of accepted file extensions (e.g., ['.pdf', '.jpg', '.png']) */
acceptedFileTypes?: string[]
}
/**
* Mapping of file extensions to MIME types for react-dropzone accept format
*/
const EXTENSION_TO_MIME: Record<string, string> = {
// Documents
'.uf': 'application/octet-stream',
'.pdf': 'application/pdf',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.docm': 'application/vnd.ms-word.document.macroEnabled.12',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.xlsb': 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
'.xlsm': 'application/vnd.ms-excel.sheet.macroEnabled.12',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.pptm': 'application/vnd.ms-powerpoint.presentation.macroEnabled.12',
'.ppsx': 'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
'.mpp': 'application/vnd.ms-project',
'.txt': 'text/plain',
'.rtf': 'application/rtf',
'.htm': 'text/html',
'.html': 'text/html',
'.xml': 'application/xml',
'.eml': 'message/rfc822',
'.mht': 'message/rfc822',
'.msg': 'application/vnd.ms-outlook',
'.one': 'application/onenote',
'.xps': 'application/vnd.ms-xpsdocument',
'.pub': 'application/x-mspublisher',
'.odt': 'application/vnd.oasis.opendocument.text',
'.ods': 'application/vnd.oasis.opendocument.spreadsheet',
'.odg': 'application/vnd.oasis.opendocument.graphics',
'.odp': 'application/vnd.oasis.opendocument.presentation',
// Images
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.bmp': 'image/bmp',
'.tif': 'image/tiff',
'.tiff': 'image/tiff',
// Media
'.mpeg-2': 'video/mpeg',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.mp4': 'video/mp4',
'.mov': 'video/quicktime',
'.wmv': 'video/x-ms-wmv',
// CAD & GIS
'.dwg': 'image/vnd.dwg',
'.dwf': 'model/vnd.dwf',
'.ifc': 'model/ifc',
'.sosi': 'application/octet-stream',
'.sos': 'application/octet-stream',
'.kof': 'application/octet-stream',
'.vgt': 'application/octet-stream',
'.gsi': 'application/octet-stream',
'.gml': 'application/gml+xml',
// Other
'.zip': 'application/zip',
'.vsd': 'application/vnd.visio',
'.vsdx': 'application/vnd.visio',
}
/**
* Converts an array of file extensions to react-dropzone accept format.
* Groups extensions by their MIME type for proper accept configuration.
*
* @param extensions - Array of file extensions (e.g., ['.pdf', '.jpg'])
* @returns Accept object for react-dropzone (e.g., { 'image/jpeg': ['.jpg', '.jpeg'] })
*/
function createAcceptObject(extensions: string[]): Record<string, string[]> {
const accept: Record<string, string[]> = {}
for (const ext of extensions) {
const normalizedExt = ext.toLowerCase()
const mimeType = EXTENSION_TO_MIME[normalizedExt]
if (mimeType) {
if (!accept[mimeType]) {
accept[mimeType] = []
}
// Only add extension if not already present (handles .jpg and .jpeg both mapping to image/jpeg)
if (!accept[mimeType].includes(normalizedExt)) {
accept[mimeType].push(normalizedExt)
}
} else {
// For unknown extensions, use a generic MIME type with the extension
// This allows the file input to still filter by extension
const genericMime = 'application/octet-stream'
if (!accept[genericMime]) {
accept[genericMime] = []
}
if (!accept[genericMime].includes(normalizedExt)) {
accept[genericMime].push(normalizedExt)
}
}
}
return accept
}
export function FileDropzone({
value,
onChange,
maxFiles,
maxFileSize,
size,
className,
icon = 'cloud-upload',
regionLabel,
ariaLabel,
getRemoveFileAriaLabel,
fullHeight = false,
acceptedFileTypes,
'aria-labelledby': ariaLabelledby,
'aria-describedby': ariaDescribedby,
'aria-invalid': ariaInvalid,
}: Readonly<FileDropzoneProps>) {
const { convertMb } = useFile()
const hintId = React.useId()
const errorId = React.useId()
const [errorMessage, setErrorMessage] = React.useState<string | null>(null)
/**
* Normalizes the value prop to always be an array of Files.
*
* Handles various input types:
* - undefined/null -> []
* - Array -> filter to only include File objects
* - Single File -> [File]
* - Anything else -> [] (invalid, reset to empty)
*/
const normalizedValue: File[] = React.useMemo(() => {
if (!value) return []
// Handle arrays - filter to only include File objects
if (Array.isArray(value)) {
return value.filter((item): item is File => item instanceof File)
}
// Handle single File object (runtime check for cases where value might be a single File)
// Use unknown cast to allow instanceof check since TypeScript types value as File[]
const singleValue = value as unknown
if (singleValue && typeof singleValue === 'object' && singleValue instanceof File) {
return [singleValue]
}
// Fallback: invalid value type, return empty array
return []
}, [value])
const onDrop = React.useCallback(
(acceptedFiles: File[]) => {
setErrorMessage(null)
const allowedCount =
typeof maxFiles === 'number' ? Math.max(0, maxFiles - normalizedValue.length) : acceptedFiles.length
const trimmed = acceptedFiles.slice(0, allowedCount)
onChange([...normalizedValue, ...trimmed])
},
[normalizedValue, onChange, maxFiles],
)
const maxBytes = typeof maxFileSize === 'number' ? Math.round(maxFileSize * 1024 * 1024) : undefined
// Create accept object from acceptedFileTypes if provided and has items
const accept = React.useMemo(() => {
if (acceptedFileTypes && acceptedFileTypes.length > 0) {
return createAcceptObject(acceptedFileTypes)
}
return undefined
}, [acceptedFileTypes])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
maxFiles,
maxSize: maxBytes,
accept,
validator: (file) => {
if (file.size === 0) {
return { code: 'file-empty', message: 'File is empty' }
}
return null
},
onDropRejected: (fileRejections) => {
const hasEmpty = fileRejections.some((rej) => rej.errors.some((e) => e.code === 'file-empty'))
const hasTooMany = fileRejections.some((rej) => rej.errors.some((e) => e.code === 'too-many-files'))
const hasTooLarge = fileRejections.some((rej) => rej.errors.some((e) => e.code === 'file-too-large'))
const hasInvalidType = fileRejections.some((rej) => rej.errors.some((e) => e.code === 'file-invalid-type'))
if (hasEmpty) {
setErrorMessage('File is empty. Please choose a file with content.')
return
}
if (hasInvalidType && acceptedFileTypes && acceptedFileTypes.length > 0) {
const formattedTypes = acceptedFileTypes.map((ext) => ext.toUpperCase().replace('.', '')).join(', ')
setErrorMessage(`Invalid file format. Please upload a ${formattedTypes} file.`)
return
}
if (hasTooLarge && typeof maxFileSize === 'number') {
setErrorMessage(`File is too large. Maximum size is ${convertMb(maxFileSize)}.`)
return
}
if (hasTooMany && typeof maxFiles === 'number') {
setErrorMessage(`Max uploads: ${maxFiles} files`)
return
}
setErrorMessage(null)
},
})
/**
* Removes a file from the list.
*
* For already-uploaded files (with _attachmentId), uses the ID for comparison.
* For new files, uses object reference comparison.
* This ensures removal works correctly even when File objects are recreated on re-render.
*/
const removeFile = (fileToRemove: File) => {
const fileToRemoveWithId = fileToRemove as File & { _attachmentId?: string }
const identifier = fileToRemoveWithId._attachmentId
if (identifier) {
// For already-uploaded files, compare by _attachmentId
// This works even when File objects are recreated on re-render
const filesWithId = normalizedValue as Array<File & { _attachmentId?: string }>
onChange(filesWithId.filter((file) => file._attachmentId !== identifier))
} else {
// For new files (not yet uploaded), use object reference comparison
onChange(normalizedValue.filter((file) => file !== fileToRemove))
}
}
// Adding or removing a file only changes the list below the drop area, which a screen
// reader does not notice; say the new count instead. Empty on the first render, so
// files already there when the page opens are not announced.
const [countStatus, setCountStatus] = React.useState('')
const fileCount = normalizedValue.length
const previousCount = React.useRef(fileCount)
React.useEffect(() => {
if (previousCount.current === fileCount) return
previousCount.current = fileCount
setCountStatus(fileCount === 1 ? '1 file' : `${fileCount} files`)
}, [fileCount])
const regionName = regionLabel ?? 'File uploader'
const dropAreaLabel = ariaLabel ?? 'Choose file or drag here'
// Build hint text: "PDF, JPG, CSV up to 1MB (max 3 files)"
const hintParts: string[] = []
if (acceptedFileTypes && acceptedFileTypes.length > 0) {
hintParts.push(acceptedFileTypes.map((ext) => ext.toUpperCase().replace('.', '')).join(', '))
}
if (typeof maxFileSize === 'number') {
hintParts.push(`up to ${convertMb(maxFileSize)}`)
}
const hintText = hintParts.length > 0 ? hintParts.join(' ') : null
const maxFilesText = typeof maxFiles === 'number' ? `(max uploads ${maxFiles})` : null
return (
<section
data-slot="file-dropzone"
className={cn(fileDropzoneVariants({ size, className }), 'group/file-dropzone min-w-0')}
aria-label={regionName}
>
<div
data-dragging={isDragActive || undefined}
className={cn(
'relative flex cursor-pointer items-center justify-center rounded-lg border border-dashed border-input bg-primary-foreground p-6 transition-colors outline-none',
!errorMessage && 'hover:border-primary hover:bg-primary/5 data-dragging:border-primary data-dragging:bg-primary/5',
// The focus indicator is drawn inside the border: a dropzone usually sits in a
// container with overflow:hidden, and anything painted outside the border box
// (a ring, which is a box-shadow, or an outline) is clipped away there, so
// keyboard users could not see where they were. The error ring is inset for the same reason.
'focus-visible:border-ring focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-ring',
'aria-invalid:border-destructive aria-invalid:outline-destructive aria-invalid:ring-3 aria-invalid:ring-inset aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
{ 'h-full': fullHeight },
)}
{...getRootProps({
role: 'button',
'aria-invalid': errorMessage || (ariaInvalid && ariaInvalid !== 'false') ? true : undefined,
'aria-describedby': [hintId, errorMessage ? errorId : null, ariaDescribedby].filter(Boolean).join(' '),
'aria-label': ariaLabelledby ? undefined : dropAreaLabel,
'aria-labelledby': ariaLabelledby,
})}
>
<div className="flex flex-col items-center gap-4">
<div className="flex flex-col items-center gap-3">
{icon === 'cloud-upload' && (
<CloudUploadIcon
className={cn(
fileDropzoneIconVariants({ size }),
errorMessage ? 'text-destructive' : 'group-hover/file-dropzone:text-primary',
)}
strokeWidth={1.5}
aria-hidden="true"
focusable="false"
/>
)}
{icon === 'image-upload' && (
<ImageUpIcon
className={cn(
fileDropzoneIconVariants({ size }),
errorMessage ? 'text-destructive' : 'group-hover/file-dropzone:text-primary',
)}
strokeWidth={1.5}
aria-hidden="true"
focusable="false"
/>
)}
<span className={fileDropzoneTextVariants({ size })}>
Choose file <span className="font-normal leading-tight text-foreground">or drag here</span>
</span>
</div>
{(hintText || maxFilesText) && (
<p className="text-center text-sm leading-tight text-muted-foreground" id={hintId}>
{hintText}
{hintText && maxFilesText && ' '}
{maxFilesText}
</p>
)}
</div>
<input {...getInputProps()} className="hidden" aria-hidden="true" tabIndex={-1} />
</div>
{/* <output> has the status role: the new count is announced politely */}
<output className="sr-only">{countStatus}</output>
{errorMessage && (
<p id={errorId} role="alert" aria-live="polite" className="text-sm text-destructive">
{errorMessage}
</p>
)}
{normalizedValue.length > 0 && (
<div className="w-full overflow-hidden rounded-lg border bg-background">
<div className="flex items-center justify-between border-b px-3 py-2">
<h3 className="text-sm font-medium">{`${normalizedValue.length} files`}</h3>
</div>
<div className="overflow-y-auto max-h-48">
<ul className="divide-y divide-border">
{normalizedValue.map((file, index) => {
// Use _attachmentId as key for already-uploaded files to ensure stable keys
// For new files, use name + size + lastModified + index as fallback
const fileWithId = file as File & { _attachmentId?: string }
const fileKey = fileWithId._attachmentId ?? `${file.name}-${file.size}-${file.lastModified}-${index}`
return (
<li key={fileKey} className="flex items-center gap-2 overflow-hidden px-3 py-2 hover:bg-muted/50">
<FileIcon className="size-4 shrink-0 text-primary" />
<div className="min-w-0 flex-1 overflow-hidden">
<p className="text-sm font-medium truncate" title={file.name}>
{file.name}
</p>
<p className="text-xs text-muted-foreground">{(file.size / 1024 / 1024).toFixed(2)} MB</p>
</div>
<Button
onClick={() => removeFile(file)}
type="button"
variant="ghost"
size="icon-sm"
aria-label={getRemoveFileAriaLabel ? getRemoveFileAriaLabel(file.name) : `Remove ${file.name}`}
>
<Trash2Icon aria-hidden="true" focusable="false" />
</Button>
</li>
)
})}
</ul>
</div>
</div>
)}
</section>
)
}function getByteSize(file: File): string {
const bytes = file.size
const sizes = ['MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes / 1024 / 1024) / Math.log(1024))
if (bytes === 0) return '0 MB'
if (bytes < 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} MB`
return `${(bytes / 1024 ** (i + 2)).toFixed(2)} ${sizes[i]}`
}
function convertMb(size: number): string {
if (size >= 1000) {
return `${size / 1000}GB`
}
return `${size}MB`
}
export function useFile() {
return {
getByteSize,
convertMb,
}
}A drag-and-drop area for one or many files, built on react-dropzone. Selected files are listed below the drop area with a remove button. Rejections (empty file, wrong type, too large, too many) surface as an inline error, and a hint line summarises the limits ("PDF, PNG up to 5MB (max uploads 3)").
Installs together with the use-file hook (hooks/use-file.ts) used for size formatting.
i18n: the visible strings are hardcoded in English. This is a template component — replace them with your app's translation layer after installing.
Usage
import { FileDropzone } from "@/components/ui/file-dropzone"
const [files, setFiles] = useState<File[]>([])
<FileDropzone
value={files}
onChange={setFiles}
maxFiles={3}
maxFileSize={5} // MB
acceptedFileTypes={['.pdf', '.png']}
icon="cloud-upload" // or "image-upload"
/>acceptedFileTypes takes extensions; they are mapped to MIME types for the browser's file picker (unknown
extensions fall back to application/octet-stream). Files that already exist server-side can carry an
_attachmentId so removal works even when the File objects are recreated.
Examples
Default
Inside a card as one step of a building permit application: drawings and attachments, up to five PDF, DWG or image files of 10MB each.
With limits
A housing allowance case that already has two documents attached. The drop area accepts one more PDF or Word file; a fourth file is rejected with an inline error.
Image upload
icon="image-upload" for photo uploads, here in a report form for problems in the street.
Small
size="sm" for a compact, optional single-file upload.
Related Components
- File Input — single file with preview