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.
Playground
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
(the USWDS/Elastic architecture). Wrap the drag wording in
FileUploadDragText: it renders only on fine-pointer devices, so a phone
never advertises "drag files here".
When to use
- Use
FileUploadfor choosing files from disk — forms, import flows, attachment pickers. Pair it withFileUploadListthe moment upload progress or per-file feedback matters. variant="compact"is the field-height row for dense forms (a resume slot, an admin panel) where a full zone is too loud.- The component never uploads. Transport (XHR/fetch, retry, chunking,
pause) belongs to the consumer;
FileUploadItemdisplays whatever state you hold. This is the same boundary react-dropzone and Ark UI draw. - Boundaries: chat composer attachments →
PromptInput· avatar with crop → composeAvatar+ a cropper dialog · camera capture UI → the OS (passcapturethrough) · cloud-source pickers (Drive/Dropbox) → product code beside the zone.
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.
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 Polaris
shipped.
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 Astryx's tooltip 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
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 |
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 |
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 |
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 |
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, hideIcon, showHeader, showSubheader,
showCta.
Error copy
The tested wording patterns (GOV.UK). 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.
Do / Don't
- Do put the constraint line in
FileUploadSubheader— it is wired to the input viaaria-describedby, so assistive tech hears it. - Do wrap drag wording in
FileUploadDragText. - Don't clear a valid selection because a later pick failed validation — the partition model exists so you never have to.
- Don't convey a row's error by colour alone;
errorMessagetext is the channel (the border is reinforcement). - Don't nest a real
Buttoninside the zone or next to a hidden input — one action, one focusable.
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. - AT matrix: VoiceOver — pending the ticket's manual pass (status stays
betauntil then). 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-cta | file-upload-icon | file-upload-error | file-upload-list | file-upload-item | 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|drag-over|drag-reject|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.