Accessible file uploads
Upload controls fail accessibility review more often than almost any other widget, because the interesting parts — drag-and-drop, live progress, per-file errors — are exactly the parts that are hardest to expose to assistive technology. Here is what to check, and how each piece should behave.
The ten-minute audit. Unplug your mouse. Can you select a file, start the upload, watch it progress, cancel one, and recover from an error — using only the keyboard? Then turn on a screen reader and do it again with your eyes closed. Most upload UIs fail on the second or third step, and you will find it faster than any checklist.
1. Every control needs an accessible name
This is the most common failure, and the easiest to ship by accident. An icon-only button whose only content is an <svg> or an icon-font <span> has no accessible name at all — a screen reader announces “button” and nothing else. The user hears that there is something there, but not what it does.
<!-- WRONG - announces "button" -->
<button type="button"><svg class="icon-upload">...</svg></button>
<!-- RIGHT - the icon is decorative, the button is named -->
<button type="button" aria-label="Attach files">
<svg class="icon-upload" aria-hidden="true" focusable="false">...</svg>
</button> Two details people miss. Mark the icon aria-hidden="true" so its content is not announced alongside the label, and add focusable="false" because SVG elements are focusable in some browsers and will otherwise appear as stray tab stops. And do not add an aria-label to a button that already has visible text — it overrides the visible text, so the two can drift apart and voice-control users who say what they see will not be understood.
In CoreUpload, an icon-only browse button gets a default accessible name automatically, and you can set your own:
<core-upload asp-upload-url="/api/upload/upload"
asp-button-text=""
asp-button-label="Attach a receipt"></core-upload> 2. Drag-and-drop needs a keyboard path
A drop zone is a convenience, never the only way in. There is no keyboard equivalent of dragging a file from the desktop, so a visible, focusable browse button must always exist alongside it. Hiding the native <input type="file"> is fine — that is how nearly every styled uploader works — as long as a real focusable control triggers it.
Hide it with display:none or visibility:hidden, not by moving it off-screen with opacity:0 or negative offsets while leaving it focusable; otherwise keyboard users land on an invisible control with no idea where their focus went.
If the drop zone itself is interactive on click, it needs a role, a name, a tab stop and a keyboard activation path — at which point a plain <button> is usually the better answer.
3. Announce progress — but not 200 times
Progress is the part that most often gets announced badly. A naive live region that updates on every progress event will read out every percentage change, drowning the user in noise and making the page unusable. But saying nothing means a blind user cannot tell whether a 400 MB upload is moving or dead.
The balance that works: use role="progressbar" with aria-valuenow for the value itself (screen readers expose it on demand rather than announcing every change), and reserve a polite live region for milestones only — started, roughly every 25%, finished, failed.
<div role="progressbar" aria-valuemin="0" aria-valuemax="100"
aria-valuenow="42" aria-label="Uploading report.pdf"></div>
<div aria-live="polite" class="visually-hidden" id="upload-status"></div>
<script>
let lastAnnounced = 0;
uploader.on('progress', (file, percent) => {
if (percent - lastAnnounced < 25) return; // milestones only
lastAnnounced = percent;
document.getElementById('upload-status').textContent =
`${file.name}: ${Math.round(percent)} percent`;
});
</script> Use aria-live="polite", not assertive. Assertive interrupts whatever the user is currently reading; an upload reaching 50% does not deserve that. Save assertive for failures that need immediate attention.
4. Errors must be findable, not just visible
A red border is not an error message. When a file is rejected, three things need to happen: the message must be announced, it must be associated with the control, and it must say what to do next.
- Put the message in a live region (assertive is justified here) or move focus to it.
- Link it to the input with
aria-describedby, and setaria-invalid="true"on the control. - Write the message so it stands alone: “report.pdf is 32 MB. The limit is 10 MB.” beats “Invalid file.” — a screen reader user may hear it with no visual context at all.
- Never signal state with colour alone. Add an icon with a text alternative, or the word “Failed”.
5. The queue is a list, and focus must survive it
Each queued file typically carries buttons — remove, retry, pause. Two rules keep that usable.
First, name the buttons per file. Fifteen buttons all called “Remove” are useless when a screen reader lists the page's controls; aria-label="Remove report.pdf" is unambiguous. Include the file name in the accessible name even when the visible label is just an icon.
Second — and this is the bug almost every uploader has — do not destroy focus on re-render. If the queue is redrawn by replacing its innerHTML on every progress tick, the focused element is removed from the document and focus falls back to <body>. A keyboard user is thrown to the top of the page several times a second, which makes the control impossible to operate. Update rows in place, or record the focused element and restore focus after the swap.
6. Respect the user's display settings
- Contrast. Progress bars and drop-zone borders are frequently the worst offenders — a pale grey dashed border on white can fall below 3:1 and effectively vanish for low-vision users.
- Zoom and reflow. At 200% zoom the queue must still be usable without horizontal scrolling. Long file names need
overflow-wrap, not truncation that hides which file failed. - Reduced motion. Honour
prefers-reduced-motion: keep the progress value updating, drop the animated stripes and slide-in transitions. - Focus visibility. Never
outline: nonewithout a replacement. A custom focus ring is fine; no focus ring means a keyboard user cannot tell where they are.
7. Give people enough time
Session timeouts and auto-dismissing toasts both cause accessibility failures on upload screens. A user relying on a screen reader, or simply reading slowly, may still be working through the queue when a three-second toast disappears with the only copy of the error. Keep important messages on the page until dismissed, and if a session can expire mid-upload, warn before it does.
A checklist you can hand to QA
- Every button has an accessible name; icons are
aria-hiddenandfocusable="false" - A visible, focusable browse control exists alongside any drop zone
- Tab order is logical; focus is always visible; no stray tab stops
- Progress uses
role="progressbar"; milestones announce politely, not every tick - Errors are announced, linked with
aria-describedby, and specific about the fix - Per-file buttons name their file; focus survives queue re-renders
- Usable at 200% zoom; honours
prefers-reduced-motion; contrast meets 3:1 for UI, 4.5:1 for text - Messages are not on a timer the user cannot control
CoreUpload ships keyboard navigation and these ARIA patterns in the built-in queue — see the keyboard accessibility demo. If you build a custom UI with the JS API, the responsibility moves to your markup, and this page is the list to work through.