react nextjs accessibility patterns

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 Relationships
  • 2.1.1 Keyboard
  • 3.3.2 Labels or Instructions
  • 4.1.3 Status Messages
Two ways to build a styled file field On the left, a div drop zone with a click handler and a hidden input that is display none: not focusable, not announced, and unusable without a pointer. On the right, a real file input that is visually hidden but still focusable, with a label styled as the drop zone, so clicking the label opens the picker, tabbing reaches the input, and the accessible name comes from the label. Div drop zone, hidden input Drop files here input has display:none — not focusable div has no role and no name unusable without a pointer Real input, styled label Choose files or drop them here label element, styled freely input is visually hidden but focusable label supplies the accessible name picker opens by click, Enter or Space Visually hidden is not the same as display:none — the first keeps the control in the tab order, the second removes it.

Prerequisites

  • A visually-hidden utility that clips rather than hides (clip-path: inset(50%), not display: 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, never display: none or visibility: 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 accept and multiple on 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 Keyboard failure and, under WCAG 2.2, a 2.5.7 Dragging Movements one too.

Announce What Was Selected

After the picker closes, the page has changed and nothing says so. This is the step most implementations skip.

What has to change after files are chosen Three consequences of a file selection. A polite status announces the count and the names, such as two files selected, report dot pdf and notes dot txt. The selected files render as a list where each entry has a remove button whose name includes the file name. Any rejected file, for example one over the size limit, is reported in the same status with the reason, rather than being silently dropped. Announce the selection, politely, once "2 files selected: report.pdf and notes.txt" Render the files as a list, each removable report.pdf · 240 KB Remove report.pdf Report rejections in the same status "video.mov was not added: files must be under 10 MB" — never a silent drop.

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 a role="progressbar" with aria-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".
Progress that reports without interrupting A progress bar filled to about seventy per cent, with three annotations. The bar itself uses the progress element, which exposes its value to assistive technology without announcing every change. A separate polite region announces only milestones at twenty-five, fifty and seventy-five per cent plus completion. A cancel button beside the bar is named after the file so it makes sense out of context. 70% Cancel upload <progress> — exposes its value on demand, announces nothing on change The user can query it whenever they want; it never interrupts them. Polite region — milestones only: 25%, 50%, 75%, then "Upload complete" Four announcements per upload instead of sixty.

How to Verify

  • Tab test. Tab to the field. Focus must land on the input with a visible ring on the styled label, and Enter or Space must 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: none on 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.