# File Dropzone

> Drag-and-drop upload zone with type / size checks, file list and progress.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/file-dropzone
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/file-dropzone.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/file-dropzone-demo.tsx"
import { FileDropzone } from "@/components/file-dropzone"

export default function FileDropzoneDemo() {
  return <FileDropzone accept="image/*,.pdf" maxSizeMb={5} />
}
```

## Source

```tsx title="components/file-dropzone.tsx"
"use client"

import * as React from "react"
import { CloudUpload, File as FileGeneric, FileImage, FileText, X } from "lucide-react"

import { cn } from "@/lib/utils"

export interface FileDropzoneProps extends Omit<React.ComponentProps<"div">, "onChange"> {
  onFilesChange?: (files: File[]) => void
  /** Same syntax as the input's accept attribute, e.g. "image/*,.pdf" */
  accept?: string
  multiple?: boolean
  /** Largest accepted file, in MB */
  maxSizeMb?: number
  maxFiles?: number
  title?: string
  hint?: string
  /** Show a progress bar per file while the upload is simulated */
  simulateUpload?: boolean
}

type Item = { id: number; file: File; progress: number; error?: string }

function formatSize(bytes: number) {
  if (bytes < 1024) return `${bytes} B`
  if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(0)} KB`
  return `${(bytes / 1024 ** 2).toFixed(1)} MB`
}

function matchesAccept(file: File, accept: string) {
  if (!accept) return true
  return accept.split(",").some((rule) => {
    const pattern = rule.trim().toLowerCase()
    if (pattern.startsWith(".")) return file.name.toLowerCase().endsWith(pattern)
    if (pattern.endsWith("/*")) return file.type.startsWith(pattern.slice(0, -1))
    return file.type === pattern
  })
}

function FileIcon({ type }: { type: string }) {
  const Icon = type.startsWith("image/") ? FileImage : type.startsWith("text/") || type === "application/pdf" ? FileText : FileGeneric
  return <Icon className="size-4" />
}

export function FileDropzone({
  onFilesChange,
  accept = "",
  multiple = true,
  maxSizeMb = 10,
  maxFiles = 5,
  title = "Drop files here or click to browse",
  hint,
  simulateUpload = true,
  className,
  ...props
}: FileDropzoneProps) {
  const [items, setItems] = React.useState<Item[]>([])
  const [dragging, setDragging] = React.useState(false)
  const input = React.useRef<HTMLInputElement>(null)
  const nextId = React.useRef(0)
  const timers = React.useRef(new Set<ReturnType<typeof setInterval>>())

  React.useEffect(() => {
    const pending = timers.current
    return () => pending.forEach(clearInterval)
  }, [])

  const upload = (id: number) => {
    const timer = setInterval(() => {
      setItems((all) =>
        all.map((item) => (item.id === id ? { ...item, progress: Math.min(100, item.progress + 8 + Math.random() * 18) } : item))
      )
    }, 180)
    timers.current.add(timer)
    setTimeout(() => {
      clearInterval(timer)
      timers.current.delete(timer)
      setItems((all) => all.map((item) => (item.id === id ? { ...item, progress: 100 } : item)))
    }, 2400)
  }

  const addFiles = (list: FileList | null) => {
    if (!list) return
    const incoming = [...list].slice(0, multiple ? maxFiles - items.length : 1)
    const added = incoming.map<Item>((file) => ({
      id: nextId.current++,
      file,
      progress: simulateUpload ? 0 : 100,
      error: !matchesAccept(file, accept)
        ? "File type not allowed"
        : file.size > maxSizeMb * 1024 ** 2
          ? `Larger than ${maxSizeMb} MB`
          : undefined,
    }))
    const next = multiple ? [...items, ...added] : added
    setItems(next)
    onFilesChange?.(next.filter((item) => !item.error).map((item) => item.file))
    if (simulateUpload) added.filter((item) => !item.error).forEach((item) => upload(item.id))
  }

  const remove = (id: number) => {
    const next = items.filter((item) => item.id !== id)
    setItems(next)
    onFilesChange?.(next.filter((item) => !item.error).map((item) => item.file))
  }

  return (
    <div className={cn("flex w-full max-w-md flex-col gap-3", className)} {...props}>
      <button
        type="button"
        onClick={() => input.current?.click()}
        onDragEnter={(event) => {
          event.preventDefault()
          setDragging(true)
        }}
        onDragOver={(event) => event.preventDefault()}
        onDragLeave={(event) => {
          if (!event.currentTarget.contains(event.relatedTarget as Node)) setDragging(false)
        }}
        onDrop={(event) => {
          event.preventDefault()
          setDragging(false)
          addFiles(event.dataTransfer.files)
        }}
        className={cn(
          "flex flex-col items-center gap-3 rounded-2xl border-2 border-dashed bg-muted/30 px-6 py-10 text-center transition-colors outline-none hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring",
          dragging && "border-ring bg-ring/5"
        )}
      >
        <span
          className={cn(
            "flex size-12 items-center justify-center rounded-full border bg-background shadow-xs transition-transform duration-300",
            dragging && "-translate-y-1 scale-110"
          )}
        >
          <CloudUpload className="size-5 text-muted-foreground" />
        </span>
        <span className="text-sm font-medium">{title}</span>
        <span className="text-xs text-muted-foreground">
          {hint ?? `${accept ? accept.replaceAll(",", ", ") : "Any file"} · up to ${maxSizeMb} MB${multiple ? ` · max ${maxFiles} files` : ""}`}
        </span>
      </button>
      <input
        ref={input}
        type="file"
        accept={accept || undefined}
        multiple={multiple}
        hidden
        onChange={(event) => {
          addFiles(event.target.files)
          event.target.value = ""
        }}
      />
      {items.length > 0 && (
        <ul className="flex flex-col gap-2">
          {items.map((item) => (
            <li key={item.id} className="flex items-center gap-3 rounded-xl border bg-card p-3 animate-in fade-in slide-in-from-top-1">
              <span
                className={cn(
                  "flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground",
                  item.error && "bg-destructive/10 text-destructive"
                )}
              >
                <FileIcon type={item.file.type} />
              </span>
              <div className="min-w-0 flex-1">
                <div className="flex items-center justify-between gap-2 text-sm">
                  <span className="truncate font-medium">{item.file.name}</span>
                  <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
                    {item.error ? "" : item.progress < 100 ? `${Math.floor(item.progress)}%` : formatSize(item.file.size)}
                  </span>
                </div>
                {item.error ? (
                  <p className="mt-0.5 text-xs text-destructive">{item.error}</p>
                ) : (
                  <div className="mt-1.5 h-1 overflow-hidden rounded-full bg-muted">
                    <div
                      className={cn("h-full rounded-full transition-[width] duration-200", item.progress < 100 ? "bg-primary" : "bg-emerald-500")}
                      style={{ width: `${item.progress}%` }}
                    />
                  </div>
                )}
              </div>
              <button
                type="button"
                onClick={() => remove(item.id)}
                aria-label={`Remove ${item.file.name}`}
                className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
              >
                <X className="size-4" />
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}
```
