Upload
A dropzone upload component with local validation, drag and drop, and a decoupled upload monitor hook
Installation
Usage
Basic Usage
The Upload component handles selection, drag and drop and local validation only — it performs no network requests and keeps no upload state. Pair it with the useUploadMonitor hook to execute uploads and track progress.
"use client";
import * as React from "react";
import { List, Upload, useUploadMonitor, type UploadActions, type UploadReject } from "@/ui";
class MockXMLHttpRequest {
status = 0;
withCredentials = false;
upload = { onprogress: null as ((event: ProgressEvent) => void) | null };
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
onabort: (() => void) | null = null;
private timer: ReturnType<typeof setInterval> | null = null;
private loaded = 0;
private total = 4 * 1024 * 1024;
open() {}
setRequestHeader() {}
send() {
this.timer = setInterval(() => {
this.loaded = Math.min(this.loaded + this.total / 25, this.total);
this.upload.onprogress?.({
lengthComputable: true,
loaded: this.loaded,
total: this.total,
} as ProgressEvent);
if (this.loaded >= this.total) this.finish();
}, 120);
}
abort() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
this.onabort?.();
}
}
private finish() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
this.status = 200;
this.onload?.();
}
}
export default function UploadBasicDemo() {
const actionsRef = React.useRef<UploadActions | null>(null);
const [rejects, setRejects] = React.useState<UploadReject[]>([]);
const upload = useUploadMonitor({ action: "mock://upload" });
React.useEffect(() => {
const Original = window.XMLHttpRequest;
window.XMLHttpRequest = MockXMLHttpRequest as unknown as typeof XMLHttpRequest;
return () => {
window.XMLHttpRequest = Original;
};
}, []);
return (
<div className="flex w-full max-w-md flex-col gap-4">
<Upload
multiple
maxCount={5}
maxSize={2 * 1024 * 1024}
actionsRef={actionsRef}
onFilesAccepted={upload.acceptFiles}
onFilesRejected={setRejects}
onFileRemove={upload.removeItem}
/>
{rejects.length > 0 && (
<ul className="space-y-1 text-sm text-danger">
{rejects.map((reject, index) => (
<li key={index}>{reject.message}</li>
))}
</ul>
)}
<List
className="max-h-48"
items={upload.fileList}
getKey={(item) => item.uid}
empty={
<div className="px-3 py-4 text-center text-muted-foreground">No files</div>
}
renderItem={(item) => (
<>
<span className="flex-1 truncate">{item.file.name}</span>
<span className="w-24 text-right text-muted-foreground">
{item.status === "uploading" ? `${item.progress}%` : item.status}
</span>
<button
type="button"
className="text-danger hover:underline"
onClick={() => actionsRef.current?.remove(item.uid)}
>
Remove
</button>
</>
)}
/>
</div>
);
}
Restrictions
accept, maxSize and maxCount are validated locally; rejected files are reported via onFilesRejected.
Accepted: 0 · Rejected: 0 · Images and PDF up to 1 MB, max 4 files
"use client";import * as React from "react";import { Upload, type UploadItem, type UploadReject } from "@/ui";export default function UploadMultipleDemo() { const [items, setItems] = React.useState<UploadItem[]>([]); const [rejects, setRejects] = React.useState<UploadReject[]>([]); return ( <div className="flex w-full max-w-md flex-col gap-3"> <Upload multiple accept="image/*,.pdf" maxCount={4} maxSize={1024 * 1024} onFilesAccepted={(accepted) => setItems((prev) => [...prev, ...accepted])} onFilesRejected={setRejects} /> <p className="text-sm text-muted-foreground"> Accepted: {items.length} · Rejected: {rejects.length} · Images and PDF up to 1 MB, max 4 files </p> </div> );}Custom
Assemble the parts manually for full control over the dropzone content and behavior.
"use client";
import * as React from "react";
import { UploadDropzone, UploadHiddenInput, UploadRoot } from "@/ui";
export default function UploadCustomDemo() {
const inputRef = React.useRef<HTMLInputElement>(null);
const [name, setName] = React.useState("");
return (
<UploadRoot className="w-full max-w-md">
<UploadHiddenInput
ref={inputRef}
accept="image/*"
aria-label="Upload image"
onChange={(e) => setName(e.target.files?.[0]?.name ?? "")}
/>
<UploadDropzone className="h-24" onClick={() => inputRef.current?.click()}>
<span className="font-medium">{name || "Custom dropzone content"}</span>
</UploadDropzone>
</UploadRoot>
);
}
API Reference
High-level Components
Upload
A dropzone built with the parts below, encapsulating validation and file item lifecycle.
| Prop | Type | Default | Description |
|---|---|---|---|
accept | string | - | Accepted file types (MIME types, image/* or extensions like .pdf) |
multiple | boolean | false | Allow selecting multiple files |
maxCount | number | - | Maximum number of simultaneously accepted items |
maxSize | number | - | Maximum file size in bytes |
disabled | boolean | false | Disables click, keyboard and drag interactions |
children | React.ReactNode | - | Custom dropzone content; defaults to a hint text |
onFilesAccepted | (items: UploadItem[]) => void | - | Called with validated items, each carrying { uid, file, signal } |
onFilesRejected | (rejects: UploadReject[]) => void | - | Called with rejected files and reasons (accept / maxSize / maxCount) |
onFileRemove | (uid: string) => void | - | Called after actionsRef.current.remove(uid) aborts the item's controller |
onDragEnter | (e: React.DragEvent) => void | - | Called when files are dragged into the dropzone |
onDragLeave | (e: React.DragEvent) => void | - | Called when files are dragged out of the dropzone |
actionsRef | React.Ref<UploadActions> | - | Imperative handle exposing remove(uid) and clear(); remove aborts the item's controller before firing onFileRemove |
className | ClassNameValue | - | Custom class for the root |
style | React.CSSProperties | - | Custom inline style for the root |
classNames | { dropzone?; hiddenInput? } | - | Custom classes for each part |
styles | { dropzone?; hiddenInput? } | - | Custom inline styles for each part |
All controllers are aborted when the component unmounts.
Composable Components
Pure display parts with no built-in state or interaction, for advanced custom assembly.
UploadRoot
The outermost container.
UploadDropzone
The dropzone surface; renders as a div with role="button" styles left to the consumer to wire.
UploadHiddenInput
The hidden native input[type=file]; the consumer wires onChange and triggers click() externally.