Skip to content
garn

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).

Stablev0.4.2added in v0.2.0@garn/ui/file-upload
On this page

Default

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.

terminal
npx garn-ui add file-upload
First time? Set up garn in your project
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

SelectorState
[data-state=idle]idle
[data-state=dragging]dragging
[data-state=reject]reject
[data-state=disabled]disabled
[data-file-status]file-status

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

Do
  • Drive the actual upload yourself: on onFilesAccepted, start your transport and push status/progress back through the controlled files array.
  • Set accept / maxSize / maxFiles / maxTotalSize and surface onFilesRejected reasons to the user.
  • Give the input a meaningful label; keep the real file input as the accessible path.
  • Use directory for folder drops and reorderable to let users order attachments.
  • Wire onRetry to restart a failed upload and onRemove to cancel an in-flight one.
Don't
  • 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>.
A1.3.1Info and RelationshipsA1.4.1Use of ColorA2.1.1KeyboardA2.4.3Focus OrderAA2.4.7Focus VisibleA4.1.2Name, Role, Value
  • 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.