File Dropzone
Drag and drop upload with validation, progress and rejection states.
@drift-ui/file-dropzoneRegistry JSON- brand-guidelines.pdf2.4 MB
- q3-roadmap.png64%
On this page
Installation
The CLI also installs the npm packages and registry items it builds on. Register the namespace once first with https://drift-ui.dev/r/{style}/{name}.json. The CLI fills in {style} from your components.json and installs the Base UI or Radix build to match. See setup.
API reference
FileDropzone
Validates dropped or picked files and reports them. It does not upload or store anything, so keep the list in your own state.
| Prop | Type | Default |
|---|---|---|
acceptstringComma separated extensions, MIME types or wildcards such as image/*. | string | |
maxSizenumberLargest file in bytes. | number | |
maxFilesnumberMost files accepted from one drop or pick. | number | |
multipleboolean= trueAllow more than one file at a time. | boolean | true |
disabledbooleanIgnore drops and clicks. | boolean | |
onFilesAccepted(files: File[]) => voidCalled with the files that passed validation. | (files: File[]) => void | |
onFilesRejected(rejections: FileRejection[]) => voidCalled with { file, reason } for each rejected file, where reason is "type", "size" or "count". | (rejections: FileRejection[]) => void |
Other props go to the <label>.
FileDropzoneItem
One row of a FileDropzoneList. A progress bar shows while progress is below 100.
| Prop | Type | Default |
|---|---|---|
nameRequiredstringFile name. | string | |
sizeRequirednumberFile size in bytes. | number | |
progressnumberUpload progress from 0 to 100. | number | |
errorstringMarks the row invalid and shows the message. | string | |
onRemove() => voidShows a remove button that calls it. | () => void |
Other props go to the <li>.
Source
"use client"
import * as React from "react"
import { FileIcon, UploadIcon, XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
type FileRejectionReason = "type" | "size" | "count"
type FileRejection = {
file: File
reason: FileRejectionReason
}
const BYTE_UNITS = ["B", "KB", "MB", "GB"]
function formatBytes(bytes: number) {
const exponent = Math.min(
Math.floor(Math.log(Math.max(bytes, 1)) / Math.log(1024)),
BYTE_UNITS.length - 1
)
const amount = bytes / 1024 ** exponent
return `${amount.toFixed(exponent === 0 || amount >= 10 ? 0 : 1)} ${BYTE_UNITS[exponent]}`
}
function matchesAccept(file: File, accept?: string) {
if (!accept) return true
return accept
.split(",")
.map((token) => token.trim().toLowerCase())
.some((token) => {
if (token.startsWith(".")) return file.name.toLowerCase().endsWith(token)
if (token.endsWith("/*")) return file.type.startsWith(token.slice(0, -1))
return file.type === token
})
}
type FileLimits = { accept?: string; maxSize?: number; maxFiles?: number }
function rejectionReasonFor(
file: File,
acceptedCount: number,
{ accept, maxSize, maxFiles }: FileLimits
): FileRejectionReason | null {
if (!matchesAccept(file, accept)) return "type"
if (maxSize !== undefined && file.size > maxSize) return "size"
if (maxFiles !== undefined && acceptedCount >= maxFiles) return "count"
return null
}
function partitionFiles(files: File[], limits: FileLimits) {
const accepted: File[] = []
const rejected: FileRejection[] = []
for (const file of files) {
const reason = rejectionReasonFor(file, accepted.length, limits)
if (reason) rejected.push({ file, reason })
else accepted.push(file)
}
return { accepted, rejected }
}
function FileDropzone({
accept,
maxSize,
maxFiles,
multiple = true,
disabled,
onFilesAccepted,
onFilesRejected,
className,
children,
...props
}: Omit<React.ComponentProps<"label">, "onDrop"> & {
accept?: string
maxSize?: number
maxFiles?: number
multiple?: boolean
disabled?: boolean
onFilesAccepted?: (files: File[]) => void
onFilesRejected?: (rejections: FileRejection[]) => void
}) {
const [isDragging, setIsDragging] = React.useState(false)
function receiveFiles(fileList: FileList | null) {
if (!fileList || disabled) return
const files = multiple
? Array.from(fileList)
: Array.from(fileList).slice(0, 1)
const { accepted, rejected } = partitionFiles(files, {
accept,
maxSize,
maxFiles,
})
if (accepted.length > 0) onFilesAccepted?.(accepted)
if (rejected.length > 0) onFilesRejected?.(rejected)
}
return (
<label
data-slot="file-dropzone"
data-dragging={isDragging ? "" : undefined}
data-disabled={disabled ? "" : undefined}
onDragEnter={(event) => {
event.preventDefault()
if (!disabled) setIsDragging(true)
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null))
setIsDragging(false)
}}
onDrop={(event) => {
event.preventDefault()
setIsDragging(false)
receiveFiles(event.dataTransfer.files)
}}
className={cn(
"flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-input px-6 py-8 text-center transition-[color,background-color,border-color,box-shadow]",
"hover:bg-accent/40 has-[input:focus-visible]:border-ring has-[input:focus-visible]:ring-[3px] has-[input:focus-visible]:ring-ring/50",
"data-[dragging]:border-primary data-[dragging]:bg-accent",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<input
type="file"
className="sr-only"
accept={accept}
multiple={multiple}
disabled={disabled}
onChange={(event) => {
receiveFiles(event.target.files)
event.target.value = ""
}}
/>
{children ?? (
<>
<span className="flex size-10 items-center justify-center rounded-full border bg-background text-muted-foreground [&_svg]:size-4">
<UploadIcon />
</span>
<span className="text-sm font-medium">
Drop files or{" "}
<span className="underline underline-offset-4">browse</span>
</span>
{maxSize !== undefined && (
<span className="text-xs text-muted-foreground">
Up to {formatBytes(maxSize)} each
</span>
)}
</>
)}
</label>
)
}
function FileDropzoneList({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="file-dropzone-list"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function FileDropzoneItem({
name,
size,
progress,
error,
onRemove,
className,
...props
}: Omit<React.ComponentProps<"li">, "children"> & {
name: string
size: number
progress?: number
error?: string
onRemove?: () => void
}) {
const isUploading = progress !== undefined && progress < 100 && !error
return (
<li
data-slot="file-dropzone-item"
data-invalid={error ? "" : undefined}
className={cn(
"flex items-center gap-3 rounded-md border px-3 py-2.5 data-[invalid]:border-destructive/50",
className
)}
{...props}
>
<FileIcon className="size-4 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="truncate">{name}</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground tabular-nums">
{isUploading ? `${Math.round(progress)}%` : formatBytes(size)}
</span>
</div>
{isUploading && (
<div
role="progressbar"
aria-label={`Uploading ${name}`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress}
className="h-1 overflow-hidden rounded-full bg-muted"
>
<div
className="h-full bg-primary transition-transform duration-300"
style={{ transform: `translateX(-${100 - progress}%)` }}
/>
</div>
)}
{error && <span className="text-xs text-destructive">{error}</span>}
</div>
{onRemove && (
<button
type="button"
aria-label={`Remove ${name}`}
onClick={onRemove}
className="rounded-sm text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<XIcon className="size-4" />
</button>
)}
</li>
)
}
export {
FileDropzone,
FileDropzoneList,
FileDropzoneItem,
formatBytes,
type FileRejection,
type FileRejectionReason,
}