Accessible File Upload Inputs in React
<input type="file"> is keyboard-accessible, screen-reader-friendly and universally understood — and almost nobody ships it as-is, because its default rendering cannot be styled. What replaces it is usually a <div> drop zone with a click handler, which is unreachable by keyboard, unannounced by screen readers, and impossible to use on a device with no drag capability at all.
The fix is not to avoid custom styling. It is to keep the real input and style around it. This guide covers that pattern, plus the parts teams forget: announcing the selected files, making the drop zone optional, and reporting progress without interrupting.
It extends Form Handling with React Hook Form & A11y.
WCAG Success Criteria Addressed:
1.3.1 Info and Relationships2.1.1 Keyboard3.3.2 Labels or Instructions4.1.3 Status Messages
Prerequisites
- A visually-hidden utility that clips rather than hides (
clip-path: inset(50%), notdisplay: none). - A place to render a status region for the selected-files announcement.
Keep the Real Input
Every accessibility property you want — focusability, the file picker, the announced role, the accept filter, mobile camera integration — comes from the native input.
Implementation Guidelines:
- Render
<input type="file">and hide it with a clip-based utility, neverdisplay: noneorvisibility: hidden, both of which remove it from the tab order. - Associate a
<label>and style that as the drop zone. Clicking a label activates its input, so the picker opens with no JavaScript. - Give the input a visible focus indicator by styling the label with
:has(:focus-visible), since the input itself is not visible. - Keep
acceptandmultipleon the input; they drive both the picker and the mobile keyboard-camera options.
export function FileField({ id, label, hint, accept, multiple, onFiles }: FileFieldProps) {
return (
<div className="file-field">
<label htmlFor={id} className="file-dropzone">
<span className="file-dropzone-title">{label}</span>
{hint && <span id={`${id}-hint`} className="file-dropzone-hint">{hint}</span>}
</label>
<input
id={id}
type="file"
className="visually-hidden"
accept={accept}
multiple={multiple}
aria-describedby={hint ? `${id}-hint` : undefined}
onChange={(event) => onFiles(Array.from(event.target.files ?? []))}
/>
</div>
);
}
/* The input stays focusable; the label carries the focus ring. */
.file-dropzone:has(input:focus-visible),
.file-field:has(input:focus-visible) .file-dropzone {
outline: 3px solid var(--focus);
outline-offset: 2px;
}
Drag and Drop Is the Enhancement
Dropping files is convenient for pointer users and impossible for everyone else, so it belongs on top of the picker rather than instead of it.
Implementation Guidelines:
- Add drop handlers to the label element; the picker path continues to work untouched.
- State both options in the visible text: "Choose files or drop them here". A drop zone with no wording about clicking looks like it only accepts drags.
- Do not rely on hover-only visual feedback for the drag state; keep a text or border change that survives high-contrast mode.
- Never make drag the only way to add a file. That is a
2.1.1 Keyboardfailure and, under WCAG 2.2, a2.5.7 Dragging Movementsone too.
Announce What Was Selected
After the picker closes, the page has changed and nothing says so. This is the step most implementations skip.
Implementation Guidelines:
- Announce the count and the names through a polite status region after the selection settles.
- Render the selected files as a list, with a remove button per file named "Remove report.pdf" rather than a bare ×.
- Move focus sensibly when a file is removed: to the next remove button, or to the field when the list empties.
- Report rejected files with the reason in the same status. Silently discarding an oversized file leaves the user certain they added it.
<p role="status" aria-live="polite" className="visually-hidden">
{selection.length > 0
? `${selection.length} file${selection.length > 1 ? 's' : ''} selected: ${names}`
: ''}
{rejected.length > 0 ? ` ${rejected[0].name} was not added: ${rejected[0].reason}` : ''}
</p>
Progress Without Interruption
Upload progress is the classic live-region trap: a percentage that updates sixty times produces sixty announcements.
Implementation Guidelines:
- Use a real
<progress>element (or arole="progressbar"witharia-valuenow) for the visual bar; it is not a live region and does not announce on every change. - If you want spoken progress, throttle it to milestones — 25%, 50%, 75%, done — in a separate polite region.
- Announce completion and failure clearly; those are the two moments that actually matter.
- Keep a cancel button reachable during the upload, and name it with the file: "Cancel upload of report.pdf".
How to Verify
- Tab test. Tab to the field. Focus must land on the input with a visible ring on the styled label, and
EnterorSpacemust open the picker. - No-pointer test. Add and remove a file using only the keyboard. If removal is only possible by clicking an ×, it is not done.
- Screen reader pass. After choosing files, confirm one announcement naming the count and files, and that each remove button's name includes the file.
- Rejection test. Select a file that violates the constraints; the reason must be announced, not just shown in red.
- Progress test. Upload a large file and listen. Continuous percentage announcements mean the progress element is inside a live region it should not be in.
- Add the same file twice. The component should either replace it or explain why it was ignored; silently doing nothing looks identical to a broken control.
- Remove every file one by one. Focus must land somewhere sensible after each removal, and the status region should report the new count rather than going quiet as the list empties.
Common Accessibility Mistakes
display: noneon the input. Removes it from the tab order, so the field is pointer-only.- A
<div>drop zone with a click handler. No role, no name, no keyboard path. - No announcement after selection. The most common gap: the file list appears and nothing is said.
- Remove buttons named "×". Announced as "times" or nothing at all, with no indication of which file they remove.
- Silent rejection. The user believes the file was added and only discovers otherwise on submit.
- Progress inside a live region. Sixty announcements for one upload.
- A drop zone that also swallows clicks. Handlers added to a wrapper element sometimes intercept the click before it reaches the label, so the picker never opens and only dragging works — an easy regression to introduce when adding drag support later.
Conclusion
An accessible file field is the native input with styling wrapped around it, not a replacement for it. Keep <input type="file"> in the tab order, style its <label> as the drop zone, add drag handling as an enhancement, and then do the part everyone forgets: say what was selected, name the remove buttons after their files, explain rejections, and keep progress out of the live region. That is roughly sixty lines of component code, and it is the difference between a field that works for everyone and one that quietly excludes anyone without a mouse.
Two related details are worth handling while you are in this component. First, respect the accept list on the server as well, because client-side filtering is a convenience rather than a constraint. Second, keep the field usable when JavaScript fails: a plain <input type="file"> inside a form that posts normally still works, whereas a drop zone whose only handler is a React callback does not. Progressive enhancement here costs nothing, and it is the difference between a degraded upload and no upload at all.
Frequently Asked Questions
Can I keep the input inside the label instead of using htmlFor?
Yes — an input nested inside a label is implicitly associated, and it avoids id management entirely. Either approach works; be consistent, because mixing them in one codebase leads to fields with two labels or none.
How do I show the file name in the styled control? Render it in the list beneath the field rather than replacing the label text. Overwriting the label loses the instruction ("Choose files or drop them here") that tells everyone how to change the selection.
Should the drop zone announce when a drag enters it? No. Drag events are pointer-only, so any announcement is for users who are not dragging. Change the visual state instead, and make sure the change is not conveyed by colour alone.
What about very large file lists? Show the count prominently and the first few names, with the rest behind a disclosure. Announce the count rather than reading twenty file names into a status region, which takes longer than most users will tolerate.
Does accept provide any accessibility benefit?
Yes, indirectly: it filters the picker so users are not offered files that will be rejected, which is especially valuable on mobile. It is not a validation mechanism — users can still choose other files — so keep the server-side check and the announced rejection message.
Related guides
- Form Handling with React Hook Form & A11y — the parent guide on accessible form fields.
- Announcing React Hook Form Submission Errors — reporting a rejected upload at submit time.
- Keyboard-Accessible Drag and Drop Alternatives — the wider rule that drag is never the only path.
- Accessible Form Validation & Error States — where rejection messages belong.