DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
FieldFieldsetFormFile UploadColor PickerMediaTrimmer
AI agentsContributing
  1. Components
  2. File Upload

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/ui

When to use

  • Choosing files from disk: forms, import flows, attachment pickers. Pair it with FileUploadList the 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 with aria-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. errorMessage text 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 FileUploadItem only 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 capture through 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 Button nested 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

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
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

example.tsx
<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:

example.tsx
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.

PropTypeDefaultDescription
onFilesSelected(files: File[], event) => void—Files that passed validation
onFilesRejected(rejections: FileUploadRejection[], event) => void—Per-file typed errors
sizexs | sm | md | lg | xlmdDensity scale
intentnone | error | warning | success | infononeStroke + tint meaning
roundednone | sm | md | lg | fulllg§2 panel ladder (0/6/8/12/24px)
orientationhorizontal | verticalhorizontalRow (auto-stacks when narrow) or centred stack
variantdropzone | compactdropzoneFull zone or field-height row
ctaVariantlink | buttonlinkCTA as an underlined link or a primary button chip
tonedefault | on-colorinheritsSurface-ink recipes on coloured plates
stringsPartial<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
errorMessageReactNode—Polite error row under the zone; implies intent="error"
maxSize / minSizenumber—Byte bounds per file
maxFilesnumber—Cap per selection; only the surplus is rejected
validate(file) => FileUploadError | FileUploadError[] | null—Custom per-file validator
preventDropOnDocumentbooleantrueStops stray page drops navigating the tab
hideIconbooleanfalseHides the icon chip. Dropzone only — compact renders no icon at all
dirltr | rtlinheritsText and layout direction; rtl also defaults strings to Arabic
classNamestring—Class for the outer wrapper
containerClassNamestring—Class for the visible dropzone box
inputClassNamestring—Class for the native input
wrapperClassNamestring—Deprecated — use className (targets the wrapper)

FileUploadList

PropTypeDefaultDescription
sizeFileUploadSizesurrounding zone's, else mdRow scale
roundedFileUploadRoundedlgThumb/bar corners (one step inside the ladder)
tonedefault | on-colorinheritsSurface-ink rows
stringsPartial<FileUploadStrings>the surrounding FileUpload's, else by dirOverride row labels, status announcements and size units for this list

FileUploadItem

PropTypeDefaultDescription
namestringrequiredFilename — scopes every action's accessible name
sizenumber—Bytes, shown human-readable and bidi-isolated for RTL
descriptionReactNode—Extra meta after the size
statusuploading | processing | complete | error—Absent = settled row
progressnumber—0–100 while uploading; omit for indeterminate
errorMessageReactNode—Shown in the row while status="error"
previewUrlstring—Thumbnail; falls back to extension glyph
onRemove / onRetry / onCancel() => void—Render Remove/Retry/Cancel {name} actions
disabledboolean—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:

SituationPattern
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

KeyAction
TabFocus the zone (its native input)
Enter / SpaceOpen 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 by Subheader → errorMessage (aria-describedby); errorMessage also sets aria-invalid and 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

OldNew
className (styled the input)styles the wrapper; use inputClassName for the input
wrapperClassNamedeprecated alias of className (one minor, warn-once)
rounded visualsladder realigned to §2 panel tokens: sm 4→6, md 6→8, lg 16→12px
onFilesSelected got every filenow receives only files that passed validation
Skeleton announced alwayssilent 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.

FormColor Picker

On this page

When to useWhen not to useInstallationUsageAnatomyExamplesItem list with statusesValidationFile-type icons and previewsOrientationButton-styled CTACompactOn colorAttach button (recipe)Native formMultiple filesSizesIntentsRoundedDirectionStatesRTLAPI ReferenceFileUploadFileUploadListFileUploadItemFileUploadSkeletonError copyKeyboardAccessibilityMigrationAgent notes