DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

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

Usage

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 FileUpload for choosing files from disk — forms, import flows, attachment pickers. Pair it with FileUploadList the 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; FileUploadItem displays whatever state you hold. This is the same boundary react-dropzone and Ark UI draw.
  • Boundaries: chat composer attachments → PromptInput · avatar with crop → compose Avatar + a cropper dialog · camera capture UI → the OS (pass capture through) · 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.

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

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

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:

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.

Do / Don't

  • Do put the constraint line in FileUploadSubheader — it is wired to the input via aria-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; errorMessage text is the channel (the border is reinforcement).
  • Don't nest a real Button inside 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 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.
  • AT matrix: VoiceOver — pending the ticket's manual pass (status stays beta until then). 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-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.

FieldsetFlex

On this page

PlaygroundInstallationUsageAnatomyWhen to useExamplesItem list with statusesValidationFile-type icons and previewsOrientationButton-styled CTACompactOn colorAttach button (recipe)Native formMultiple filesSizesIntentsRoundedDirectionStatesAPI ReferenceFileUploadFileUploadListFileUploadItemFileUploadSkeletonError copyKeyboardDo / Don'tAccessibilityMigrationAgent notes