Skip to documentation content

File Upload

Select, validate, and review files while your application keeps control of every upload request.

file-upload-demo

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

FileUpload
  • 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.

KeyAction
TabMove across dropzone, triggers, rows, remove and retry controls
Enter / SpaceOpen the picker from the focused dropzone or trigger
SpaceActivate remove when a row action holds focus

Installation

npx honestui@latest add file-upload

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.

file-upload-selected

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.

file-upload-states

Rejections

Deliberately tight limits, so ordinary browsing trips the rules. Rows explain themselves and wait for dismissal.

file-upload-rejected

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.

file-upload-drag-over

Single file

Replace and the empty state take over from the dropzone, sized for avatars, certificates, and imports that take exactly one document.

file-upload-single

Button only

Use this layout for filters and other compact controls. It shows No file chosen while empty.

file-upload-button-only

Disabled, inside Field

The enabled example makes the disabled border, text, and cursor changes easy to compare.

file-upload-disabled

Controlled ownership

Reset writes through the same callback everything else uses, and the commit counter makes round-trips visible.

file-upload-controlled

Custom validation

Return a sentence from validateFile to reject a file with that reason.

file-upload-validation

Image previews

This example creates blob thumbnails from an SVG and a JPG, then updates their progress and status.

file-upload-images

Paste support

Focus the zone, press paste, and clipboard screenshots travel the identical validation path.

file-upload-paste

Less common inputs

Directory picking and camera capture together, both opt-in props with their caveats stated next to the controls that enable them.

file-upload-sources

API reference

FileUpload

Root provider and layout wrapper. Accepts native div props beyond those listed.

PropTypeDefaultDescription
valueFileUploadItemData[]NoneControlled selection owned by your application.
defaultValueFileUploadItemData[][]Initial items when uncontrolled.
onValueChange(items) => voidNoneEvery accepted mutation, including clears.
acceptRecord<string, string[]> | stringNoneMIME-to-extension map filtering the picker and rules.
multiplebooleanfalseAllows stacking selections instead of replacing.
maxFilesnumberNoneCapacity ceiling driving batch rejection and trigger disabling.
maxSize / minSizenumberNonePer-file byte bounds quoted back in reasons.
disabledbooleanfalseBlocks clicks, drops, pastes, and focus entry.
onAccept(items) => voidNoneFires after validation passes.
onReject(rejections) => voidNoneOne call per selection carrying every rejection.
onRemove(item) => voidNoneUser-initiated removals only.
onRetry(item) => voidNoneReports retry intent; you restart the request.
validateFile(file) => string | nullNoneCustom rule returning a display-ready reason or null.
allowPastebooleanfalseReads clipboard files while focus sits inside this uploader.
directorybooleanfalseSwitches the picker to folder selection where supported.
capture"user" | "environment"NoneRequests a phone camera facing direction.

Parts

PartRendersNotes
FileUploadDropzonebuttonAccepts native button props, handles picker and drop actions, and prints selection errors beneath itself. Use wrapperClassName to style its outer layout wrapper.
FileUploadIconsvg, 24pxTint tracks idle, valid, and invalid drag states.
FileUploadTitlespanReads primary instructions; swap wording during drags for non-color feedback.
FileUploadDescriptionspanMuted requirements line that joins the accessible name.
FileUploadTriggerconfigurable buttonMerges with render={<Button />} and disables at the file limit.
FileUploadAddMoreoutline ButtonPlus-marked convenience wrapper around the trigger.
FileUploadClearlink ButtonRenders nothing at zero or one file.
FileUploadListulContains item rows plus rejection rows; showImagePreviews={false} uses file-type tiles.
FileUploadItemliRequires an item prop; sets row context for its children.
FileUploadItemPreviewimg or tileUses image bytes or src; showImagePreview={false} forces a file-type tile.
FileUploadItemContentdivFlex row holding name, metadata, status; wraps progress and reason below.
FileUploadItemActionsdivHolds Retry and Remove in the row's action column.
FileUploadItemNamespanTruncates with title fallback; strongest weight in the row.
FileUploadItemMetadataspanHuman size joined with uppercase extension by a middle dot.
FileUploadItemStatusspanWaiting, uploading, uploaded, or failed text with a status icon.
FileUploadItemProgressProgress trackThin determinate or indeterminate bar labeled Uploading {name}.
FileUploadItemReasonspanShows item.error verbatim under the name.
FileUploadItemRetrylink ButtonRenders for error rows when onRetry is set. Your handler owns the request.
FileUploadItemRemoveghost icon ButtonBecomes plain-text Remove on failed rows; focus target follows removals.
FileUploadSummaryspanChosen-file echo for trigger-only layouts.

Types

ExportShape
FileUploadItemData{ id: string; file: File; status?: FileUploadStatus; progress?: number | null; error?: string }
FileUploadStatus"pending" | "uploading" | "success" | "error"
FileUploadRejection{ id: string; file: File; reason: string }
FileUploadAcceptRecord<string, string[]> | string
FileUploadDragState"idle" | "valid" | "invalid"

Rows use HonestUI spacing, color, radius, and focus tokens. Test custom token values in every theme your application supports.