Mischief

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

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-upload
import { FileUpload } from "mischief-ui/file-upload"
Also installs
  • lucide-react

Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.

registry/default/file-upload/file-upload.tsx
"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?: string

Usage

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.

CodeMeaning
typeDid not match accept.
sizeLarger than maxSize.
duplicateAlready in the queue.
countWould 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.