File upload
ProA dropzone with drag-over state, click or keyboard to browse and type and size checks, plus a file list with type icons, progress, success and error states, remove and retry.
npx prodular-ui add file-uploadPart of Prodular UI Pro. Preview and read the docs freely, install with a license. See pricing
Attachments
Simulated uploads. Every third new file fails halfway so you can try Retry. Files over 10 MB or of other types are rejected with a message.
- q3-revenue-report.pdf2.4 MB · Uploaded
- customers-export-oct.csv38%2.5 MB of 6.6 MB
- signed-msa-northwind.pngConnection lost at 64%. Try again.
"use client"
import * as React from "react"
import { FileUpload, FileUploadDropzone, FileUploadItem, FileUploadList, type FileUploadStatus,} from "@/components/ui/file-upload"
type Item = { id: string name: string size: number type: string progress: number status: FileUploadStatus error?: string /** The demo fails some uploads on purpose so the error state can be tried. */ failAt?: number}
const initial: Item[] = [ { id: "1", name: "q3-revenue-report.pdf", size: 2_480_000, type: "application/pdf", progress: 100, status: "complete" }, { id: "2", name: "customers-export-oct.csv", size: 6_900_000, type: "text/csv", progress: 38, status: "uploading" }, { id: "3", name: "signed-msa-northwind.png", size: 1_240_000, type: "image/png", progress: 64, status: "error", error: "Connection lost at 64%. Try again." },]
const MAX = 10 * 1024 * 1024let counter = 10
export default function FileUploadAttachments() { const [items, setItems] = React.useState(initial) const [rejection, setRejection] = React.useState<string | null>(null)
React.useEffect(() => { const timer = setInterval(() => { setItems((list) => list.map((it) => { if (it.status !== "uploading") return it const progress = Math.min(100, it.progress + (it.size < 2_000_000 ? 12 : it.size < 5_000_000 ? 6 : 1.5)) if (it.failAt && progress >= it.failAt) { return { ...it, progress: it.failAt, status: "error", error: `Connection lost at ${it.failAt}%. Try again.`, failAt: undefined } } return { ...it, progress, status: progress >= 100 ? "complete" : "uploading" } }) ) }, 300) return () => clearInterval(timer) }, [])
return ( <div className="w-full max-w-lg"> <FileUpload accept=".pdf,.csv,.xlsx,image/*" maxSize={MAX} maxFiles={8} currentCount={items.length} onFilesAccepted={(files) => { setRejection(null) setItems((list) => [ ...list, ...files.map((f) => { counter++ return { id: String(counter), name: f.name, size: f.size, type: f.type, progress: 0, status: "uploading" as const, failAt: counter % 3 === 0 ? 55 : undefined, } }), ]) }} onFilesRejected={(r) => setRejection(r.map((x) => x.message).join(" "))} > <FileUploadDropzone hint="PDF, CSV, XLSX or images, up to 10 MB each" /> {rejection && ( <p role="alert" className="text-[13px] text-destructive-subtle-foreground"> {rejection} </p> )} <FileUploadList> {items.map((it) => ( <FileUploadItem key={it.id} name={it.name} size={it.size} type={it.type} progress={it.progress} status={it.status} error={it.error} onRemove={() => setItems((list) => list.filter((x) => x.id !== it.id))} onRetry={() => setItems((list) => list.map((x) => (x.id === it.id ? { ...x, status: "uploading", progress: 0, error: undefined } : x)) ) } /> ))} </FileUploadList> </FileUpload> </div> )}Single file import
One CSV at a time. The dropzone is replaced by the file once it is picked.
Import customers
One customer per row. We match on email and update existing records.
"use client"
import * as React from "react"import { SheetIcon } from "lucide-react"
import { Button } from "@/components/ui/button"import { FileUpload, FileUploadDropzone, FileUploadItem, FileUploadList, type FileUploadStatus,} from "@/components/ui/file-upload"
type Picked = { name: string; size: number; progress: number; status: FileUploadStatus }
export default function FileUploadImport() { const [file, setFile] = React.useState<Picked | null>(null) const [error, setError] = React.useState<string | null>(null)
React.useEffect(() => { if (file?.status !== "uploading") return const t = setTimeout(() => { setFile((f) => { if (!f) return f const progress = Math.min(100, f.progress + 12) return { ...f, progress, status: progress >= 100 ? "complete" : "uploading" } }) }, 180) return () => clearTimeout(t) }, [file])
return ( <div className="flex w-full max-w-md flex-col gap-4 rounded-xl border bg-card p-5 shadow-xs"> <div className="flex flex-col gap-1"> <p className="text-sm font-medium">Import customers</p> <p className="text-[13px] text-muted-foreground"> One customer per row. We match on email and update existing records. </p> </div> <FileUpload accept=".csv" maxSize={25 * 1024 * 1024} multiple={false} maxFiles={1} currentCount={file ? 1 : 0} disabled={Boolean(file)} onFilesAccepted={([f]) => { setError(null) setFile({ name: f.name, size: f.size, progress: 0, status: "uploading" }) }} onFilesRejected={(r) => setError(r[0].message)} > {!file && ( <FileUploadDropzone className="py-6" hint="CSV only, up to 25 MB" title="Drop a CSV here or" /> )} {error && ( <p role="alert" className="text-[13px] text-destructive-subtle-foreground"> {error} </p> )} {file && ( <FileUploadList> <FileUploadItem name={file.name} size={file.size} type="text/csv" progress={file.progress} status={file.status} onRemove={() => setFile(null)} /> </FileUploadList> )} </FileUpload> <div className="flex items-center justify-between gap-3 border-t pt-4"> <Button variant="link" size="sm" className="gap-1.5"> <SheetIcon className="size-3.5" /> Download template </Button> <Button size="sm" disabled={file?.status !== "complete"}> Map columns </Button> </div> </div> )}Anatomy
<FileUpload accept=".pdf,.csv" maxSize={10_000_000} onFilesAccepted={startUploads}> <FileUploadDropzone hint="PDF or CSV, up to 10 MB" /> <FileUploadList> {files.map((f) => ( <FileUploadItem key={f.id} name={f.name} size={f.size} progress={f.progress} status={f.status} onRemove={() => remove(f.id)} /> ))} </FileUploadList></FileUpload>Guidance
Use it when
- Attaching documents to a record, importing a CSV, uploading a logo.
- The list on its own to show files that are already attached.
Avoid
- Hiding the limits. Always say which types and how large in the hint.
- Removing failed files automatically. Keep them with a reason and a retry.
Rules for AI agents
Published in llms-full.txt, the registry and the MCP server, so coding agents follow them when they build with this component.
- FileUpload validates and hands you files in onFilesAccepted; it does not upload. Keep your own list state with progress and status, and render a FileUploadItem per file.
- Set accept and maxSize, and repeat them in plain words in the dropzone hint, for example PDF or CSV, up to 10 MB.
- Show onFilesRejected messages near the dropzone in a role="alert" paragraph using text-destructive-subtle-foreground.
- Pass currentCount together with maxFiles so the limit counts files already in the list.
- On failure set status="error" with an error message that says what happened, and pass onRetry.
- Use multiple={false} and hide the dropzone after a pick for single-file flows.
API
FileUpload
| Prop | Type | Default | Description |
|---|---|---|---|
| accept | string | Input accept syntax: ".pdf,.csv,image/*". | |
| maxSize | number | Largest file in bytes. | |
| maxFiles / currentCount | number | Total file limit and how many are already listed. | |
| multiple | boolean | true | Allow several files per pick. |
| onFilesAccepted | (files: File[]) => void | Files that passed validation. | |
| onFilesRejected | (rejections: FileRejection[]) => void | Each rejected file with reason and message. |
FileUploadDropzone
| Prop | Type | Default | Description |
|---|---|---|---|
| hint / title / browseLabel | ReactNode | Copy inside the default dropzone. Pass children to replace it entirely. |
FileUploadItem
| Prop | Type | Default | Description |
|---|---|---|---|
| status | "queued" | "uploading" | "complete" | "error" | "queued" | Drives the icon, progress bar and meta text. |
| progress | number | 0 | 0 to 100 while uploading. |
| onRemove / onRetry | () => void | Show remove (or cancel while uploading) and retry on error. |
Installation
The Prodular UI CLI adds the source to components/ui with everything it depends on. In a project that already uses shadcn/ui, the shadcn CLI installs it from the Prodular registry too. See Installation for the first setup.
npx prodular-ui add file-upload# or, with the registry in components.jsonnpx shadcn@latest add @prodular/file-upload