13 / Files
File Upload
A file picker and dropzone with clear validation and a visible queue. Connect your upload function when you need progress, cancel, and retry.
Drop files here
"use client"
import { FileUpload } from "mischief-ui/file-upload"
export function FileUploadDemo() {
return (
<FileUpload
accept="image/*,.pdf"
className="mx-auto w-full max-w-2xl"
description="Images or PDF · Up to 10 MB each"
/>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/file-uploadimport { FileUpload } from "mischief-ui/file-upload"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { Check, File, RefreshCw, Trash2, Upload, X } from "lucide-react"import { cn } from "@/lib/utils" export type FileUploadStatus = "queued" | "uploading" | "complete" | "error" export type FileUploadEntry<TResult = unknown> = { id: string file: File status: FileUploadStatus progress: number error?: stringUsage
async function uploadFile(file, { signal, onProgress }) {
return uploadToYourStorage(file, { signal, onProgress })
}
export function Attachments() {
return (
<FileUpload
accept="image/*,.pdf"
maxFiles={5}
maxSize={10 * 1024 * 1024}
uploadFile={uploadFile}
/>
)
}Validation is not a boundary
accept and maxSize exist so someone can correct a mistake before waiting for an upload to fail. They are not security. Every one of them is trivially bypassed -- the accept attribute is a filter in a file dialog, the size is read from the file the browser hands over, and the type comes from an extension rather than the bytes.
Repeat every check on the server, and sniff the actual content rather than trusting the reported type. A file named invoice.pdf is only a PDF if its bytes say so.
Why a file was refused
Refused files arrive through onReject with a code, so you can respond to the reason rather than parsing a message.
| Code | Meaning |
|---|---|
type | Did not match accept. |
size | Larger than maxSize. |
duplicate | Already in the queue. |
count | Would exceed maxFiles. |
Each rejection carries the file it refers to, so several can be reported at once when a whole folder is dropped in.
Driving progress
The component queues files and shows their state; it never uploads anything. Move each item through its status yourself, and set progress from whatever your transport reports.
async function upload(item) {
update(item.id, { status: "uploading", progress: 0 })
try {
const result = await put(item.file, {
onProgress: (progress) => update(item.id, { progress }),
})
update(item.id, { status: "complete", progress: 100, result })
} catch (error) {
update(item.id, { status: "error", error: String(error) })
}
}API
acceptstringMIME types and extensions accepted by the picker.multiplebooleanAllows more than one file. Defaults to true.maxFilesnumberMaximum files in the queue. Defaults to 5.maxSizenumberMaximum bytes per file. Defaults to 10 MB.uploadFileFileUploadAdapterYour async upload function with progress and cancellation hooks.autoUploadbooleanStarts the adapter when files are accepted. Defaults to true.onFilesAccepted(files: File[]) => voidRuns with files that pass validation.onFilesRejected(rejections) => voidReports type, size, count, and duplicate failures.onFilesChange(entries) => voidRuns when the queue or an upload state changes.value, defaultValueFileUploadEntry[]Controls the queue or supplies its initial entries.onValueChange(entries) => voidUpdates a controlled queue.onUploadComplete(entry, result) => voidReceives the value returned by your upload adapter.disabledbooleanDisables both picking and dropping.classNamestringClasses for the root element.Accessibility
The picker is a named native button backed by a file input. Drag and drop is an additional path, not the only one. Validation and upload changes are announced politely. Every queue action and the primary picker keep a 44px target. Progress uses native progressbar semantics. File type and size checks must also run on the server because browser validation is not a security boundary.