File Upload
A drag-and-drop file intake surface for business documents — accessible-first (a real file input) and transport-agnostic (you own the upload).
@garn/ui/file-uploadDefault
Uncontrolled — the component holds the queue; a plain document field with accept + maxSize.
Uploading
Controlled — a fake transport ticks each file to success; one is forced to fail to show the error + Retry.
Validation
Typed rejections surfaced to the user — accept + per-file + count limits, every reason reported.
Reorderable
Attachments the user can drag to reorder (via List's Sortable wiring).
Directory
Folder drop + a running total capped by maxTotalSize, with a custom dropzone prompt.
Disabled
The whole surface inert.
Installation
garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.
npx garn-ui add file-upload- Registry
file-upload- Deps
class-variance-authoritylucide-react- Registry deps
utilslistprogressbuttonuse-controllable-stateuse-announce
Import
import { FileUpload.Dropzone, FileUpload.Summary, FileUpload.List, FileUpload.Item } from "@garn/ui/file-upload";Anatomy
The parts this component is built from — compose them to assemble it.
FileUpload.DropzoneRequired[data-slot="file-upload-dropzone"]The drop target — a <label> wrapping a focusable (never display:none) <input type=file>, so click/keyboard open the OS picker natively; drag-and-drop is enhancement. The input is named via label (aria-label) and described by an sr-only constraints span. Reflects data-state = idle | dragging | reject | disabled. Pass children to replace the default prompt; the input + constraints are always rendered.
FileUpload.Summary[data-slot="file-upload-summary"]A running "N files · X of Y" caption derived from the queue (and maxTotalSize). format overrides the caption with the live totals.
FileUpload.List[data-slot="file-upload-list"]The queued-file list — a <List appearance="divided"> wired to the queue (and to List's reorderable/onReorder when the root is reorderable). Renders nothing while empty; auto-maps the queue to FileUpload.Item rows, or takes children/renderItem to override.
FileUpload.Item[data-slot="file-upload-item"]One queued-file row (a composed List.Item): mime glyph · filename · size, with an inline Progress while uploading, the error message + a Retry on failure, and a Remove button. Reflects data-file-status. icon overrides the auto mime glyph.
Props
The component's public props and their types.
size- Description
- Dropzone scale.
- Type
"lg" | "md" | "sm"- Default
"md"
accept- Description
- Native accept syntax — mime types, `type/*`, and/or `.ext`, comma-separated.
- Type
string
allowDuplicates- Description
- Allow the same name+size file more than once (the default de-dups).
- Type
boolean- Default
false
defaultFiles- Description
- Uncontrolled initial queue (the component owns the list).
- Type
FileUploadFile[]
directory- Description
- Recurse dropped folders (`webkitdirectory`); degrades to flat files.
- Type
boolean- Default
false
disabled- Description
- Disable the whole surface (input, drop, and row actions).
- Type
boolean- Default
false
files- Description
- Controlled queue — pair with `onFilesChange`; drives real upload progress.
- Type
FileUploadFile[]
label- Description
- Accessible name for the file input.
- Type
string- Default
"Upload files"
maxFiles- Description
- Cap on the number of files (overflow is rejected as `count`). `1` → replace.
- Type
number
maxSize- Description
- Per-file byte ceiling.
- Type
number
maxTotalSize- Description
- Ceiling on the summed size of the whole queue.
- Type
number
onFilesAccepted- Description
- Fires with the newly accepted entries — the signal to start uploading them.
- Type
(files: FileUploadFile[]) => void
onFilesChange- Description
- The next queue after any add / remove / retry / reorder.
- Type
(files: FileUploadFile[]) => void
onFilesRejected- Description
- Fires with the files that failed validation, each carrying its reasons.
- Type
(rejections: FileRejection[]) => void
onRemove- Description
- Fires when a row is removed — cancel that file's transport if in flight.
- Type
(file: FileUploadFile) => void
onReorder- Description
- Fires with the next id order after a reorder drop.
- Type
(ids: string[]) => void
onRetry- Description
- Fires when a row's retry is pressed — restart that file's transport.
- Type
(file: FileUploadFile) => void
reorderable- Description
- Enable drag-reorder of the file list (via List's Sortable wiring).
- Type
boolean- Default
false
Plus 279 inherited native <div> attributes.
FileUpload.Summary
format- Description
- Override the caption; receives the live totals.
- Type
(info: { count: number; totalBytes: number; maxFiles?: number; maxTotalBytes?: number; }) => React.ReactNode
Plus 280 inherited native HTML attributes.
FileUpload.List
children- Description
- Manual rows; omit to auto-render one `FileUpload.Item` per queued file.
- Type
React.ReactNode
renderItem- Description
- Per-file row override used by the auto path.
- Type
(file: FileUploadFile) => React.ReactNode
Plus 294 inherited native HTML attributes.
FileUpload.Item
fileRequired- Description
- The queued file this row represents.
- Type
FileUploadFile
icon- Description
- Override the auto mime glyph.
- Type
React.ReactNode
Plus 293 inherited native HTML attributes.
Styling
Target these data-slots and states, and remap these tokens, to restyle without forking the component.
[data-slot="file-upload"][data-slot="file-upload-remove"]States
| Selector | State | Description |
|---|---|---|
[data-state=idle] | idle | The resting dropzone — a dashed outline over the input surface. |
[data-state=dragging] | dragging | A drag is over the zone (files that could be accepted) — the border + fill shift to brand. |
[data-state=reject] | reject | A best-effort preview that the dragged files' known types can't be accepted — the border + fill shift to danger. |
[data-state=disabled] | disabled | The whole surface is inert — the input is disabled, drops are ignored, and row actions are disabled. |
[data-file-status] | file-status | Per-row lifecycle on FileUpload.Item — idle | uploading (inline Progress, indeterminate without a value) | success (a success check) | error (danger tint + message + Retry). |
When to use
Letting a user attach one or more documents — a résumé, email attachments, a batch of deal-room docs — via drag-and-drop or a file picker, with validation, per-file status/progress, and retry/remove. Omit files for a simple form field; pass files + onFilesChange to show real upload progress.
Reach for something else when
- An image/media gallery with thumbnail grids and cropping (FileUpload is document-first — mime icons, not thumbnails)
- a single avatar/photo picker (use a plain Input type=file or an Avatar upload affordance)
- when you need a bundled resumable/chunked transport out of the box (FileUpload is transport-agnostic — wire your own, or reach for a dedicated uploader)
- a plain text field (use Input).
Overview
FileUpload is a drag-and-drop intake surface for business documents — résumés, attachments, deal-room docs — not a photo gallery. It is accessible-first: the dropzone is a real, keyboard-operable file input, and drag is progressive enhancement on top. It is also transport-agnostic — it owns the file queue, validation, drag state, and per-item status/progress display, but never performs the network request. You drive your own upload (fetch / XHR / presigned URL / tus) and push status and progress back through the controlled files array. File rows are composed from List (so they inherit its a11y and drag-reorder) and per-row Progress. Omit files for a plain collect-files-for-a-form field; pass files + onFilesChange to drive real progress.
Guidelines
Let the consumer own the network. FileUpload displays status; it doesn't upload. On onFilesAccepted, start your transport and update each entry's status/progress in your files state; wire onRetry to restart and onRemove to cancel in-flight requests.
Constrain up front. Set accept, maxSize, maxFiles, and/or maxTotalSize; every rejection arrives via onFilesRejected with a typed errors[] (type | size | count | totalSize | duplicate) so you can tell the user why a file didn't make it in — never drop files silently. The constraints are also surfaced to screen readers on the input.
Keep it accessible. The real file input is the baseline path — don't hide it or replace it with a div. Give a meaningful label. Convey status with the row's text + Progress, not colour alone. Use directory for folder drops (it degrades on Safari) and reorderable to let users order attachments.
Best practices
- Drive the actual upload yourself: on
onFilesAccepted, start your transport and pushstatus/progressback through the controlledfilesarray. - Set
accept/maxSize/maxFiles/maxTotalSizeand surfaceonFilesRejectedreasons to the user. - Give the input a meaningful
label; keep the real file input as the accessible path. - Use
directoryfor folder drops andreorderableto let users order attachments. - Wire
onRetryto restart a failed upload andonRemoveto cancel an in-flight one.
- Don't expect FileUpload to perform the network request — it's transport-agnostic by design.
- Don't hide or bypass the real <input type=file> — drag must never be the only way in.
- Don't rely on the row tint alone for the error state — the message + Retry carry it too.
- Don't use it for a thumbnail-first media gallery — it shows mime icons, not previews.
Content guidelines
- Keep the dropzone prompt short ("Drag files here, or browse"); the constraints line states the accepted types and limits.
- Name files by their real filename; keep rejection messages specific (which file, which limit).
Troubleshooting
Expecting FileUpload to upload the files by itself (looking for an action/url prop).
Cause. FileUpload is transport-agnostic — it owns the queue and display, never the network request.
Fix. On onFilesAccepted, start your own upload (fetch / XHR / presigned URL) and push each entry's status/progress back through the controlled files array.
Wanting real upload progress but passing no files (uncontrolled).
Cause. Uncontrolled mode holds the queue internally at status "idle"; you can't push progress into it.
Fix. Control it: pass files + onFilesChange, then update each entry's status/progress as your transport reports.
Setting the native accept and assuming dropped files are pre-filtered.
Cause. accept only hints the OS picker and is bypassed entirely by drag-and-drop.
Fix. Nothing to do — FileUpload validates accept itself on both paths and reports mismatches via onFilesRejected (reason type).
A rejected file only shows one reason when it breaks several rules.
Cause. Each FileRejection carries an errors[] array, not a single reason.
Fix. Read rejection.errors (each { code, message }) — a file can be both type and size, etc.
Folder drop doesn't recurse (or does nothing on Safari).
Cause. Directory drop needs directory and the non-standard webkitGetAsEntry API, which Safari lacks.
Fix. Set directory; it recurses where supported and degrades to the flat file list where it isn't.
Accessibility
- Role
group- Focus
- The sr-only <input> is the keyboard/AT target; the visible dropzone box wears the ring via has-[input:focus-visible]. Row Retry/Remove are ordinary tab stops. On removal, focus is programmatically moved to the sibling remove button (or the input) so it never falls to <body>.
- The dropzone is a <label> wrapping a real <input type=file> that is sr-only (focusable, never display:none), so click / Tab / Enter / Space open the OS picker with zero JS — drag-and-drop is progressive enhancement layered on top, never the only path in.
- The input gets a concise accessible name from `label` (aria-label, default "Upload files") and is described by an sr-only constraints span (accepted types / size / count limits) via aria-describedby, so AT users hear the rules before choosing.
- The dropzone reflects data-state = idle | dragging | reject | disabled (component state, not the `variant` axis); the visible box wears the focus ring via has-[input:focus-visible] since the input itself is sr-only.
- File events are announced through a shared live region: additions politely ("N files added."), rejections and per-file error transitions assertively — so a file appearing or vanishing from the DOM is never silent to screen-reader users.
- Each row composes List.Item, inheriting List's listitem semantics; the row's Retry (error only) and Remove are real labelled buttons (`Retry <name>` / `Remove <name>`). Removing a row moves focus to the next remove button (or the input) so deletion never drops focus to <body>.
- While uploading, the row shows a Progress (role=progressbar) — determinate with a `progress` value, indeterminate without one; disabled disables the input and every row action.