Components

File Dropzone

A keyboard-accessible drag-and-drop upload area with client-side validation, upload progress, and a controlled file list.

input

Installation

npx cano-ui add file-dropzone

Installs button, progress, lucide-react automatically if missing.

Source

"use client"

import * as React from "react"
import { AlertCircle, CheckCircle2, File, Upload, X } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"

export interface DropzoneFile {
  id: string
  name: string
  size: number
  /** 0–100, shown while status is "uploading". */
  progress?: number
  status: "uploading" | "done" | "error"
  error?: string
}

interface RejectedFile {
  name: string
  reason: string
}

export function formatBytes(bytes: number, decimals = 1): string {
  if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"
  const k = 1024
  const units = ["B", "KB", "MB", "GB", "TB"]
  const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), units.length - 1)
  const value = bytes / Math.pow(k, i)
  return `${value.toFixed(i === 0 ? 0 : decimals)} ${units[i]}`
}

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

export interface FileDropzoneProps {
  /** Comma-separated accept list, e.g. ".pdf,image/*". */
  accept?: string
  /** Maximum file size in bytes. */
  maxSize?: number
  /** Maximum total number of files (including the controlled list). */
  maxFiles?: number
  multiple?: boolean
  disabled?: boolean
  onFilesAccepted: (files: globalThis.File[]) => void
  /** Controlled list of files rendered below the drop area. */
  files?: DropzoneFile[]
  onRemove?: (id: string) => void
  /** Hint line under the main label, e.g. "PNG or PDF up to 10 MB". */
  hint?: string
  className?: string
}

function FileDropzone({
  accept,
  maxSize,
  maxFiles,
  multiple = true,
  disabled = false,
  onFilesAccepted,
  files = [],
  onRemove,
  hint,
  className,
}: FileDropzoneProps) {
  const inputRef = React.useRef<HTMLInputElement>(null)
  const [isDragging, setIsDragging] = React.useState(false)
  const [rejections, setRejections] = React.useState<RejectedFile[]>([])

  function openFileDialog() {
    if (!disabled) inputRef.current?.click()
  }

  function handleFiles(fileList: FileList | null) {
    if (disabled || !fileList || fileList.length === 0) return

    const incoming = multiple ? Array.from(fileList) : Array.from(fileList).slice(0, 1)
    const accepted: globalThis.File[] = []
    const rejected: RejectedFile[] = []
    let remaining =
      maxFiles !== undefined
        ? Math.max(0, maxFiles - files.length)
        : Number.POSITIVE_INFINITY

    for (const file of incoming) {
      if (!matchesAccept(file, accept)) {
        rejected.push({ name: file.name, reason: "File type is not allowed." })
        continue
      }
      if (maxSize !== undefined && file.size > maxSize) {
        rejected.push({
          name: file.name,
          reason: `File is larger than ${formatBytes(maxSize, 0)}.`,
        })
        continue
      }
      if (remaining <= 0) {
        rejected.push({
          name: file.name,
          reason: `File limit reached (max ${maxFiles} files).`,
        })
        continue
      }
      accepted.push(file)
      remaining -= 1
    }

    setRejections(rejected)
    if (accepted.length > 0) onFilesAccepted(accepted)
  }

  return (
    <div className={cn("flex flex-col gap-3", className)}>
      <div
        role="button"
        tabIndex={disabled ? -1 : 0}
        aria-label="Upload files: drag files here or activate to browse"
        aria-disabled={disabled || undefined}
        onClick={openFileDialog}
        onKeyDown={(event) => {
          if (event.key === "Enter" || event.key === " ") {
            event.preventDefault()
            openFileDialog()
          }
        }}
        onDragOver={(event) => {
          event.preventDefault()
          if (!disabled) setIsDragging(true)
        }}
        onDragLeave={() => setIsDragging(false)}
        onDrop={(event) => {
          event.preventDefault()
          setIsDragging(false)
          handleFiles(event.dataTransfer.files)
        }}
        className={cn(
          "flex cursor-pointer flex-col items-center justify-center gap-3 rounded-lg border border-dashed px-6 py-10 text-center transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
          isDragging && "border-primary bg-accent/50",
          disabled && "pointer-events-none opacity-50"
        )}
      >
        <div className="flex size-10 items-center justify-center rounded-full bg-muted">
          <Upload className="size-4 text-muted-foreground" aria-hidden="true" />
        </div>
        <div>
          <p className="text-sm font-medium text-foreground">
            Drag files here or click to browse
          </p>
          {hint ? (
            <p className="mt-1 text-xs text-muted-foreground">{hint}</p>
          ) : null}
        </div>
        <input
          ref={inputRef}
          type="file"
          accept={accept}
          multiple={multiple}
          disabled={disabled}
          tabIndex={-1}
          aria-hidden="true"
          className="sr-only"
          onChange={(event) => {
            handleFiles(event.target.files)
            event.target.value = ""
          }}
        />
      </div>

      <div aria-live="polite" className="flex flex-col gap-1 empty:hidden">
        {rejections.map((rejection) => (
          <p
            key={`${rejection.name}-${rejection.reason}`}
            className="flex items-center gap-2 text-sm text-destructive"
          >
            <AlertCircle className="size-4 shrink-0" aria-hidden="true" />
            <span className="min-w-0 truncate">
              {rejection.name}: {rejection.reason}
            </span>
          </p>
        ))}
      </div>

      {files.length > 0 ? (
        <ul aria-live="polite" className="flex flex-col gap-2">
          {files.map((file) => (
            <li
              key={file.id}
              className="flex items-center gap-3 rounded-md border p-3"
            >
              <File
                className="size-4 shrink-0 text-muted-foreground"
                aria-hidden="true"
              />
              <div className="min-w-0 flex-1">
                <div className="flex items-baseline justify-between gap-2">
                  <p className="truncate text-sm font-medium">{file.name}</p>
                  <span className="shrink-0 text-xs text-muted-foreground">
                    {formatBytes(file.size)}
                  </span>
                </div>
                {file.status === "uploading" ? (
                  <Progress
                    value={file.progress ?? 0}
                    className="mt-2 h-1"
                    aria-label={`Uploading ${file.name}`}
                  />
                ) : null}
                {file.status === "error" && file.error ? (
                  <p className="mt-1 text-xs text-destructive">{file.error}</p>
                ) : null}
              </div>
              {file.status === "done" ? (
                <CheckCircle2 className="size-4 shrink-0" aria-hidden="true" />
              ) : null}
              {file.status === "error" ? (
                <AlertCircle
                  className="size-4 shrink-0 text-destructive"
                  aria-hidden="true"
                />
              ) : null}
              {onRemove ? (
                <Button
                  type="button"
                  variant="ghost"
                  size="icon"
                  className="size-7 shrink-0"
                  aria-label={`Remove ${file.name}`}
                  onClick={() => onRemove(file.id)}
                >
                  <X aria-hidden="true" />
                </Button>
              ) : null}
            </li>
          ))}
        </ul>
      ) : null}
    </div>
  )
}

export { FileDropzone }

This exact file lands in your project at components/ui/file-dropzone.tsx. It is yours to edit.