File Upload
Select, validate, and review files while your application keeps control of every upload request.
Overview
FileUpload lets people choose, drop, or paste files. It checks count, type, and size rules in the browser, then shows the selected files and any rejection reasons. Your application supplies upload progress, success, and failure states.
FileUpload does not send requests. Your application receives the selection through onValueChange, uploads each File, and updates status, progress, and error. There is no endpoint prop or storage adapter.
The default dropzone uses a dashed border, an upload icon, and short instructions. Drag, rejection, and success states pair color with text or an icon. Progress and errors stay in the row for the file they describe.
Anatomy
- DropzoneClickable dashed area that also accepts drops, Enter, and Space
- IconSmall upload arrow tinted by drag state
- TitlePrimary instruction, such as 'Drop files here or click to browse'
- DescriptionMuted requirements line, such as 'PNG, JPG, PDF up to 10 MB'
- TriggerAny button that opens the picker; Choose file, Replace, Add more files
- SummaryOptional chosen-file text for button-only layouts
- ListBordered row container, including rejected rows until dismissed
- ItemPreview, content column, and actions
- PreviewImage thumbnail or file-type tile
- ContentName, metadata, status, progress, reason
- ActionsRemove plus Retry when status is error
- Clear allQuiet text action shown above one selected file
<FileUploadList /> renders complete rows, including Retry on failed uploads. Each row part is also exported for custom layouts.
Installing file-upload brings Button and Progress along as registry dependencies, because rows render both internally.
Behavior
Ownership of the request
FileUpload reports selections. The application uploads them:
<FileUpload value={files} onValueChange={setFiles} />Items carry whatever state your uploader produces:
type FileUploadStatus = "pending" | "uploading" | "success" | "error"
type FileUploadItemData = {
id: string
file: File
status?: FileUploadStatus
progress?: number | null // null means indeterminate
error?: string // your sentence, shown verbatim
}A fresh pick starts as pending. When a transfer begins, set uploading and update progress from your transport's progress events. If the length cannot be computed, use progress: null. The row then shows an indeterminate bar and the text Uploading....
On failure, set status: "error" and add a specific error sentence. The original File stays in the item, so Retry calls your handler with it again:
onRetry={(item) => startUpload(item)}Retry renders only when a failed row has an onRetry handler. It performs no request itself.
Validation order
Count rules run first because they answer a different question than size rules. A batch either fits the remaining capacity or it does not, and partial acceptance hides that fact. Dropping three files into two open slots rejects the entire gesture with You can upload up to N files. Remove a file before adding another, printed under the dropzone and announced politely.
Then per-file rules run: empty files, minSize, maxSize, the accept mapping, and your validateFile callback. Each rejected file gets a row with its reason. onReject receives all rejections from that selection.
These checks are picker feedback, not a security boundary. Filenames and browser-reported MIME types are untrusted. Recheck type and size on the server, inspect file signatures when the format allows it, generate safe storage names, and scan files when your threat model calls for it. The OWASP File Upload Cheat Sheet covers the server controls that this client component cannot provide.
Single-file mode replaces its selection wholesale, whether the new file comes from Replace, a re-pick, or a drop. Extra files in a single-file drop land as rejected rows saying only one file belongs there, because silently discarding someone's second photo teaches nothing.
Drag feedback
During a hover, browsers expose the number of files being carried, but not their names or sizes. The dropzone can check capacity at that point. A drag that fits uses the accent state. A drag that cannot fit uses the danger state, sets dropEffect to none, and announces the limit. Type and size checks run after the drop, when the browser exposes each file.
Non-file drags, a text selection for instance, leave the zone untouched.
Capacity and removal
Add more files disables itself at the cap instead of letting someone fill a queue the rules will reject. Clear all appears above one file, stays a ghost-weight text button, and skips destructive styling since nothing has been uploaded by FileUpload itself. Removing a row is instant with no confirm dialog, appropriate for local picks that nobody's server has seen.
Focus after changes
After removing a selected or rejected row, focus moves to the next remove action, then the previous one. When no row remains, focus moves to the first available picker trigger or dropzone. Clear all follows the same fallback. Closing the native picker returns focus to the control that opened it.
Accessibility
Everything reachable by pointer works by keyboard. The dropzone is a real button, so Enter and Space open the picker without drag assistance, removed files report their removal target, and every icon-only action carries a generated accessible label such as Remove invoice-march.pdf, overridable per instance.
Tab walks trigger, rows, and actions in DOM order. Each row exposes filename, size and type, current status, and any error as visible text, which screen readers announce through normal reading rather than noisy per-row live regions.
One polite status region announces additions, completed uploads, failed uploads, and rejections. Percentage updates stay silent. Row-level Progress elements use labels such as Uploading report.pdf, so each progress bar is identifiable on its own.
The instruction inside the dropzone contributes to its accessible name. FieldLabel can name the button through its id, and aria-describedby connects outside help text. Invalid drags change the text and icon as well as the color. Reduced motion removes the indeterminate animation, while the Uploading... text remains.
Installation
Usage
Import the parts your layout needs. Composition mirrors the anatomy tree:
import {
FileUpload,
FileUploadDropzone,
FileUploadDescription,
FileUploadIcon,
FileUploadTitle,
} from "@/components/ui/file-upload/file-upload"
<FileUpload accept={{ "image/png": [".png"] }} maxSize={10 * 1024 * 1024} multiple>
<FileUploadDropzone>
<FileUploadIcon />
<FileUploadTitle>Drop files here or click to browse</FileUploadTitle>
<FileUploadDescription>PNG up to 10 MB</FileUploadDescription>
</FileUploadDropzone>
</FileUpload>Inside an existing Field, point the label at the dropzone button. Use aria-describedby for instructions outside the dropzone:
<Field>
<FieldLabel htmlFor="attachments">Attachments</FieldLabel>
<FileUpload>
<FileUploadDropzone
id="attachments"
aria-describedby="attachments-help"
>
<FileUploadIcon />
<FileUploadTitle>Drop attachments or browse</FileUploadTitle>
</FileUploadDropzone>
</FileUpload>
<FieldDescription id="attachments-help">
PDF or image up to 5 MB.
</FieldDescription>
</Field>Connecting an uploader takes one callback; wire statuses wherever your requests resolve:
const [items, setItems] = useState<FileUploadItemData[]>([])
<FileUpload value={items} onValueChange={setItems} />
async function upload(item: FileUploadItemData) {
patch(item.id, { status: "uploading", progress: null })
await xhrSend(item.file, (percent) =>
patch(item.id, { status: "uploading", progress: percent })
)
patch(item.id, { status: "success", progress: 100 })
}FileUpload does not participate in native form submission. If the uploader sits in a form, submit the selected files in your form handler or build a FormData object yourself:
async function submitFiles(items: FileUploadItemData[]) {
const body = new FormData()
for (const item of items) {
body.append("attachments", item.file, item.file.name)
}
await fetch("/api/attachments", { method: "POST", body })
}Paste support opts in per instance with allowPaste; directory browsing and camera capture are plain props (directory, capture) documented under Less common inputs.
Single-file flows
Compact tasks read better with the row replacing the zone entirely. Both buttons below ride the same hidden input, so Replace never spawns a second picker circuit:
<FileUpload value={value} onValueChange={setValue} accept={avatarTypes}>
{value.length > 0 ? (
<>
<FileUploadList />
<FileUploadTrigger render={<Button variant="outline" />}>
Replace
</FileUploadTrigger>
</>
) : (
<FileUploadDropzone>...</FileUploadDropzone>
)}
</FileUpload>Button-only layouts work the same way. The summary uses the familiar text No file chosen when the selection is empty:
<FileUploadTrigger render={<Button variant="outline" />}>Choose file</FileUploadTrigger>
<FileUploadSummary />Locale and long names
Long filenames truncate in the row, with the full name available through the native title tooltip. The default metadata uses English number formatting and binary KB, MB, and GB units. Compose a custom row if your product needs locale-specific file sizes.
Don't do this
Sending requests from inside the component
// Bad. Storage credentials leak into every bundle that touches this form.
<FileUpload bucket="prod-assets" supabaseKey={publicAnonKey} />// Good. The server signs, the client uploads, the component stays dumb.
onAccept={(items) => enqueue(items.map((item) => signAndUpload(item.file)))}Provider adapters hide authentication and retry decisions inside a presentation component. Keep transport in the application layer.
Faking progress
// Bad. Nine seconds of lies followed by sudden truth.
setInterval(() => bumpProgress(+4), 300)
await doRealUpload()
patch(item.id, { status: "success" })Use progress: null when the transport cannot report bytes sent. Do not invent percentages.
Examples
The examples below cover selected files, upload states, rejection, alternate inputs, and controlled ownership.
Multiple files and capacity
This example shows the selected count, a disabled Add more action at the cap, and Clear all.
Upload lifecycle
One determinate climb, one indeterminate ride, one finished row, and one scripted connection loss with a working Retry. Retry replays succeed because this demo, like your app, still holds the original File.
Rejections
Deliberately tight limits, so ordinary browsing trips the rules. Rows explain themselves and wait for dismissal.
Drag states
The examples show valid and over-capacity drag states. Count rules run during the drag. Type and size rules wait for the drop.
Single file
Replace and the empty state take over from the dropzone, sized for avatars, certificates, and imports that take exactly one document.
Button only
Use this layout for filters and other compact controls. It shows No file chosen while empty.
Disabled, inside Field
The enabled example makes the disabled border, text, and cursor changes easy to compare.
Controlled ownership
Reset writes through the same callback everything else uses, and the commit counter makes round-trips visible.
Custom validation
Return a sentence from validateFile to reject a file with that reason.
Image previews
This example creates blob thumbnails from an SVG and a JPG, then updates their progress and status.
Paste support
Focus the zone, press paste, and clipboard screenshots travel the identical validation path.
Less common inputs
Directory picking and camera capture together, both opt-in props with their caveats stated next to the controls that enable them.
API reference
FileUpload
Root provider and layout wrapper. Accepts native div props beyond those listed.
Parts
Types
Rows use HonestUI spacing, color, radius, and focus tokens. Test custom token values in every theme your application supports.