Drop files or browse with native file selection, type and size validation, removable file rows, and live errors.
Drop files here or browse
Up to 3 files · 5 MB each
PDF or image files for this review.
import { FileDropzone } from "@/components/ballmac/file-dropzone"
export default function FileDropzoneDemo() {
return (
<div className="w-full max-w-sm">
<div className="mb-3 text-sm font-semibold">Attach project files</div>
<FileDropzone
accept=".pdf,.png,.jpg"
maxFiles={3}
maxSize={5 * 1024 * 1024}
/>
<p className="mt-2 text-xs text-muted-foreground">
PDF or image files for this review.
</p>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/file-dropzoneInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/file-dropzone.tsx// Ballmac UI: File Dropzone. https://ui.ballmac.com/components/file-dropzone "use client" import * as React from "react" import { FileUp, X } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type FileDropzoneProps = Omit<React.ComponentProps<"div">, "onChange"> & { /** Accepted MIME types or extensions, in native input accept syntax. */ accept?: string /** Allow more than one file. */ multiple?: boolean /** Maximum number of files in the selection. */ maxFiles?: number /** Maximum size of each file in bytes. */ maxSize?: number /** Controlled file selection. */ files?: File[] /** Initial selection when uncontrolled. */ defaultFiles?: File[] /** Called with the complete selection after add or remove. */ onFilesChange?: (files: File[]) => void /** Disable browsing and dropping. */ disabled?: boolean } function FileDropzone({ accept, multiple = true, maxFiles = multiple ? 5 : 1, maxSize = 10 * 1024 * 1024, files, defaultFiles = [], onFilesChange, disabled = false, className, onDragEnter, onDragLeave, onDragOver, onDrop, ...props }: FileDropzoneProps) { const msg = useMessages() const [internalFiles, setInternalFiles] = React.useState<File[]>(defaultFiles) const [dragging, setDragging] = React.useState(false) const [error, setError] = React.useState("") const inputRef = React.useRef<HTMLInputElement>(null) const id = React.useId() const selected = files ?? internalFiles function commit(next: File[]) { if (files === undefined) setInternalFiles(next) onFilesChange?.(next) } function add(incoming: File[]) { const allowed = accept ?.split(",") .map((part) => part.trim().toLowerCase()) .filter(Boolean) ?? [] const valid = incoming.filter((file) => { const accepted = !allowed.length || allowed.some((part) => part.startsWith(".") ? file.name.toLowerCase().endsWith(part) : part.endsWith("/*") ? file.type.startsWith(part.slice(0, -1)) : file.type.toLowerCase() === part, ) if (!accepted) setError(`${file.name} is not an accepted file type.`) else if (file.size > maxSize) setError(`${file.name} exceeds the size limit.`) return accepted && file.size <= maxSize }) const next = multiple ? [...selected, ...valid] : valid.slice(0, 1) if (next.length > maxFiles) { setError(`Choose no more than ${maxFiles} files.`) return } if (valid.length) { setError("") commit(next) } } return ( <div data-slot="file-dropzone" className={cn("w-full min-w-0", className)} {...props} > <div data-slot="file-dropzone-area" data-dragging={dragging || undefined} className="flex flex-col items-center rounded-xl border border-dashed border-input bg-card/50 px-5 py-7 text-center transition-[background-color,border-color] duration-150 data-[dragging]:border-ring data-[dragging]:bg-accent motion-reduce:transition-none" onDragEnter={(event) => { onDragEnter?.(event) event.preventDefault() if (!disabled) setDragging(true) }} onDragLeave={(event) => { onDragLeave?.(event) if (!event.currentTarget.contains(event.relatedTarget as Node)) setDragging(false) }} onDragOver={(event) => { onDragOver?.(event) event.preventDefault() }} onDrop={(event) => { onDrop?.(event) event.preventDefault() setDragging(false) if (!disabled) add(Array.from(event.dataTransfer.files)) }} > <FileUp aria-hidden="true" className="mb-3 size-6 text-muted-foreground" /> <p className="text-sm font-medium">{msg("file-dropzone.dropFilesHereOrBrowse", "Drop files here or browse")}</p> <p id={id} className="mt-1 text-xs text-muted-foreground"> {multiple ? `Up to ${maxFiles} files` : "One file"} ·{" "} {msg("file-dropzone.maxSize", "{size} MB each", { size: Math.round(maxSize / 1024 / 1024) })} </p> <button type="button" disabled={disabled} onClick={() => inputRef.current?.click()} className="mt-4 inline-flex h-9 items-center rounded-md border border-border bg-background px-3 text-sm font-medium shadow-xs outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50" > {msg("file-dropzone.choose", "Choose")} {multiple ? "files" : "file"} </button> <input ref={inputRef} type="file" aria-label={msg("file-dropzone.filesToUpload", "Files to upload")} accept={accept} multiple={multiple} disabled={disabled} aria-describedby={id} tabIndex={-1} className="sr-only" onChange={(event) => { add(Array.from(event.currentTarget.files ?? [])) event.currentTarget.value = "" }} /> </div> {error && ( <p data-slot="file-dropzone-error" role="alert" className="mt-2 text-sm text-destructive" > {error} </p> )} {selected.length > 0 && ( <ul data-slot="file-dropzone-files" className="mt-3 flex flex-col gap-2" > {selected.map((file, index) => ( <li key={`${file.name}-${index}`} className="flex min-w-0 items-center gap-2 rounded-lg border bg-card px-3 py-2 text-sm" > <span className="min-w-0 flex-1 truncate">{file.name}</span> <span className="text-xs text-muted-foreground tabular-nums"> {msg("file-dropzone.sizeKb", "{size} KB", { size: Math.max(1, Math.round(file.size / 1024)) })} </span> <button type="button" aria-label={msg("file-dropzone.remove", "Remove {name}", { name: file.name })} disabled={disabled} onClick={() => commit(selected.filter((_, position) => position !== index)) } className="flex size-7 shrink-0 items-center justify-center rounded-md outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50" > <X aria-hidden="true" className="size-4" /> </button> </li> ))} </ul> )} </div> ) } export { FileDropzone, type FileDropzoneProps }Update the import paths to match your project setup.
Usage
import { FileDropzone } from "@/components/ballmac/file-dropzone"The full example is in the Code tab above.
Examples
States and variants
Upload a profile image
Drop files here or browse
One file · 2 MB each
import { FileDropzone } from "@/components/ballmac/file-dropzone"
export default function FileDropzoneStates() {
return (
<div className="w-full max-w-sm">
<p className="mb-3 text-sm font-semibold">Upload a profile image</p>
<FileDropzone
accept="image/*"
multiple={false}
maxSize={2 * 1024 * 1024}
/>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
acceptAccepted MIME types or extensions, in native input accept syntax. | string | — |
multipleAllow more than one file. | boolean | true |
maxFilesMaximum number of files in the selection. | number | multiple ? 5 : 1 |
maxSizeMaximum size of each file in bytes. | number | 10 * 1024 * 1024 |
filesControlled file selection. | File[] | — |
defaultFilesInitial selection when uncontrolled. | File[] | [] |
onFilesChangeCalled with the complete selection after add or remove. | (files: File[]) => void | — |
disabledDisable browsing and dropping. | boolean | false |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab / Enter | Opens the native file chooser |
| Drop | Adds valid files |
| Tab / Enter | Removes a selected file |
Use with AI
Drop files or browse with native file selection, type and size validation, removable file rows, and live errors. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI File Dropzone (@ballmac/file-dropzone) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Upload a document or group of assets
- Review selected files before submitting
Not for
- Use input for a minimal single-file field
Registry JSON: https://ui.ballmac.com/r/file-dropzone.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Button
A button with six variants, three sizes, a pill shape and a built-in loading state. buttonVariants() styles links the same way.
Calendar
A date grid for one day, several days or a range, with month and year selects, week numbers, disabled rules and range-end styling, on React DayPicker.
Color Picker
A native color chooser paired with an editable hex field and a live swatch.
Combobox
A searchable single-choice select with groups, descriptions, keywords, clearable value, invalid state and hidden-input form submission.