File Upload
Dropzone file input with an item list — a native input stretched across a dashed zone (click anywhere, drop anywhere on it), per-file validation with typed rejections, upload/processing/error rows, on-color tone, and a compact single-row variant.
pnpm add @tessinaui/uiWhen to use
- Choosing files from disk: forms, import flows, attachment pickers. Pair it
with
FileUploadListthe moment upload progress or per-file feedback matters. - State the constraints up front. Put accepted formats and limits in
FileUploadSubheader; it is wired to the input witharia-describedby, so assistive tech hears it too. - Wrap drag wording in
FileUploadDragText. It renders only on fine-pointer devices, so a phone never advertises "drag files here". - A dense form:
variant="compact"gives a field-height row where a full zone is too loud (a resume slot, an admin panel). - Keep what is valid. Validation is per file, so never clear a good selection because a later pick failed; the partition model exists so you never have to.
- Say the error in words.
errorMessagetext is the channel; the border and colour only reinforce it.
When not to use
- To transport files. The component never uploads: XHR/fetch, retry,
chunking and pause belong to your code, and
FileUploadItemonly displays the state you hold. - For chat attachments. Use PromptInput.
- For an avatar with a crop step. Compose Avatar with a cropper dialog.
- For camera capture or cloud-storage pickers. Pass
capturethrough and let the OS handle the camera; build cloud pickers in product code beside the zone. - For a button next to a hidden input. A real
Buttonnested in the zone or beside a hidden input gives one action two focusable stops. Use the Attach button recipe under Examples. - When the file is not essential. Do not ask for an upload the task can do without, and let people reuse a file they already supplied.
Installation
pnpm add @tessinaui/uiUsage
import {
FileUpload,
FileUploadHeader,
FileUploadSubheader,
FileUploadDragText,
FileUploadList,
FileUploadItem,
} from "@tessinaui/ui";
<FileUpload
orientation="vertical"
accept=".pdf,.png"
maxSize={10 * 1024 * 1024}
onFilesSelected={(files) => upload(files)}
onFilesRejected={(rejections) => report(rejections)}
>
<FileUploadHeader>Upload your files</FileUploadHeader>
<FileUploadSubheader>PDF or PNG, up to 10 MB</FileUploadSubheader>
<span>
Click to browse<FileUploadDragText> or drag files here</FileUploadDragText>
</span>
</FileUpload>Anatomy
<FileUpload>
<FileUploadHeader /> {/* bold first line — joins the accessible name */}
<FileUploadSubheader /> {/* constraint hint — joined via aria-describedby */}
<span>…</span> {/* anything else becomes the CTA label */}
</FileUpload>
<FileUploadList> {/* separate display list — the consumer owns the state */}
<FileUploadItem name="report.pdf" size={1840000} status="uploading" progress={45} />
</FileUploadList>The zone is a native <input type="file"> stretched invisibly across the
whole box — clicking anywhere and dropping files both use built-in browser
behaviour, and the input stays the one focusable, voice-targetable control.
Wrap the drag wording in FileUploadDragText: it renders only on fine-pointer
devices, so a phone never advertises "drag files here".
Examples
Item list with statuses
Rows carry uploading (determinate or indeterminate progress), processing
(post-transfer work), complete, and error (with a filename-scoped Retry).
Status transitions are announced through the list's polite live region. The
"n of m uploaded" counter is a plain consumer line.
Validation
accept, maxSize, minSize, maxFiles, and a custom validate partition
every selection per file: accepted files still arrive via onFilesSelected
when siblings fail — never an all-or-nothing batch clear. onFilesRejected
delivers typed codes (file-invalid-type, file-too-large, file-too-small,
too-many-files, or your own).
File-type icons and previews
Every item resolves an icon from the file extension —
image, document, sheet, video, audio, archive, and a generic fallback for
anything unrecognised. Pass previewUrl to replace that icon with a real
thumbnail:
const url = URL.createObjectURL(file);
// …
<FileUploadItem name={file.name} size={file.size} previewUrl={url} />
// revoke on removal, or the blob leaks for the life of the document
URL.revokeObjectURL(url);Orientation
vertical is the centred stack — the majority shape in the wild. horizontal
is the icon-left row, and it auto-adopts the vertical stack when its own
container is narrower than the @md container breakpoint — the switch reads
the zone's measured width, not the viewport, so it also works inside a narrow
column on a wide screen.
Button-styled CTA
ctaVariant="button" paints the CTA with the house primary button recipe
(buttonVariants, not a re-derivation) at the zone's own size. Below the
md viewport the button family's 44px touch floor applies, so xs, sm and
md share one height there. size="xl" inherits Button's hero step (96px,
24px type) — that is the Button ladder's deliberate outlier, so reach for lg
if you want a large zone with an ordinary chip.
It is still the zone's one label — a single tab stop, never a nested control — so keep the chip text short ("Select file") and let the Subheader carry the formats and the drag wording.
Compact
A field-height single row on the input family's height ladder, with the 44px phone floor. Header/Subheader are ignored (dev-warned) and there is no upload icon — the CTA line is the whole content, so the row reads as a field rather than as a shrunken dropzone.
On color
On a Surface plate the tone inherits from context: the dashed stroke, fill
washes, text inks, focus ring and item rows all switch to the surface-ink
recipes. An explicit tone prop wins over context. A drag turns the stroke solid
rather than thickening the fill, and ctaVariant="button" is the plate's inverse
chip.
Attach button (recipe)
No zone at all — a visually-hidden but focusable native input inside a
button-styled label. Keep the input sr-only (never display:none, which
kills keyboard access and native validation), and never place a real Button
next to it: two focusables for one action is the double-tab-stop bug.
Native form
With a name the FileList survives until submit (the usual value reset is
skipped) and the input is a real, in-flow control, so it participates in
FormData like any field.
The form sets noValidate. required stays for semantics, but the browser's
own validation bubble is unstyleable OS chrome — it carries none of our tokens
and vanishes on its own rather than persisting as an announced error. Route
validation through errorMessage instead: an inline row in a polite live
region that also sets aria-invalid and joins the describedby chain. (This is
the same reason a tooltip-based statusVariant was rejected for this
library — one error placement, ours.)
Multiple files
Sizes
Intents
Rounded
Radius follows the §2 panel ladder — none 0 · sm 6 · md 8 · lg 12 (default) ·
full 24px — and every inner corner (icon bubble, skeleton bars, item thumbs)
steps one size below the shell. The dashed SVG stroke reads the same
--radius-* tokens as the fill.
Direction
States
RTL
dir="rtl" mirrors the zone. Give FileUploadList the same dir (or nest it inside the FileUpload) and rows run from the right: glyph, name and meta, then actions at the left end, with each row's upload bar filling from the right. File sizes stay bidi-isolated. With dir unset the rows follow the page, but the upload bar stays left to right, because Progress does not inherit the page's direction.
API Reference
FileUpload
Extends the native file input's props (accept, multiple, capture,
name, required, …). Per the house forwarding split: aria-* land on the
input, everything unrecognised (data-*, test ids, style) lands on the
wrapper; className styles the wrapper, inputClassName the input.
| Prop | Type | Default | Description |
|---|---|---|---|
onFilesSelected | (files: File[], event) => void | — | Files that passed validation |
onFilesRejected | (rejections: FileUploadRejection[], event) => void | — | Per-file typed errors |
size | xs | sm | md | lg | xl | md | Density scale |
intent | none | error | warning | success | info | none | Stroke + tint meaning |
rounded | none | sm | md | lg | full | lg | §2 panel ladder (0/6/8/12/24px) |
orientation | horizontal | vertical | horizontal | Row (auto-stacks when narrow) or centred stack |
variant | dropzone | compact | dropzone | Full zone or field-height row |
ctaVariant | link | button | link | CTA as an underlined link or a primary button chip |
tone | default | on-color | inherits | Surface-ink recipes on coloured plates |
strings | Partial<FileUploadStrings> | English, Arabic under dir="rtl" | Override the announcements, validation messages, row labels, the input's fallback name (uploadFiles) and the size units (sizeBytes through sizeTerabytes). {name}, {value}, {count}, {size} and the other placeholders are filled in |
errorMessage | ReactNode | — | Polite error row under the zone; implies intent="error" |
maxSize / minSize | number | — | Byte bounds per file |
maxFiles | number | — | Cap per selection; only the surplus is rejected |
validate | (file) => FileUploadError | FileUploadError[] | null | — | Custom per-file validator |
preventDropOnDocument | boolean | true | Stops stray page drops navigating the tab |
hideIcon | boolean | false | Hides the icon chip. Dropzone only — compact renders no icon at all |
dir | ltr | rtl | inherits | Text and layout direction; rtl also defaults strings to Arabic |
className | string | — | Class for the outer wrapper |
containerClassName | string | — | Class for the visible dropzone box |
inputClassName | string | — | Class for the native input |
wrapperClassName | string | — | Deprecated — use className (targets the wrapper) |
FileUploadList
| Prop | Type | Default | Description |
|---|---|---|---|
size | FileUploadSize | surrounding zone's, else md | Row scale |
rounded | FileUploadRounded | lg | Thumb/bar corners (one step inside the ladder) |
tone | default | on-color | inherits | Surface-ink rows |
strings | Partial<FileUploadStrings> | the surrounding FileUpload's, else by dir | Override row labels, status announcements and size units for this list |
FileUploadItem
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | required | Filename — scopes every action's accessible name |
size | number | — | Bytes, shown human-readable and bidi-isolated for RTL |
description | ReactNode | — | Extra meta after the size |
status | uploading | processing | complete | error | — | Absent = settled row |
progress | number | — | 0–100 while uploading; omit for indeterminate |
errorMessage | ReactNode | — | Shown in the row while status="error" |
previewUrl | string | — | Thumbnail; falls back to extension glyph |
onRemove / onRetry / onCancel | () => void | — | Render Remove/Retry/Cancel {name} actions |
disabled | boolean | — | Dims the row, disables its actions and sets aria-disabled |
FileUploadSkeleton
Silent by default — pass label ("Loading attachments") for one role="status"
announcement per region; the bars are always aria-hidden. Mirrors size,
rounded, orientation, variant, ctaVariant, hideIcon, showHeader,
showSubheader, showCta. Pass the ctaVariant the loaded zone will use: a
button CTA is taller than a link one, and the placeholder reserves that box.
Error copy
The tested wording patterns. Defaults use the first three; keep the
rest for your own validate/transport errors:
| Situation | Pattern |
|---|---|
| Wrong type | "The selected file must be a [formats]" |
| Too large | "The selected file must be smaller than [size]" |
| Too many | "You can only select up to [n] files at the same time" |
| Empty file | "The selected file is empty" |
| Virus | "The selected file contains a virus" |
| Password-protected | "The selected file is password protected" |
| Transport failed | "The selected file could not be uploaded – try again" |
Keyboard
| Key | Action |
|---|---|
Tab | Focus the zone (its native input) |
Enter / Space | Open the OS file picker |
Tab (in a list) | Move through Cancel/Retry/Remove actions |
There is no keyboard drag for OS files — no web API exposes one. The focusable input opening the native picker is the accessible path; the drop target is an enhancement for pointer users.
Accessibility
- Name/description chain: the input is named by
Header + CTA(aria-labelledby) and described bySubheader→errorMessage(aria-describedby);errorMessagealso setsaria-invalidand renders in a pre-mounted polite region. - Announcements: one polite region per zone announces "Entered/Left drop
zone", selection counts, and rejection summaries; one per list announces
item status transitions ("report.pdf: upload complete."). Progress bars
are named through the house
Progress("{name} upload progress"). - Drag-reject is computed mid-drag from MIME types only (the platform exposes no name or size during drag, and Chrome may report an empty type — treated optimistically); size checks land on drop.
- Touch: the whole zone is the target; item actions sit on
IconButton's 44px tap extensions; drag copy disappears on coarse pointers. - Disabled item: the row dims to 50 % opacity, which takes its text under
AA — WCAG allows that only in an inactive component, so the row sets
aria-disabled(its actions are natively disabled) and axe and assistive tech read it as inactive. - AT matrix: VoiceOver + Safari passed 2026-09-14 — the picker reads "no file selected, Supported formats: .jpg, .png, file upload button". NVDA and JAWS — UNTESTED on this machine, not claimed.
Migration
| Old | New |
|---|---|
className (styled the input) | styles the wrapper; use inputClassName for the input |
wrapperClassName | deprecated alias of className (one minor, warn-once) |
rounded visuals | ladder realigned to §2 panel tokens: sm 4→6, md 6→8, lg 16→12px |
onFilesSelected got every file | now receives only files that passed validation |
| Skeleton announced always | silent by default; pass label to announce |
Agent notes
Stable hooks: data-slot="file-upload" | file-upload-zone | file-upload-header | file-upload-subheader | file-upload-drag-text | file-upload-cta | file-upload-icon | file-upload-error | file-upload-list | file-upload-item | file-upload-item-preview | file-upload-item-name | file-upload-item-meta | file-upload-item-progress | file-upload-item-error | file-upload-item-actions | file-upload-skeleton",
plus data-state="idle|dragOver|dragReject|disabled",
data-orientation, data-variant on the root and data-status per item.
Drive selection through the input (data-slot="file-upload" → input[type=file]);
read rejections from the polite regions or the consumer's onFilesRejected.