Particular.Tech
Formsui

Dropzone

Drag-and-drop file upload with type, size, and count validation.

Installation

npx shadcn@latest add @particular/dropzone
npm dependencies
  • cn@^0.4.0
  • lucide-react@^1.51.0

Source

ui/dropzone.tsx
"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 }