Building an Accessible File Dropzone
Put a visible, labelled <input type="file"> inside the drop area and style its button with ::file-selector-button, so keyboard, screen-reader, switch and touch users choose files the normal way while pointer users can also drop them. Attach dragenter/dragover/dragleave/drop handlers to the surrounding container, show the “drop here” state with a border change plus text (not colour alone), send dropped and chosen files through one function, and announce the result (“3 files added”) through a polite live region. Do not make the whole area a custom role="button" that hides the input; that is where most dropzones stop working for anyone not holding a mouse.
Most dropzone components start from the drag interaction and bolt accessibility on afterwards: a div with tabindex="0" and role="button" that opens a hidden input on Enter. That passes a quick keyboard test but fails in the details — the hidden input’s constraints are not announced, the focus ring sits on a div with no accessible name, and on mobile the tap target is a paragraph of instructions. Starting from the native input avoids the whole category. This page belongs to accessible upload interfaces in frontend UX, chunking and progress tracking; drag event mechanics are covered in drag-and-drop file uploads.
When to use this approach
- You want drag and drop on desktop without excluding anyone else.
- You are replacing a third-party dropzone component that fails keyboard or screen-reader checks.
- You need to meet WCAG 2.2, including 2.5.7 Dragging Movements and 2.5.8 Target Size.
Prerequisites
- A page with a form or upload flow that already works with a plain file input.
- A visually hidden utility class for live regions (the usual
clip-based.visually-hidden). - A screen reader for testing (VoiceOver, NVDA or TalkBack) and a keyboard.
Anatomy of the component
Implementation
The markup:
<section class="dropzone" aria-labelledby="dz-label">
<h3 id="dz-label" class="dropzone__label">Add photos</h3>
<p id="dz-rules" class="dropzone__rules">JPEG, PNG or HEIC, up to 50 MB each, up to 20 files.</p>
<input id="dz-input" class="dropzone__input" type="file" multiple
accept="image/jpeg,image/png,image/heic,.heic"
aria-labelledby="dz-label" aria-describedby="dz-rules dz-hint">
<p id="dz-hint" class="dropzone__hint">You can also drag files onto this area.</p>
<p class="dropzone__over-text" aria-hidden="true">Drop to add files</p>
<div id="dz-status" class="visually-hidden" role="status" aria-live="polite"></div>
</section>
The styles, including the focus ring and drag state:
.dropzone { border: 2px dashed #7a5c42; border-radius: 12px; padding: 1.5rem; position: relative; }
.dropzone.is-over { border-style: solid; border-color: #7a1515; background: #f2ead9; }
.dropzone__over-text { display: none; font-weight: 700; }
.dropzone.is-over .dropzone__over-text { display: block; }
.dropzone__input { font: inherit; max-width: 100%; }
.dropzone__input::file-selector-button {
font: inherit; min-height: 44px; padding: 0.5rem 1rem; margin-right: 0.75rem;
border: 2px solid #7a1515; border-radius: 8px; background: #fdf8f2; color: #7a1515; cursor: pointer;
}
.dropzone__input:focus-visible { outline: 3px solid #b34914; outline-offset: 3px; border-radius: 8px; }
@media (prefers-reduced-motion: no-preference) {
.dropzone { transition: border-color 120ms, background-color 120ms; }
}
The behaviour:
export function mountDropzone(root: HTMLElement, onFiles: (files: File[]) => { added: number; rejected: string[] }) {
const input = root.querySelector<HTMLInputElement>(".dropzone__input")!;
const status = root.querySelector<HTMLElement>("[role=status]")!;
let depth = 0; // counts nested dragenter/dragleave pairs
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes("Files");
function accept(files: File[]) {
const { added, rejected } = onFiles(files);
const parts = [];
if (added) parts.push(`${added} ${added === 1 ? "file" : "files"} added.`);
if (rejected.length) parts.push(`${rejected.length} not added: ${rejected.join("; ")}.`);
status.textContent = ""; // force re-announcement of identical text
requestAnimationFrame(() => { status.textContent = parts.join(" "); });
}
input.addEventListener("change", () => { accept(Array.from(input.files ?? [])); input.value = ""; });
root.addEventListener("dragenter", (e) => {
if (!hasFiles(e)) return;
e.preventDefault();
if (depth++ === 0) root.classList.add("is-over");
});
root.addEventListener("dragover", (e) => {
if (!hasFiles(e)) return;
e.preventDefault();
e.dataTransfer!.dropEffect = "copy";
});
root.addEventListener("dragleave", () => { if (--depth <= 0) { depth = 0; root.classList.remove("is-over"); } });
root.addEventListener("drop", (e) => {
if (!hasFiles(e)) return;
e.preventDefault();
depth = 0; root.classList.remove("is-over");
accept(Array.from(e.dataTransfer!.files));
});
// Stop the browser from opening files dropped just outside the zone.
window.addEventListener("dragover", (e) => { if (hasFiles(e)) e.preventDefault(); });
window.addEventListener("drop", (e) => { if (hasFiles(e) && !root.contains(e.target as Node)) e.preventDefault(); });
}
Line-by-line on the decisions that matter
- A real input, visibly rendered. The input’s button is focusable, has the label’s name, announces the rules through
aria-describedby, and on phones opens the system picker with camera and photo library. Nothing custom is needed for any of that. ::file-selector-buttonstyling. The part of the native control that looks dated is the button; it can be styled directly in all current browsers. A 44-pixel minimum height comfortably exceeds the WCAG 2.2 target size and suits touch.- Rules before the choice. Stating types, sizes and counts next to the label prevents most errors. The
acceptattribute narrows the picker, but dropped files ignore it, so validation happens inonFiles. - Depth counter for drag state.
dragleavefires when the pointer moves over a child element, which makes naive highlight logic flicker. Counting enter and leave pairs keeps the state stable; fixing dragleave flicker on drop zones compares alternatives. - Only react to file drags. Checking
dataTransfer.typesforFilesstops the zone lighting up when the user drags selected text or a link. - Drop state shown with shape and text. The border switches from dashed to solid and “Drop to add files” appears, so the state is visible without colour perception. The text is
aria-hiddenbecause a screen-reader user is not dragging. - Announcing the result. After a drop or choice, one polite message says what happened, including rejections. Clearing the region first makes identical consecutive messages (“1 file added.”) announce again.
Paths into the uploader
Pasting is a useful extra path for screenshots: listen for paste on the document while the uploader is visible, take clipboardData.files, and pass them to the same accept function. It helps keyboard users as much as anyone, because “take screenshot, paste” needs no pointer at all. Announce pasted files exactly as you do chosen or dropped ones.
Validating in the handler
The onFiles callback applies the same rules the label states: allowed types (by signature, not just extension — see validating dropped files before upload), maximum size, and maximum count including files already added. It returns what it added and a short reason for each rejection, phrased for people: “holiday.mov is not a photo” rather than “MIME type video/quicktime not allowed”. Rejected files do not enter the queue, but their reasons appear in the live announcement and next to the dropzone until the user adds more files, so they can be read at leisure. Writing accessible upload error messages has wording for common cases.
Folders need a decision. Dropping a folder in Chromium-based browsers yields a zero-byte entry in files unless you walk dataTransfer.items with webkitGetAsEntry(). Either support folders deliberately — walk the tree, show the count, and let users confirm — or detect the zero-byte entry and explain that folders are not supported, with a suggestion to select the files inside instead.
Configuration gotchas
The input is hidden with display: none and a label is styled as a button. Keyboard users cannot focus a hidden input in some browsers, and the label-as-button has no focus ring. Keep the input rendered and style its button.
Files dropped outside the zone open in the tab. The browser’s default for an unhandled drop is to navigate to the file, losing the user’s work. The window-level handlers above prevent that.
Screen readers announce “No file chosen” after every selection. Resetting input.value after reading the files keeps the control neutral; your file list is the source of truth.
The zone lights up when dragging text. Check dataTransfer.types for Files in every drag handler.
Verification
- Tab to “Choose files”, press Enter, pick two files: the status region announces “2 files added.”
- Drag a file across the zone’s child elements: the highlight stays on without flicker.
- Drop a file just outside the zone: the page does not navigate away.
- With VoiceOver on iOS, double-tap the button: the system sheet offers camera, photo library and files.
- Check the border colours at rest and during drag meet 3:1 contrast against the page background.
Frequently Asked Questions
Can the whole zone be clickable to open the picker?
It can, as a convenience for mouse users, by forwarding clicks on the container to input.click(). Keep the visible button too, and make sure the container itself is not focusable, so keyboard users do not meet two controls that do the same thing.
Should the zone take up the whole page during a drag?
A full-page drop overlay is fine as an enhancement, as long as it appears only during a file drag, announces nothing, and disappears on drop or when the drag leaves the window.
What heading level should the dropzone label use?
Whatever fits the page outline. In a form, a <label> or <legend> is often better than a heading; on an upload page where the dropzone is a major section, a heading helps screen-reader users jump to it. Either way, the same text must name the input.
Do dropzone libraries get this right?
Some do. Check that the library renders a real input you can see and style, respects your label, and lets you control announcements; otherwise, the fifty lines above are easier to own.