Inputs
File upload
A drop zone that also opens the file dialog, plus a file list with progress bars, status and remove. Files are checked against accept and maxSize before you see them, and rejected ones come back with a reason so you can explain what went wrong.
- fernhill-brand-guide.pdfUploaded2.4 MB
- invoice-FHS-0142.pdf64%
"use client"; import { useEffect, useRef, useState } from "react";import { FileItem, FileList, FileUpload, formatBytes, type FileRejection } from "@/components/ui/file-upload"; type Upload = { id: number; name: string; size: number; type: string; progress: number; status: "uploading" | "done" | "error"; error?: string }; const MAX = 10 * 1024 * 1024; export function FileUploadDemo() { const [files, setFiles] = useState<Upload[]>([ { id: 1, name: "fernhill-brand-guide.pdf", size: 2_480_000, type: "application/pdf", progress: 100, status: "done" }, { id: 2, name: "invoice-FHS-0142.pdf", size: 184_000, type: "application/pdf", progress: 64, status: "uploading" }, ]); const nextId = useRef(3); // Simulated upload: every running file advances a little each tick. const running = files.some((f) => f.status === "uploading"); useEffect(() => { if (!running) return; const t = setInterval(() => { setFiles((cur) => cur.map((f) => { if (f.status !== "uploading") return f; const progress = Math.min(100, f.progress + 4 + Math.random() * 10); return { ...f, progress, status: progress >= 100 ? "done" : "uploading" }; }), ); }, 350); return () => clearInterval(t); }, [running]); const add = (list: File[]) => setFiles((cur) => [ ...cur, ...list.map((f) => ({ id: nextId.current++, name: f.name, size: f.size, type: f.type, progress: 0, status: "uploading" as const })), ]); const reject = (list: FileRejection[]) => setFiles((cur) => [ ...cur, ...list.map((r) => ({ id: nextId.current++, name: r.file.name, size: r.file.size, type: r.file.type, progress: 0, status: "error" as const, error: r.reason === "size" ? `Larger than ${formatBytes(MAX)}` : "File type not supported", })), ]); return ( <div className="mx-auto flex w-full max-w-md flex-col gap-3"> <FileUpload accept="image/*,.pdf" maxSize={MAX} onFiles={add} onReject={reject} hint="PDF, PNG or JPG, up to 10 MB each" /> {files.length ? ( <FileList aria-label="Attachments"> {files.map((f) => ( <FileItem key={f.id} name={f.name} size={f.size} type={f.type} status={f.status} progress={f.progress} error={f.error} onRetry={f.error ? undefined : () => setFiles((cur) => cur.map((x) => (x.id === f.id ? { ...x, status: "uploading", progress: 0 } : x)))} onRemove={() => setFiles((cur) => cur.filter((x) => x.id !== f.id))} /> ))} </FileList> ) : null} </div> );}Usage
components/ui/file-upload.tsx
import { FileUpload } from "@/components/ui/file-upload"; export function Example() { return <FileUpload accept="image/*,.pdf" maxSize={10 * 1024 * 1024} onFiles={(files) => upload(files)} />;}Examples
Upload with progress
Drop files or click to browse. Progress is simulated in the browser; swap the interval for your upload request's progress events.
- fernhill-brand-guide.pdfUploaded2.4 MB
- invoice-FHS-0142.pdf64%
"use client"; import { useEffect, useRef, useState } from "react";import { FileItem, FileList, FileUpload, formatBytes, type FileRejection } from "@/components/ui/file-upload"; type Upload = { id: number; name: string; size: number; type: string; progress: number; status: "uploading" | "done" | "error"; error?: string }; const MAX = 10 * 1024 * 1024; export function FileUploadDemo() { const [files, setFiles] = useState<Upload[]>([ { id: 1, name: "fernhill-brand-guide.pdf", size: 2_480_000, type: "application/pdf", progress: 100, status: "done" }, { id: 2, name: "invoice-FHS-0142.pdf", size: 184_000, type: "application/pdf", progress: 64, status: "uploading" }, ]); const nextId = useRef(3); // Simulated upload: every running file advances a little each tick. const running = files.some((f) => f.status === "uploading"); useEffect(() => { if (!running) return; const t = setInterval(() => { setFiles((cur) => cur.map((f) => { if (f.status !== "uploading") return f; const progress = Math.min(100, f.progress + 4 + Math.random() * 10); return { ...f, progress, status: progress >= 100 ? "done" : "uploading" }; }), ); }, 350); return () => clearInterval(t); }, [running]); const add = (list: File[]) => setFiles((cur) => [ ...cur, ...list.map((f) => ({ id: nextId.current++, name: f.name, size: f.size, type: f.type, progress: 0, status: "uploading" as const })), ]); const reject = (list: FileRejection[]) => setFiles((cur) => [ ...cur, ...list.map((r) => ({ id: nextId.current++, name: r.file.name, size: r.file.size, type: r.file.type, progress: 0, status: "error" as const, error: r.reason === "size" ? `Larger than ${formatBytes(MAX)}` : "File type not supported", })), ]); return ( <div className="mx-auto flex w-full max-w-md flex-col gap-3"> <FileUpload accept="image/*,.pdf" maxSize={MAX} onFiles={add} onReject={reject} hint="PDF, PNG or JPG, up to 10 MB each" /> {files.length ? ( <FileList aria-label="Attachments"> {files.map((f) => ( <FileItem key={f.id} name={f.name} size={f.size} type={f.type} status={f.status} progress={f.progress} error={f.error} onRetry={f.error ? undefined : () => setFiles((cur) => cur.map((x) => (x.id === f.id ? { ...x, status: "uploading", progress: 0 } : x)))} onRemove={() => setFiles((cur) => cur.filter((x) => x.id !== f.id))} /> ))} </FileList> ) : null} </div> );}File list states
- studio-photos.zip38%
- contract-signed.pdfUploaded598 KB
- logo-final.pngUploaded1.3 MB
- timesheet-october.xlsxSpreadsheets aren't supported
<div className="mx-auto w-full max-w-md"> <FileList aria-label="Attachments"> <FileItem name="studio-photos.zip" size={48_200_000} status="uploading" progress={38} /> <FileItem name="contract-signed.pdf" size={612_000} type="application/pdf" status="done" /> <FileItem name="logo-final.png" size={1_350_000} type="image/png" status="done" /> <FileItem name="timesheet-october.xlsx" size={88_000} status="error" error="Spreadsheets aren't supported" /> </FileList></div>Compact
A single-row zone for forms and side panels.
<div className="mx-auto w-full max-w-md"> <FileUpload variant="compact" multiple={false} accept=".csv" title="Import clients from CSV" hint="Up to 5,000 rows" /></div>Disabled
<div className="mx-auto w-full max-w-md"> <FileUpload disabled title="Uploads are paused" hint="Your workspace has reached its 5 GB storage limit." /></div>Props
FileUpload
PropTypeDefaultDescription
onFiles(files: File[]) => voiddefault: —Accepted files from a drop or the dialog.onReject(rejections: { file, reason }[]) => voiddefault: —Files that fail accept ("type") or maxSize ("size").acceptstringdefault: —Same syntax as the input attribute, e.g. "image/*,.pdf".maxSizenumberdefault: —Max bytes per file.multiplebooleandefault: trueAllow more than one file.title / hintReactNodedefault: —Main line and the line under it.variant"default" | "compact"default: "default"Tall zone or single row.disabledbooleandefault: —Ignore drops and clicks.namestringdefault: —Name of the underlying file input for native forms.
FileItem
PropTypeDefaultDescription
namerequiredstringdefault: —File name, truncated when long.sizerequirednumberdefault: —Size in bytes, shown formatted.typestringdefault: —MIME type; images get an image icon.status"uploading" | "done" | "error"default: "done"Row state.progressnumberdefault: 00–100 while uploading.errorReactNodedefault: —Reason shown when status is error.onRemove / onRetry() => voiddefault: —Show remove (cancel while uploading) and retry buttons.
Accessibility
- The zone is a <label> wrapping a visually hidden <input type="file">: Tab focuses it, Enter or Space opens the file dialog, and the focus ring wraps the whole zone.
- Dragging is a pointer-only shortcut; everything it does is also possible through the dialog.
- Each upload has a role="progressbar" named after the file, with aria-valuenow.
- Remove, cancel and retry buttons are labelled with the file name (“Remove logo-final.png”).
- Status never relies on color alone: finished files get a check icon announced as “Uploaded”, failed ones show the reason in text.