Dropzone
A dashed area that takes dropped files or opens the file picker, checks type, size and count, and lists the files the app holds under it. It never uploads; the app does.
Installation
$
npx shadcn@latest add https://ui.mattdowney.com/r/dropzone.jsonUsage
import { Dropzone, DropzoneFiles, DropzoneFile } from "@/components/ui/dropzone"Examples
Default
Any file, up to 5 MB each. The app keeps what onFiles hands it and lists each one under the zone with DropzoneFile.
Drop files here or browseUp to 5 MB each
Images, with limits
accept, maxSize and maxFiles turn files away through onReject, each with its reason. Pass an image's object URL as preview to show it.
Drop images here or browsePNG, JPG or WebP, up to 2 MB. 4 left.
Invalid and disabled
invalid draws the failed edge when a file is required. disabled turns it off, for example once maxFiles is reached.
Add a file to continue
Drop files here or browse4 files at most. Remove one to add another.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| onFiles | (files: File[]) => void | - | The files that passed accept, maxSize and maxFiles, in the order given. The app uploads them. |
| onReject | (rejections: { file: File; reason: "type" | "size" | "count" }[]) => void | - | The files turned away, each with its reason. |
| accept | string | - | What it takes, in the file input's own syntax (image/*,.pdf). Any file when absent. |
| maxSize | number | - | The largest file it takes, in bytes. |
| maxFiles | number | - | How many more files it takes. Past this, the rest are turned away as count. |
| multiple | boolean | true | Several files at a time. false takes one. |
| title | React.ReactNode | - | The line in the zone. Default "Drop files here or browse". |
| hint | React.ReactNode | - | The quiet line under it. Defaults to the size and count limits, when set. |
| icon | IconProp | - | Shown above the title. Default an upload arrow. |
| invalid | boolean | - | Draws the failed edge. |
| disabled | boolean | - | Takes no files and opens no picker. |
| nameDropzoneFile | string | - | The file's name, cut short with the full name on hover. |
| sizeDropzoneFile | number | - | In bytes, shown as a person reads it (1.5 MB). |
| previewDropzoneFile | string | - | A picture of the file, such as an image's object URL. A file icon when absent. |
| statusDropzoneFile | "uploading" | "done" | "error" | "done" | uploading shows a spinner. error shows error in place of the size. |
| errorDropzoneFile | React.ReactNode | - | Why it failed. Default "Could not upload". |
| onRemoveDropzoneFile | () => void | - | Shows a remove button. |
| onFiles(files: File[]) => voidThe files that passed accept, maxSize and maxFiles, in the order given. The app uploads them. |
| onReject(rejections: { file: File; reason: "type" | "size" | "count" }[]) => voidThe files turned away, each with its reason. |
acceptstringWhat it takes, in the file input's own syntax (image/*,.pdf). Any file when absent. |
| maxSizenumberThe largest file it takes, in bytes. |
maxFilesnumberHow many more files it takes. Past this, the rest are turned away as count. |
multipleboolean = trueSeveral files at a time. false takes one. |
| titleReact.ReactNodeThe line in the zone. Default "Drop files here or browse". |
| hintReact.ReactNodeThe quiet line under it. Defaults to the size and count limits, when set. |
| iconIconPropShown above the title. Default an upload arrow. |
| invalidbooleanDraws the failed edge. |
| disabledbooleanTakes no files and opens no picker. |
| nameDropzoneFilestringThe file's name, cut short with the full name on hover. |
| sizeDropzoneFilenumberIn bytes, shown as a person reads it (1.5 MB). |
| previewDropzoneFilestringA picture of the file, such as an image's object URL. A file icon when absent. |
statusDropzoneFile"uploading" | "done" | "error" = "done"uploading shows a spinner. error shows error in place of the size. |
| errorDropzoneFileReact.ReactNodeWhy it failed. Default "Could not upload". |
| onRemoveDropzoneFile() => voidShows a remove button. |
"use client"
import * as React from "react"
import {
IconFile as FileIcon,
IconLoader2 as Loader2,
IconUpload as Upload,
IconX as X,
} from "@tabler/icons-react"
import { cn } from "@/registry/lib/utils"
import { renderIconProp, type IconProp } from "@/registry/lib/icon-prop"
import { Button } from "@/registry/ui/button"
/** Why a file was turned away. */
type DropzoneRejection = { file: File; reason: "type" | "size" | "count" }
type DropzoneProps = Omit<React.ComponentProps<"div">, "children" | "title" | "onDrop"> & {
/** What it takes, in the file input's own syntax (`image/*,.pdf`). Any file when absent. */
accept?: string
/** The largest file it takes, in bytes. */
maxSize?: number
/** How many more files it takes. Past this, the rest are turned away as `count`. */
maxFiles?: number
/** Several files at a time. Default `true`; `false` takes one. */
multiple?: boolean
disabled?: boolean
/** Draws the failed look (a picked file was turned away, or one is required). */
invalid?: boolean
/** The files that passed `accept`, `maxSize` and `maxFiles`, in the order given. */
onFiles: (files: File[]) => void
/** The files turned away, each with its reason. */
onReject?: (rejections: DropzoneRejection[]) => void
/** The line in the zone. Default "Drop files here or browse". */
title?: React.ReactNode
/** The quiet line under it. Defaults to the size and count limits, when set. */
hint?: React.ReactNode
/** Shown above the title. Default an upload arrow. */
icon?: IconProp
/** The input's accessible name when `title` is not plain text. */
"aria-label"?: string
}
/** 1.5 MB, 800 KB: the size a person reads, not the bytes. */
function formatBytes(bytes: number) {
if (bytes >= 1_000_000) return `${+(bytes / 1_000_000).toFixed(1)} MB`
if (bytes >= 1_000) return `${Math.round(bytes / 1_000)} KB`
return `${bytes} B`
}
/** Whether `file` matches the input's own `accept` syntax. */
function accepts(file: File, accept?: string) {
if (!accept?.trim()) return true
const name = file.name.toLowerCase()
const type = file.type.toLowerCase()
return accept.split(",").some((raw) => {
const rule = raw.trim().toLowerCase()
if (!rule) return false
if (rule.startsWith(".")) return name.endsWith(rule)
// A wildcard type (image, then a star) matches the whole family.
const [family, subtype] = rule.split("/")
if (subtype === "*") return type.startsWith(`${family}/`)
return type === rule
})
}
function defaultHint(maxSize?: number, maxFiles?: number, multiple = true) {
const parts = [
maxSize ? `Up to ${formatBytes(maxSize)}${multiple ? " each" : ""}` : null,
multiple && maxFiles ? `${maxFiles} ${maxFiles === 1 ? "file" : "files"} at most` : null,
].filter(Boolean)
return parts.length ? parts.join(", ") : null
}
// The edge is AddTile's: dashed, no fill, and on hover, focus or a held file
// the edge, the icon and the title turn accent. A wash would read as a button.
const zone =
"group/dropzone relative flex w-full cursor-pointer select-none flex-col items-center justify-center gap-1 rounded-lg border border-dashed border-border bg-transparent px-6 py-8 text-center outline-none transition-[color,border-color] duration-small ease-small motion-reduce:transition-none focus-visible:ring-[3px] focus-visible:ring-ring-focus data-[invalid]:border-destructive aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
const accentLook =
"border-accent-500 dark:border-accent-400 [&_[data-slot=dropzone-icon]]:text-accent-500 [&_[data-slot=dropzone-title]]:text-accent-500 dark:[&_[data-slot=dropzone-icon]]:text-accent-400 dark:[&_[data-slot=dropzone-title]]:text-accent-400"
const accentHover =
"hover:border-accent-500 dark:hover:border-accent-400 focus-visible:border-accent-500 dark:focus-visible:border-accent-400 [&:hover_[data-slot=dropzone-icon]]:text-accent-500 [&:hover_[data-slot=dropzone-title]]:text-accent-500 dark:[&:hover_[data-slot=dropzone-icon]]:text-accent-400 dark:[&:hover_[data-slot=dropzone-title]]:text-accent-400"
/**
* A dashed area that takes files dropped on it, or opens the file picker on a
* click, Enter or Space. It checks each file against `accept`, `maxSize` and
* `maxFiles` and hands back the ones that pass (`onFiles`) and the ones that
* did not, with why (`onReject`). It never uploads: the app does, and lists
* what it holds under the zone with `DropzoneFiles` and `DropzoneFile`.
*/
// @use-when a form field that takes files: a drop area for images, documents or any upload, with the files picked listed under it. Not AddTile, which is the small "add one" square at the end of a row of thumbnails.
function Dropzone({
accept,
maxSize,
maxFiles,
multiple = true,
disabled = false,
invalid = false,
onFiles,
onReject,
title = "Drop files here or browse",
hint,
icon,
className,
"aria-label": ariaLabel,
...props
}: DropzoneProps) {
const inputRef = React.useRef<HTMLInputElement>(null)
const hintId = React.useId()
const [over, setOver] = React.useState(false)
// dragenter and dragleave fire for every child crossed, so count them.
const depth = React.useRef(0)
const limit = maxFiles ?? (multiple ? Infinity : 1)
const shownHint = hint === undefined ? defaultHint(maxSize, maxFiles, multiple) : hint
const idle = !disabled
function take(list: FileList | null) {
if (!list?.length || disabled) return
const ok: File[] = []
const rejected: DropzoneRejection[] = []
for (const file of Array.from(list)) {
if (!accepts(file, accept)) rejected.push({ file, reason: "type" })
else if (maxSize !== undefined && file.size > maxSize) rejected.push({ file, reason: "size" })
else if (ok.length >= limit) rejected.push({ file, reason: "count" })
else ok.push(file)
}
if (ok.length) onFiles(ok)
if (rejected.length) onReject?.(rejected)
}
return (
<div
data-slot="dropzone"
data-drag-over={over ? "" : undefined}
role="button"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled || undefined}
// A button role cannot carry aria-invalid, so the failed look rides on
// data-invalid; say why it failed in the hint, which the zone reads out.
data-invalid={invalid ? "" : undefined}
aria-label={ariaLabel ?? (typeof title === "string" ? title : undefined)}
aria-describedby={shownHint ? hintId : undefined}
onClick={() => idle && inputRef.current?.click()}
onKeyDown={(e) => {
if (!idle || (e.key !== "Enter" && e.key !== " ")) return
e.preventDefault()
inputRef.current?.click()
}}
onDragEnter={(e) => {
if (!idle) return
e.preventDefault()
depth.current += 1
setOver(true)
}}
onDragOver={(e) => idle && e.preventDefault()}
onDragLeave={() => {
depth.current = Math.max(0, depth.current - 1)
if (depth.current === 0) setOver(false)
}}
onDrop={(e) => {
e.preventDefault()
depth.current = 0
setOver(false)
take(e.dataTransfer.files)
}}
{...props}
className={cn(zone, idle && accentHover, idle && over && accentLook, className)}
>
<span data-slot="dropzone-icon" className="mb-1 text-muted-foreground transition-colors duration-small ease-small motion-reduce:transition-none [&_svg]:size-5 [&_svg]:[stroke-width:var(--stroke-icon)]">
{icon === undefined ? <Upload aria-hidden /> : renderIconProp(icon, { "aria-hidden": true })}
</span>
<span data-slot="dropzone-title" className="text-sm font-medium text-foreground transition-colors duration-small ease-small motion-reduce:transition-none">
{title}
</span>
{shownHint ? (
<span id={hintId} data-slot="dropzone-hint" className="text-xs text-subtle-foreground">
{shownHint}
</span>
) : null}
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
disabled={disabled}
tabIndex={-1}
aria-hidden
className="hidden"
onChange={(e) => {
take(e.target.files)
// The same file again still fires a change.
e.target.value = ""
}}
/>
</div>
)
}
/** The files a Dropzone's app holds, listed under the zone. */
function DropzoneFiles({ className, ...props }: React.ComponentProps<"ul">) {
return <ul data-slot="dropzone-files" className={cn("flex flex-col gap-2", className)} {...props} />
}
type DropzoneFileProps = Omit<React.ComponentProps<"li">, "children"> & {
name: string
/** In bytes, shown as a person reads it. */
size?: number
/** A picture of the file (an image's own object URL, or a thumbnail). A file icon when absent. */
preview?: string
/** `uploading` shows a spinner; `error` shows `error` in place of the size. Default `done`. */
status?: "uploading" | "done" | "error"
/** Why it failed, when `status` is `error`. */
error?: React.ReactNode
/** Shows a remove button. */
onRemove?: () => void
}
/**
* One file under the zone: its picture or a file icon, its name and size, a
* spinner while it uploads or why it failed, and a remove button.
*/
function DropzoneFile({ name, size, preview, status = "done", error, onRemove, className, ...props }: DropzoneFileProps) {
return (
<li
data-slot="dropzone-file"
data-status={status}
className={cn("flex min-w-0 items-center gap-3 rounded-lg bg-muted p-2", className)}
{...props}
>
<span className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-md bg-background text-muted-foreground outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10 [&_svg]:size-4 [&_svg]:[stroke-width:var(--stroke-icon)]">
{preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt="" className="size-full object-cover" />
) : (
<FileIcon aria-hidden />
)}
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm text-foreground" title={name}>
{name}
</span>
{status === "error" ? (
<span className="text-xs text-destructive">{error ?? "Could not upload"}</span>
) : size !== undefined ? (
<span className="text-xs tabular-nums text-subtle-foreground">{formatBytes(size)}</span>
) : null}
</span>
{status === "uploading" ? (
// motion-exempt: an indeterminate spinner is the only "still working"
// cue, so it keeps spinning under reduced motion.
<Loader2 role="status" aria-label={`Uploading ${name}`} className="size-4 shrink-0 animate-spin text-muted-foreground" />
) : null}
{onRemove ? (
<Button type="button" variant="ghost" size="icon-sm" aria-label={`Remove ${name}`} onClick={onRemove}>
<X aria-hidden />
</Button>
) : null}
</li>
)
}
export { Dropzone, DropzoneFiles, DropzoneFile }
export type { DropzoneProps, DropzoneFileProps, DropzoneRejection }