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.
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| maxFiles | number | — | Cap on total files; extra dropped files are rejected. |
| accept | string | — | Accepted types (extensions/MIME/wildcards). |
| maxSize | number | — | Max size per file in bytes. |
| label | string | — | Field label. |
| hint | string | — | Helper text below the field. |
| error | string | — | Field-level error. |
| disabled | boolean | — | 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 };
}