Formsui
Dropzone
Drag-and-drop file upload with type, size, and count validation.
Installation
npx shadcn@latest add @particular/dropzonenpm dependencies
- cn@^0.4.0
- lucide-react@^1.51.0
Source
"use client"
import * as React from "react"
import { UploadIcon } from "lucide-react"
import { cn } from "cn"
type FileRejection = { file: File; reason: "type" | "size" | "count" }
function matchesAccept(file: File, accept: string | undefined) {
if (!accept) return true
const name = file.name.toLowerCase()
const type = file.type.toLowerCase()
return accept
.split(",")
.map((token) => token.trim().toLowerCase())
.filter(Boolean)
.some((token) => {
if (token.startsWith(".")) return name.endsWith(token)
if (token.endsWith("/*")) return type.startsWith(token.slice(0, -1))
return type === token
})
}
function validateFiles(
files: File[],
{ accept, maxSize, maxFiles }: { accept?: string; maxSize?: number; maxFiles?: number }
): { accepted: File[]; rejected: FileRejection[] } {
const accepted: File[] = []
const rejected: FileRejection[] = []
for (const file of files) {
if (!matchesAccept(file, accept)) rejected.push({ file, reason: "type" })
else if (maxSize !== undefined && file.size > maxSize) rejected.push({ file, reason: "size" })
else if (maxFiles !== undefined && accepted.length >= maxFiles) rejected.push({ file, reason: "count" })
else accepted.push(file)
}
return { accepted, rejected }
}
function formatBytes(bytes: number) {
const units = ["B", "KB", "MB", "GB"]
let value = bytes
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit++
}
return `${Number(value.toFixed(1))} ${units[unit]}`
}
function Dropzone({
accept,
maxSize,
maxFiles,
multiple = true,
disabled = false,
onFiles,
onReject,
title = "Drop files here or click to browse",
description,
className,
...props
}: Omit<React.ComponentProps<"label">, "onDrop" | "title"> & {
/** Same syntax as `<input accept>`: `.png,image/*,application/pdf`. */
accept?: string
maxSize?: number
maxFiles?: number
multiple?: boolean
disabled?: boolean
onFiles: (files: File[]) => void
onReject?: (rejections: FileRejection[]) => void
title?: React.ReactNode
description?: React.ReactNode
}) {
const [dragging, setDragging] = React.useState(false)
const inputId = React.useId()
const limit = multiple ? maxFiles : 1
const handle = (list: FileList | null) => {
if (!list || disabled) return
const { accepted, rejected } = validateFiles([...list], { accept, maxSize, maxFiles: limit })
if (accepted.length) onFiles(accepted)
if (rejected.length) onReject?.(rejected)
}
const hint =
description ??
[accept && `Accepts ${accept}`, maxSize !== undefined && `up to ${formatBytes(maxSize)}`]
.filter(Boolean)
.join(", ")
return (
<label
htmlFor={inputId}
data-slot="dropzone"
data-dragging={dragging || undefined}
data-disabled={disabled || undefined}
onDragOver={(event) => {
event.preventDefault()
if (!disabled) setDragging(true)
}}
onDragLeave={() => setDragging(false)}
onDrop={(event) => {
event.preventDefault()
setDragging(false)
handle(event.dataTransfer.files)
}}
className={cn(
"flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border border-dashed bg-muted/30 px-6 py-10 text-center transition-colors",
"hover:border-foreground/30 hover:bg-muted/60 has-focus-visible:border-ring has-focus-visible:ring-3 has-focus-visible:ring-ring/50",
"data-dragging:border-brand data-dragging:bg-brand-subtle data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<span className="flex size-10 items-center justify-center rounded-full border bg-background text-muted-foreground shadow-xs">
<UploadIcon className="size-4" aria-hidden />
</span>
<span className="text-sm font-medium">{title}</span>
{hint && <span className="text-xs text-muted-foreground">{hint}</span>}
<input
id={inputId}
type="file"
className="sr-only"
accept={accept}
multiple={multiple}
disabled={disabled}
onChange={(event) => {
handle(event.target.files)
event.target.value = ""
}}
/>
</label>
)
}
export { Dropzone, validateFiles, formatBytes, type FileRejection }