FileUploadMultiple

Drag-and-drop + click picker for many files, each with a type-aware preview, per-file progress and per-file error.

import { FileUploadMultiple }from "@/components/ui"

Specification

Uploads multiple files. Each file gets its own row with a preview, progress, and error. The drop zone stays visible so users can keep adding files. The parent owns the items list and drives per-file status/progress.

Anatomy

  • Persistent drop zone with count (e.g. 2/5 files)
  • One row per file: preview, name, size, per-file progress/error, remove

States

  • Idle / drag over (accent border)
  • At limit: zone disabled once maxFiles reached
  • Per-file: uploading (progress), complete (check), error (red row + reason)

Accessibility

  • Hidden multiple <input type=file>
  • Drop zone role=button, tabindex=0, keyboard-activated
  • Per-file progress and errors announced via aria-live

Spec sourced from mathesis ui-component/file-upload

Props (11)
Every prop this component accepts, its type, default, and how to use it.
PropTypeDefaultDescription
items*UploadItem[]—Current files (controlled). Each item carries file, status, progress, error.
onAdd(files: File[]) => void—Called with newly accepted files to append.
onRemove(id: string) => void—Called to remove an item by id.
onReject(rejections: { file: File; reason: string }[]) => void—Called with files that failed validation and why.
maxFilesnumber—Cap on total files; extra dropped files are rejected.
acceptstring—Accepted types (extensions/MIME/wildcards).
maxSizenumber—Max size per file in bytes.
labelstring—Field label.
hintstring—Helper text below the field.
errorstring—Field-level error.
disabledboolean—Disables the picker and drop zone.

Multiple attachments

Up to 5 files; each row has its own remove control.

Click to browse or drag and drop

.pdf,.png,.jpg,.jpeg,.csv,.xlsx · up to 10.0 MB · 0/5 files

 

Full source
The complete component, read from src/components/ui/file-upload-multiple.tsx.
file-upload-multiple.tsx
"use client";

import { useState, useRef, useEffect, useId, type DragEvent, type ChangeEvent } from "react";
import { UploadCloud, X, CheckCircle2, AlertCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import {
  formatFileSize,
  fileKind,
  fileIcon,
  validateFile,
  type FileConstraints,
} from "@/lib/upload";
import type { UploadStatus } from "./file-upload";
import { Field } from "./field";
import { Progress } from "./progress";

/**
 * One tracked upload. The parent owns the list and updates `status`/`progress`/
 * `error` per item as its upload logic runs.
 */
export interface UploadItem {
  /** Stable id (e.g. crypto.randomUUID()). */
  id: string;
  file: File;
  status?: UploadStatus;
  progress?: number;
  error?: string;
}

interface FileUploadMultipleProps {
  id?: string;
  label?: string;
  hint?: string;
  error?: string;
  required?: boolean;
  disabled?: boolean;
  accept?: string;
  /** Max size per file, in bytes. */
  maxSize?: number;
  /** Max number of files. Extra dropped files are rejected via `onReject`. */
  maxFiles?: number;
  /** Current items (controlled). */
  items: UploadItem[];
  /** Called with the newly accepted files to append. */
  onAdd?: (files: File[]) => void;
  /** Called to remove an item by id. */
  onRemove?: (id: string) => void;
  /** Called when files are rejected, with per-file reasons. */
  onReject?: (rejections: { file: File; reason: string }[]) => void;
}

/**
 * FileUploadMultiple — drag-and-drop + click picker for many files. Each file
 * gets its own row with a type-aware preview, per-file progress, and per-file
 * error. The drop zone stays visible so users can keep adding files.
 * Presentational: the parent owns `items` and drives status/progress.
 */
export function FileUploadMultiple({
  id,
  label,
  hint,
  error,
  required,
  disabled,
  accept,
  maxSize,
  maxFiles,
  items,
  onAdd,
  onRemove,
  onReject,
}: FileUploadMultipleProps) {
  const reactId = useId();
  const inputId = id ?? reactId;
  const messageId = `${inputId}-message`;
  const inputRef = useRef<HTMLInputElement>(null);
  const [dragOver, setDragOver] = useState(false);

  const constraints: FileConstraints = { accept, maxSize };
  const atLimit = typeof maxFiles === "number" && items.length >= maxFiles;

  const intake = (files: File[]) => {
    const accepted: File[] = [];
    const rejected: { file: File; reason: string }[] = [];
    let remaining = typeof maxFiles === "number" ? maxFiles - items.length : Infinity;

    for (const file of files) {
      if (remaining <= 0) {
        rejected.push({ file, reason: `Too many files — max ${maxFiles}.` });
        continue;
      }
      const message = validateFile(file, constraints);
      if (message) {
        rejected.push({ file, reason: message });
      } else {
        accepted.push(file);
        remaining -= 1;
      }
    }
    if (accepted.length) onAdd?.(accepted);
    if (rejected.length) onReject?.(rejected);
  };

  const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) intake(Array.from(e.target.files));
    e.target.value = "";
  };

  const onDrop = (e: DragEvent) => {
    e.preventDefault();
    setDragOver(false);
    if (disabled || atLimit) return;
    if (e.dataTransfer.files) intake(Array.from(e.dataTransfer.files));
  };

  const openPicker = () => !disabled && !atLimit && inputRef.current?.click();

  return (
    <Field label={label} hint={hint} error={error} required={required} htmlFor={inputId} messageId={messageId}>
      <input
        ref={inputRef}
        id={inputId}
        type="file"
        multiple
        accept={accept}
        disabled={disabled}
        onChange={onInputChange}
        className="sr-only"
        aria-describedby={messageId}
      />

      <div className="space-y-3">
        <div
          role="button"
          tabIndex={disabled || atLimit ? -1 : 0}
          aria-label={label ? `Upload ${label}` : "Upload files"}
          aria-disabled={disabled || atLimit || undefined}
          onClick={openPicker}
          onKeyDown={(e) => {
            if (e.key === "Enter" || e.key === " ") {
              e.preventDefault();
              openPicker();
            }
          }}
          onDragOver={(e) => {
            e.preventDefault();
            if (!disabled && !atLimit) setDragOver(true);
          }}
          onDragLeave={() => setDragOver(false)}
          onDrop={onDrop}
          className={cn(
            "flex flex-col items-center justify-center gap-2 rounded-xl border border-dashed px-6 py-8 text-center transition-colors",
            "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
            disabled || atLimit
              ? "cursor-not-allowed border-border bg-bg-secondary opacity-60"
              : "cursor-pointer border-border hover:border-border-hover hover:bg-bg-hover",
            dragOver && !disabled && !atLimit && "border-accent bg-accent-light",
            error && "border-error"
          )}
        >
          <UploadCloud size={24} className={cn("transition-colors", dragOver ? "text-accent" : "text-text-tertiary")} aria-hidden />
          <div className="text-sm text-text">
            <span className="font-medium text-accent">Click to browse</span>{" "}
            <span className="text-text-secondary">or drag and drop</span>
          </div>
          <p className="text-xs text-text-tertiary">
            {accept ? accept : "Any file"}
            {maxSize ? ` · up to ${formatFileSize(maxSize)}` : ""}
            {maxFiles ? ` · ${items.length}/${maxFiles} files` : ""}
          </p>
        </div>

        {items.length > 0 && (
          <ul className="space-y-2">
            {items.map((item) => (
              <li key={item.id}>
                <UploadRow item={item} disabled={disabled} onRemove={() => onRemove?.(item.id)} />
              </li>
            ))}
          </ul>
        )}
      </div>
    </Field>
  );
}

function UploadRow({ item, disabled, onRemove }: { item: UploadItem; disabled?: boolean; onRemove: () => void }) {
  const { file, status = "idle", progress = 0, error } = item;
  const kind = fileKind(file.name, file.type);
  const Icon = fileIcon(kind);
  const [thumb, setThumb] = useState<string | null>(null);

  useEffect(() => {
    if (kind === "image") {
      const url = URL.createObjectURL(file);
      setThumb(url);
      return () => URL.revokeObjectURL(url);
    }
    setThumb(null);
  }, [file, kind]);

  const failed = Boolean(error) || status === "error";

  return (
    <div className={cn("flex items-center gap-3 rounded-lg border p-2.5", failed ? "border-error bg-error-light" : "border-border bg-surface")}>
      <div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-md bg-bg-secondary">
        {thumb ? (
          // eslint-disable-next-line @next/next/no-img-element
          <img src={thumb} alt="" className="size-full object-cover" />
        ) : (
          <Icon size={18} className="text-text-secondary" aria-hidden />
        )}
      </div>

      <div className="min-w-0 flex-1">
        <div className="flex items-center gap-2">
          <p className="truncate text-sm font-medium text-text">{file.name}</p>
          {(status === "complete" || status === "idle") && !failed && (
            <CheckCircle2 size={14} className="shrink-0 text-accent" aria-hidden />
          )}
          {failed && <AlertCircle size={14} className="shrink-0 text-error" aria-hidden />}
        </div>
        {failed ? (
          <p className="text-xs text-error" aria-live="polite">{error ?? "Upload failed."}</p>
        ) : (
          <p className="text-xs tabular-nums text-text-tertiary">
            {formatFileSize(file.size)}
            {status === "uploading"
              ? ` · ${Math.round(progress)}%`
              : status === "complete"
                ? " · Uploaded"
                : " · Ready to upload"}
          </p>
        )}
        {status === "uploading" && !failed && (
          <div className="mt-1.5" aria-live="polite">
            <Progress value={progress} />
          </div>
        )}
      </div>

      {!disabled && status !== "uploading" && (
        <button
          type="button"
          onClick={onRemove}
          aria-label={`Remove ${file.name}`}
          className="shrink-0 rounded-md p-1 text-text-tertiary transition-colors hover:bg-bg-hover hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
        >
          <X size={15} aria-hidden />
        </button>
      )}
    </div>
  );
}

Dependencies

To use FileUploadMultiple you also need the following. Copy these into your project alongside it.

Related components

Recommended folder structure

project structure
src/components/ui/
  ├─ field.tsx
  ├─ file-upload-multiple.tsx
  ├─ file-upload.tsx
  └─ progress.tsx
src/lib/
  ├─ upload.ts
  └─ utils.ts

Dependency files

These supporting files aren’t components — copy each one into the matching path above.

src/components/ui/field.tsx
field.tsx
"use client";

import { type ReactNode } from "react";
import { cn } from "@/lib/utils";

interface FieldCounter {
  text: string;
  warn?: boolean;
}

interface FieldProps {
  label?: string;
  hint?: string;
  error?: string;
  required?: boolean;
  htmlFor?: string;
  counter?: FieldCounter;

  messageId?: string;
  children: ReactNode;
}

export function Field({
  label,
  hint,
  error,
  required,
  htmlFor,
  counter,
  messageId,
  children,
}: FieldProps) {
  const message = error || hint;
  return (
    <div className="flex w-full flex-col gap-1.5">
      {label && (
        <label
          htmlFor={htmlFor}
          className="flex items-center gap-1 text-[13px] font-medium text-text"
        >
          {label}
          {required && (
            <span className="text-error" aria-hidden>
              *
            </span>
          )}
        </label>
      )}
      {children}
      <div className="flex items-start justify-between gap-3">
        <p
          id={messageId}
          className={cn("text-xs leading-snug", error ? "text-error" : "text-text-secondary")}
        >
          {message || "\u00A0"}
        </p>
        {counter && (
          <span
            className={cn(
              "shrink-0 text-xs tabular-nums",
              counter.warn ? "text-error" : "text-text-tertiary"
            )}
          >
            {counter.text}
          </span>
        )}
      </div>
    </div>
  );
}

export const controlBase = cn(
  "w-full rounded-lg border text-sm bg-surface text-text transition-colors",
  "placeholder:text-text-tertiary",
  "focus:outline-none focus-visible:outline-none focus-visible:border-accent",
  "disabled:cursor-not-allowed disabled:bg-bg-secondary disabled:opacity-60",
  "read-only:bg-bg-secondary"
);

export function controlClasses(error?: string, disabled?: boolean, readOnly?: boolean) {
  return cn(
    error ? "border-error focus-visible:border-error" : "border-border",
    readOnly && "read-only:bg-bg-secondary",
    disabled && "text-text-tertiary"
  );
}
src/lib/upload.ts
upload.ts
import {
  FileText,
  FileSpreadsheet,
  FileImage,
  FileArchive,
  FileVideo,
  FileAudio,
  FileCode,
  File as FileIcon,
  type LucideIcon,
} from "lucide-react";

/**
 * Shared, framework-agnostic helpers for the upload components
 * (FileUpload, FileUploadMultiple, PhotoUpload) and DocumentsTable.
 * Pure functions — safe to call during render.
 */

/** Broad file kind used for preview + icon selection. */
export type FileKind = "image" | "document" | "spreadsheet" | "archive" | "video" | "audio" | "code" | "other";

/** Format a byte count as a human-readable size, e.g. `2.4 MB`. */
export function formatFileSize(bytes: number): string {
  if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
  const units = ["B", "KB", "MB", "GB", "TB"];
  const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
  const value = bytes / 1024 ** i;
  // No decimals for bytes; one decimal otherwise.
  return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`;
}

/** Lowercased extension without the dot (e.g. `pdf`), or `""` when none. */
export function fileExtension(name: string): string {
  const dot = name.lastIndexOf(".");
  return dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
}

const EXT_KIND: Record<string, FileKind> = {
  // documents
  pdf: "document", doc: "document", docx: "document", txt: "document", rtf: "document", md: "document",
  // spreadsheets
  xls: "spreadsheet", xlsx: "spreadsheet", csv: "spreadsheet",
  // images
  png: "image", jpg: "image", jpeg: "image", gif: "image", webp: "image", svg: "image", avif: "image", heic: "image",
  // archives
  zip: "archive", rar: "archive", "7z": "archive", tar: "archive", gz: "archive",
  // media
  mp4: "video", mov: "video", webm: "video", avi: "video", mkv: "video",
  mp3: "audio", wav: "audio", ogg: "audio", m4a: "audio",
  // code
  js: "code", ts: "code", tsx: "code", jsx: "code", json: "code", html: "code", css: "code",
};

/** Classify a file by MIME type first, falling back to its extension. */
export function fileKind(nameOrType: string, mime?: string): FileKind {
  if (mime) {
    if (mime.startsWith("image/")) return "image";
    if (mime.startsWith("video/")) return "video";
    if (mime.startsWith("audio/")) return "audio";
    if (mime === "application/pdf") return "document";
    if (mime.includes("spreadsheet") || mime.includes("excel") || mime === "text/csv") return "spreadsheet";
    if (mime.includes("zip") || mime.includes("compressed") || mime.includes("tar")) return "archive";
  }
  return EXT_KIND[fileExtension(nameOrType)] ?? "other";
}

const KIND_ICON: Record<FileKind, LucideIcon> = {
  image: FileImage,
  document: FileText,
  spreadsheet: FileSpreadsheet,
  archive: FileArchive,
  video: FileVideo,
  audio: FileAudio,
  code: FileCode,
  other: FileIcon,
};

/** The lucide icon that represents a file kind. */
export function fileIcon(kind: FileKind): LucideIcon {
  return KIND_ICON[kind];
}

export interface FileConstraints {
  /** Max size per file, in bytes. */
  maxSize?: number;
  /**
   * Accepted types, in the same syntax as the input `accept` attribute:
   * extensions (".pdf"), MIME types ("image/png"), or wildcards ("image/*").
   */
  accept?: string;
}

/**
 * Validate a file against constraints. Returns a human-readable error string
 * ("what + why"), or `null` when the file is acceptable.
 */
export function validateFile(file: File, constraints: FileConstraints = {}): string | null {
  const { maxSize, accept } = constraints;
  if (typeof maxSize === "number" && file.size > maxSize) {
    return `File is too large — max ${formatFileSize(maxSize)}.`;
  }
  if (accept && !matchesAccept(file, accept)) {
    return `File type not allowed — accepted: ${accept}.`;
  }
  return null;
}

/** Whether a file satisfies an `accept` list (extensions, MIME, wildcards). */
export function matchesAccept(file: File, accept: string): boolean {
  const patterns = accept.split(",").map((p) => p.trim().toLowerCase()).filter(Boolean);
  if (patterns.length === 0) return true;
  const type = file.type.toLowerCase();
  const ext = `.${fileExtension(file.name)}`;
  return patterns.some((p) => {
    if (p.startsWith(".")) return ext === p;
    if (p.endsWith("/*")) return type.startsWith(p.slice(0, -1)); // e.g. "image/"
    return type === p;
  });
}
src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

/**
 * Merge class names with clsx semantics + Tailwind conflict resolution.
 * Prefer this in all components.
 */
export function cn(...inputs: ClassValue[]): string {
  return twMerge(clsx(inputs));
}

/**
 * Concatenate class names, filtering out falsy values.
 * @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
 */
export function cx(...args: (string | false | null | undefined)[]): string {
  return args.filter(Boolean).join(" ");
}

/**
 * Focus ring style helper.
 * @deprecated Focus rings are now handled globally via the
 * `:focus-visible` rule in `globals.css` and the `focus-visible:*`
 * utilities. This remains only for backwards compatibility.
 */
export function ring(color: string = "var(--color-accent)") {
  return { outlineColor: color };
}