rapidUI

File Dropzone

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.json

Installs 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.9

Add the components it builds on: button.

Copy the source into your project:

components/ui/file-dropzone.tsx
'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>
	)
}
hooks/use-file.ts
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.

  • File Input — single file with preview