Most WordPress sites reach for a plugin the moment they need a contact form. It works, but it is not the only way. You can create forms in WordPress without…
Table of contents
Most of a drag and drop upload form is ordinary upload code. The drop zone adds a few drag event handlers on top of a native file input, and everything after that (FormData, progress, server checks) works the same as it would without dragging.
The input has to stay, and not as a courtesy. WCAG 2.5.7 Dragging Movements, a Level AA criterion added in WCAG 2.2 (W3C Recommendation, October 2023), requires a single-pointer alternative to any dragging function. A drop zone with no input behind it fails.
Dropped files also never pass through the file dialog. Type and size checks have to be written in JavaScript, and the server gets the final say.
How does a drag and drop file upload form work?

The HTML Drag and Drop API lets a page accept files released on a marked zone, and a standard file input sits next to it as the fallback.
Dropping is the easy part. After that the page checks each file against the allowed types, the size limit and the file count, then shows names, sizes and thumbnails so mistakes can be removed. The files go into a FormData object and travel as multipart/form-data, progress events update a status for each one, and the server repeats every check before it stores anything. Picking files in the dialog produces the same file list, so both paths end up in one pipeline.
The API gives the page nothing for free. The drop event only fires if the dragover handler calls preventDefault, and unless the drop is cancelled too, the browser may open the dropped file in the tab instead of passing it to your code, as MDN’s file drag and drop guide describes.
That is why the native input stays in the markup. If dragging fails for any reason (an old browser, a phone, a user who cannot drag), the picker still feeds the same pipeline.
Drop zone markup and the fallback file input

Build on a real input type=”file” and treat the drop zone as an upgrade layered over it. The form then keeps working with JavaScript switched off.
The form itself needs method=”post” with enctype=”multipart/form-data”, the encoding that carries files. Inside it goes an input type=”file” with the multiple attribute, tied to a label through matching for and id values. The drop zone can be a plain container, or the label itself, whichever receives the drag events. Add an element with role=”status” so selected files and errors get announced.
The accept attribute narrows the file dialog to listed extensions or MIME types, such as image/png or .pdf. MDN is explicit that it does not validate the selected files, it only guides the dialog.
Files dropped on a custom zone skip the dialog altogether. The type check has to live in JavaScript.
Tutorials disagree on whether to hide the input. One camp tucks it inside a label and lets the label act as both drop zone and click target.
Transloadit’s developer tutorials keep the native input visible and keyboard accessible, and restyle its button with ::file-selector-button. The same tutorial warns that a label dressed up as a button does not gain real button keyboard behavior.
For most projects the visible input wins. Less code, fewer ways to break for keyboard users.
The form around the zone follows established HTML form practices: one label per control, a real submit button, error text next to the field it describes.
Handling drag events and reading dropped files
See the Pen
Drag & Drop File Upload Form by Bogdan Sandu (@bogdansandu)
on CodePen.
The zone listens for dragenter, dragover, dragleave and drop. Call preventDefault on dragover and drop, then read the files from the drop event.
The drag events
dragenter fires as the pointer enters the zone, so the highlight class goes on there. dragover keeps firing while the pointer moves over the zone, and its handler has to call preventDefault or the drop event never arrives. When the pointer leaves, dragleave fires and the highlight comes off. On release, drop stops the default action and hands over the file list.
The highlight has a catch. dragleave also fires when the pointer moves from the zone onto one of its child elements, because dragenter fires whenever the immediate target changes, even to a descendant, and dragleave follows from that (WHATWG mailing list, 2012).
A reply on Mozilla bug 631713 calls this spec behavior, not a bug. So the flicker is yours to handle.
Two fixes work. Count entries (add 1 on dragenter, subtract 1 on dragleave, highlight while the count is above 0), or set pointer-events: none on the zone’s children so they never become targets.
Reset the counter inside the drop handler, since a drop does not fire dragleave. Add the same preventDefault guard on the window too, so a drop that misses the zone by a few pixels does not load the file into the tab.
Reading files from DataTransfer
The drop event’s dataTransfer object exposes files through two APIs: the files list on DataTransfer, and the DataTransferItemList. MDN’s current guide demonstrates the items list.
Each entry in the items list reports a kind, so the loop can skip dragged text or links and call getAsFile only on file entries. The files list is shorter to write and fits a zone that only ever receives files.
Every File object carries name, size (in bytes), type (the MIME type string) and lastModified (a timestamp in milliseconds). The validation step reads all of them.
Assigning dataTransfer.files to the hidden input’s files property is possible, and it replaces the whole list. Most forms skip that step, keep their own array of File objects and build FormData from the array, which also makes removal simple.
Validating and previewing files before upload

Validate type, size and count in JavaScript before anything is sent, then render one preview row per accepted file with a remove button. The checks save a round trip, and the server still repeats them.
Client-side validation rules
File.type is weaker than it looks. Browsers do not read a file’s bytes to set it, so a PNG renamed to .txt reports text/plain, and uncommon extensions return an empty string (MDN, File.type).
MDN advises against using the property as the only validation scheme. Treat it as a first filter, and read an empty value as unknown, never as valid.
Check the extension and file.type against the allowed list and require both to match. file.size is in bytes, so a 5 MB limit (5 x 1024 x 1024) works out to 5242880. For the file count, either reject the extras or queue them for a second batch.
Client checks exist for speed, and a user learns a file is too big before waiting on it. They stop nothing hostile, since any script can post to the endpoint without your page, which is why client-side and server-side validation are separate jobs.
Report each rejection with the file name and the rule it broke, in the style of clear form error messages. “report.pdf is 12 MB. The limit is 5 MB.” beats “Invalid file.”
Previews and removing files
Thumbnails take one call. URL.createObjectURL(file) returns a string that works as an img src.
FileReader.readAsDataURL does the same job through an asynchronous callback and returns a data URL. The object URL is shorter to write, and it needs cleanup.
Each createObjectURL call makes a new URL, even for the same file, and each one stays alive until released. MDN says browsers free them when the document unloads and advises revoking earlier with URL.revokeObjectURL, a method it lists as Baseline widely available since July 2015.
Revoke the URL once the image has loaded, and again when its row is removed.
A FileList is read-only, so removal works on your own array of File objects: delete the entry, re-render the list, release its preview URL.
Each row shows the file name, its size, a status (ready, too large, uploading, done) and a remove button.
Sending files with FormData, fetch or XMLHttpRequest
Append each file to a FormData object and post it as multipart/form-data. Use XMLHttpRequest when the form shows upload progress, because fetch does not report it.
| Feature | fetch | XMLHttpRequest |
|---|---|---|
| Upload progress events | None | xhr.upload progress event |
| Cancel an upload | AbortController signal | xhr.abort() |
| Streaming request body | Chromium 105 and later, with duplex: half | Not available |
| API style | Promises | Event handlers |
Do not set the Content-Type header yourself. The browser writes multipart/form-data with the boundary string, and a hand-written header drops the boundary.
fetch looks like the modern choice, and for a form without a progress bar it is. Append the files, post the FormData, read the error list from the JSON response.
Chrome for Developers documents request streaming from Chromium 105: pass a ReadableStream as the body and set duplex to half. The page lists the limits too: it fails on HTTP/1.x connections, requires CORS, always triggers a preflight, and Safari lacked the duplex option when it was written.
None of that is upload progress. A stream body lets the page count the bytes it hands to the browser, which is not the same as bytes the server has received, and two 2025 write-ups (OpenReplay and waspdev) both conclude that fetch exposes no upload progress events.
Tracking upload progress per file
The progress event on XMLHttpRequestUpload has been supported since Chrome 4, Firefox 3.5 and Safari 4, according to MDN’s compatibility data.
Send one request per file, so each row owns its progress bar and its cancel button. Percent done is event.loaded divided by event.total, times 100, and overall progress is the sum of loaded bytes over the sum of totals (OpenReplay).
- When event.lengthComputable is false, total is meaningless (MDN), so remove the value attribute on the progress element and show an indeterminate bar
- The loadend event also fires after an abort, an error or a timeout, with loaded and total both 0, so never compute a percentage there
Put the requests in a queue as well, because a big batch otherwise opens every connection at once (Code Boxx’s uploader queues for this reason). Cancel calls xhr.abort() and flips the row to a cancelled state.
What the server must verify before it stores an upload
OWASP’s File Upload Cheat Sheet says no single technique secures an upload and recommends defense in depth. On the server that means a stack of independent checks, each covering a gap the others leave.
| Layer | Catches | Gets bypassed by |
|---|---|---|
| accept attribute | A wrong pick in the dialog | Drops, scripts, any direct request |
| JavaScript checks | Honest mistakes, before the wait | A direct POST, an edited script |
| Server checks | Everything the client can lie about | Gaps in the checks themselves |
On file type, OWASP says to allow only the extensions the business needs (docx and pdf for a CV, say), and to decode the filename first so double extensions, null bytes and case tricks fail the filter. The user’s filename should be replaced with a generated one such as a UUID, or at least capped in length and restricted in characters.
Set a size limit, and apply it after decompression when the server unpacks archives. For storage, keep files on another host or outside the web root, let only authorized users upload, and put CSRF protection on the endpoint.
Content-Type is the weak point in all of this. OWASP calls it spoofable and useful only as a quick check, and says a file signature check must not stand alone because bypassing it is easy.
For images, OWASP recommends decoding and re-encoding to an allowed format with metadata stripped. It advises against accepting ZIP files at all, since they can carry any file type.
Node.js with Express and Multer
Multer’s limits object has no ceiling out of the box: fileSize and files both default to Infinity, according to the library’s README.
Set both. The README notes that limits help protect against denial of service attacks.
fileSize takes bytes and files takes a count. The allowlist goes in fileFilter, which rejects a file before it is stored. For storage, pick disk over memory, because large or numerous files can exhaust RAM under memory storage.
Mount Multer on the upload route only. The README warns that a global middleware lets a malicious user post files to routes you never planned for, and Multer skips any form that is not multipart/form-data.
PHP with $_FILES
PHP stores each upload in the $_FILES array and reports the outcome in an error field that holds one of the UPLOAD_ERR constants. UPLOAD_ERR_OK (0) means no error, so the content checks can start. UPLOAD_ERR_INI_SIZE (1) means the file exceeds upload_max_filesize in php.ini, and UPLOAD_ERR_FORM_SIZE (2) means it exceeds the MAX_FILE_SIZE value set in the HTML form.
The PHP manual points to the php.ini settings post_max_size and upload_max_filesize as the two limits that together cap what a request can carry. Map each code to a message the client can print next to the matching file.
How to make a drag and drop upload accessible

WCAG 2.5.7 Dragging Movements, a Level AA criterion, requires that any function operated by dragging can also be done with a single pointer and no dragging.
It arrived with WCAG 2.2, which became a W3C Recommendation on 5 October 2023 (European Commission, AccessibleEU Centre). A drop zone with no file input behind it is the failure case named most often in the implementation guides.
A zone that is the only way in fails, because voice-control and switch users have no command for dragging a file. The same zone with a visible Choose file button or input that opens the picker with one click passes.
The W3C wording exempts functions where the drag is the point (a signature field) and behavior determined by the user agent and not modified by the author. A custom zone runs author code on dragover and drop, so it sits inside the criterion.
One unofficial summary treats native drop targets as exempt, which reads the exemption more broadly than the W3C text does.
Keyboard support does not satisfy 2.5.7 by itself, because the criterion is about pointer alternatives. A native input covers both at once.
The GOV.UK Design System’s file upload component lets users press Choose file or drag a file into the input area. The DWP Design System’s checklist asks whether drag and drop is offered as an extra, never the only option.
Status messages need a live region. An element with role=”status” carries an implicit aria-live of polite and aria-atomic of true, and MDN says not to move focus into it when its text changes.
W3C’s technique ARIA22 still advises adding aria-atomic=”true” explicitly, because some environments do not treat status as atomic by default.
Announce the selection (“2 files selected: report.pdf, photo.png”) and each rejection with its reason. Completion or failure goes out per file, not on every progress tick.
GOV.UK’s component goes a step further. Its Nunjucks options include text announced to assistive technology when a drag leaves the zone without a drop, defaulting to “Left drop zone”.
The form around the zone owes the same things as any other: visible labels, a logical focus order, errors tied to their fields. Those are covered in broader form accessibility practices.
Build it yourself or use Dropzone.js, FilePond or Uppy?
Write it yourself for a form that takes a few files, previews them and posts to your own endpoint. Choose a library when uploads must resume, come from cloud sources, or share a framework’s drop zone hook.
Building it yourself means no dependency to patch, and the markup, focus order and messages stay yours. The price is that resumable uploads, retries and cloud sources become your job to write.
A library ships previews, progress bars and plugins ready to use, but it adds another surface to test with a keyboard and a screen reader.
- Dropzone.js turns any HTML element into a drop zone, uploads through XHR and offers thumbnail previews, but its README says it provides no server-side implementation
- FilePond is a thin-core, modular uploader with a ready-made interface (Uploadcare’s comparison)
- Uppy is modular too, with a plugin for tus resumable uploads and a Companion server for sources like Google Drive and Dropbox (Transloadit)
- react-dropzone only picks files and checks their type and size, so the upload code stays yours (Upstash’s comparison)
None of them touches the server. The Multer, $_FILES and OWASP checks stay your job whichever front end you pick.
Below resumable uploads and cloud sources, a library trades code you control for a dependency you maintain. On a WordPress site there is another route: a form plugin that ships a WordPress form with a file upload field handles the server side as well. The file upload field in IvyForms, for example, takes drag and drop or click to browse, and checks size, type and count as each file is added. The files then show up in the form entries and can be attached to email notifications.
When a drag and drop upload form is the wrong choice
The drop zone is the wrong tool on touch screens, and it adds little when a form takes a single file once. Large files posted through serverless functions are a separate problem.
On touch screens, three design systems treat the zone as a desktop extra. Queensland Health’s changes the mobile heading to “Select files to upload”, the UK DWP’s swaps in native upload options, and Shopify’s DropZone docs say drag and drop won’t be effective on mobile.
DWP’s research notes carry a hypothesis, not a finding: when drag and drop looks dominant, some users assume it is the only way to upload. Build the picker as the main control and the zone as the add-on, and leave the rest of the small-screen layout to mobile form practices.
A form that posts through a serverless function breaks near the platform’s body limit, however good the front end is.
- Vercel Functions: 4.5 MB request body, then a 413 FUNCTION_PAYLOAD_TOO_LARGE error (Vercel docs)
- AWS Lambda: 6 MB request payload for synchronous invocation (AWS docs)
Supabase Storage recommends resumable uploads for files that may exceed 6 MB (Supabase docs). Vercel’s own guidance is client uploads straight to Vercel Blob. Supabase builds on the tus protocol, where the client asks the server for its current offset and sends only the remainder.
For the single-file case, GOV.UK’s guidance goes past the drop zone question: ask for an upload only when it is critical to the service. A form that takes one PDF once a year gains little from a drop zone, and a plain input is the better build.
Drag and Drop File Upload Form FAQ
How do you accept a dropped folder?
Call webkitGetAsEntry on each item in dataTransfer.items. It returns a file or directory entry, and for a directory you create a reader and call readEntries until it returns an empty array.
Chromium-based browsers return at most 100 entries per call, according to MDN, which also lists the method as limited availability.
How do you build a drag and drop file upload in React?
Use the useDropzone hook from react-dropzone. Spread getRootProps on the container and getInputProps on the input, pass an onDrop callback, and set accept and maxSize to filter files.
The hook only picks and checks files. The FormData request and the server checks stay your code.
Can the whole page act as the drop target?
Yes. Attach dragenter, dragover and drop listeners to the window, show a fixed full-screen overlay on dragenter, and hide it on drop.
The child-element flicker returns at page level. Cache the target of the last dragenter and hide the overlay only when dragleave fires on that same target.
How do you style a drop zone with CSS?
Give the zone padding and a permanently visible border, then toggle a class from the drag handlers that strengthens the border and tints the background while a file hovers.
The DWP Design System also changes the heading text during a drag to confirm the zone is ready, so the cue never rests on color alone.
Testing a Drag and Drop File Upload Form Before Launch
Run the form through bad input in the order of the damage a failure would cause. Post a renamed executable straight to the endpoint, bypassing the page. Then upload files at each size limit, and one byte over. Last, finish a full upload by keyboard alone and again on a phone.
Set the form’s size limit below the smallest cap in the chain. Multer’s fileSize starts at Infinity, so a serverless host answers an oversized file with a bare 413 before the app explains anything.
As of November 2026, upload progress still requires XMLHttpRequest. Igalia’s Luke Warlow is developing a fetch progress API (an observer option), described in a September 2025 post by Jake Archibald, a Mozilla engineer. The WHATWG pull request is still marked work in progress, and the API’s shape may still change. The Interop 2026 fetch proposal Archibald mentions covers request streams, not progress events.
The same bad-input mindset applies to every other field, as laid out in these form validation practices.


