File Upload
A drag-and-drop file intake surface for business documents — résumés, attachments, deal-room docs. Accessible-first (a real, keyboard-operable file input; drag is progressive enhancement) and transport-agnostic (it owns file state, validation, and per-item status/progress display — the consumer drives the actual upload and reports progress back through a controlled `files` array). Typed rejections (type/size/count/totalSize/duplicate), directory drop, a running total, retry/remove, and drag-reorder — with rows composed from List + per-row Progress.
@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.
0 files · 0 B of 19.1 MB
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- Source
- file-upload.tsx
- Deps
class-variance-authoritylucide-react- Registry deps
utilslistprogressbuttonuse-controllable-stateuse-announce
Import
import { FileUpload } from "@garn/ui/file-upload";Props
Generated from the component's TypeScript 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 (default de-dups).
- Type
boolean
defaultFiles- Description
- Uncontrolled initial queue (the component owns the list).
- Type
FileUploadFile[]
directory- Description
- Recurse dropped folders (`webkitdirectory`); degrades to flat files.
- Type
boolean
disabled- Description
- Disable the whole surface (input, drop, and row actions).
- Type
boolean
files- Description
- Controlled queue — pair with `onFilesChange`; drives real upload progress.
- Type
FileUploadFile[]
label- Description
- Accessible name for the file input (default "Upload files").
- Type
string
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
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 remap these tokens to restyle without forking the component.
[data-slot="file-upload"][data-slot="file-upload-remove"]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).
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.
- Status
- Stable
- Version
v0.3.0- Introduced
v0.2.0
garn doesn't yet record a per-release version history. This reflects the component's current lifecycle state; full changelog entries will appear once the metadata pipeline emits a lifecycle.history field.