Upload

Files chosen from a picker or dropped onto a target, with a list of what is uploading, what succeeded and what failed.

Accessibility

What Upload does for you, and what it leaves to the page around it.

What it renders

Every Upload has a real <input type="file"> carrying name, accept, multiple and the directory attributes. It is hidden with display: none, so it cannot be focused, reached or labeled. The control is whatever you put around it.
  • The input sits inside the trigger, and a click on the trigger opens it. Whatever can be activated in the trigger is the control.
  • aria-* and data-* props passed to Upload land on the hidden input. An aria-label there names something nobody can reach, so name the trigger instead.
  • id is worth passing, since a <label> uses it. It is left off when there are no children or the Upload is disabled, so a label cannot open a picker that is not there.

Naming the trigger, and giving it a tab stop

hasControlInside defaults to true, so the Upload element gets no role and no tabIndex. It expects your children to contain the control.
  • So put a control there. A <Button>Upload</Button> child gives you a real button with its own focus ring and name. A styled <div> gives you a control no keyboard can reach, and nothing warns.
  • The button's text is its accessible name, so “Upload” alone is thin when a form has three upload fields. Write “Upload invoice”.
  • An icon-only trigger still needs that word, hidden with sr-only or set as aria-label on the button itself.
  • On picture-card and picture-circle, the children render inside a dashed tile, usually a plus glyph and a short label. That label is the name, so keep it.
  • hasControlInside={false} puts role="button" and tabIndex="0" on the Upload element instead. Use it only when your children are not focusable, and read the next section first, since Dragger works the same way.

The drop area needs a keyboard route too

Dropping is a pointer gesture with no keyboard equivalent. Upload.Dragger sets hasControlInside={false}, which puts role="button" and tabIndex="0" on the area so it can be reached and opened with the keyboard.
  • Only Enter opens the picker. Space does not, unlike a button, and it is what a keyboard user tries next. Where that matters, put a real Button inside the area as well.
  • The area's name comes from its contents, so the instruction line inside it (“Click or drag a file here to upload”) is its accessible name. Write it as an instruction, not a heading.
  • Disabled takes the area out of the tab order. A keyboard user cannot land on it to find out why it is unavailable, so say why in text beside it.
  • The hover state is the only feedback a drag gets. The file landing in the list confirms the drop, so keep that list visible without scrolling.
  • pastable adds a paste route, which works from the keyboard and is invisible. Mention it in the hint line.

Announcing progress and completion

Each uploading row renders a Progress bar named from the file: <Progress percent={file.percent} aria-label={file["aria-label"]} />. Both are fields on UploadFile, and nothing fills them for you.
  • Set aria-label on each file in your controlled list, or point aria-labelledby at the row's name. Without one the bar announces a percentage with no subject.
  • The bar shows only while the status is uploading and is removed on completion, so nothing announces “done”.
  • Announce the outcome yourself from onChange, once per file, in a polite live region: “invoice.pdf uploaded”, “invoice.pdf failed”. The message statics already render into one.
  • Do not announce every tick. onChange fires on each progress event, and a live region driven from it reads a number over and over.
  • Do not disable the trigger while an upload runs. It reads as broken, and it takes the only named control out of the tab order.

Naming the actions on a row

The remove and download icons are real buttons (type="text", size="small") with a title and aria-label from the locale. They are named and focusable already. The names need attention.
  • The default names are generic: “Remove file”, “Download file”, “Preview file”. In a list of five they are five identical buttons. Override them through Upload's locale.
  • The file name is the preview control. With a url it is an <a>. Without one it is a span with role="button", tabIndex={0} and an Enter and Space handler, so it works from the keyboard either way.
  • The picture-card preview action shows only when the file has a url or a thumbUrl. A card with neither can be hovered with a mouse and not opened from the keyboard.
  • Card actions live in a hover overlay, and hover is not focus. They stay in the DOM so the keyboard reaches them. Check that focusing one shows the overlay: an action at opacity 0 with a focus ring off the tile is worse than no overlay.
  • Replacing an icon through showUploadList keeps the button, and with it the name. Replacing the row through itemRender does not: you name everything in it.

An error that is only red is not an error

A failed row turns red and shows the reason in a tooltip. That is a color and a hover, and neither reaches everyone.
  • The reason is file.response when it is a string, then file.error.statusText, then file.error.message, then the locale's “Upload error”. A JSON body falls through to those two words.
  • Put the reason in text as well: an Alert under the field, or the form item's error slot. The tooltip can stay.
  • A file rejected with Upload.LIST_IGNORE gets no row at all. Nothing appears and nothing is said, so show a message, or the picker looks broken.
  • The icon pack glyphs turn red with a failed row. If you restyle them, keep that.

Contrast and motion

  • The picture-card trigger tile and the Dragger area have dashed borders, and that border is the only thing saying “drop here”. It needs 3:1 against the surface behind it.
  • The card overlay draws white icons on a dark scrim. A custom icon that takes the body text color lands dark on dark, so give it a light color.
  • A thumbnail can be any image, so keep the file name on the row, not printed over the tile.
  • Every color comes from the active theme, so contrast depends on the theme. After changing the seed colors, check these pairs. The Tokens tab lists what this component reads.
  • The uploading spinner only spins under motion-safe:. The fade and progress transitions turn off with theme={{ token: { motion: false } }} on a ThemeScope.