Skip to content

File Upload UX: 5 Proven Fixes for Web Forms

File Upload UX: 5 Proven Fixes for Web Forms

A quote request with attachments, a CV form, a warranty claim with photos from a phone: the customer picks a file, presses send, and the page answers “file is too large”. They try again with a screenshot, the field clears itself, and the tab closes. File upload ux is judged in those two minutes, and it is usually the cheapest part of a form to repair.

Code: Oat-Upload, MIT licensed.

Oat Upload is a small extension for the Oat UI library. It wraps a native <input type="file"> and adds a drop zone, previews, removal, validation and progress. No runtime dependencies, no build step on the server, and a form that still submits when the script fails.

Where a file upload ux loses the customer

Five failures repeat across projects, and none of them is the upload itself.

The customer cannot see what they attached. A phone camera names files IMG_4821. Without a preview, the wrong document travels to support, and someone corrects it by hand a day later.

The limit is announced after the attempt. “Max 5 MB” in small grey text under the field is a hint, and a hint is not a check. The component compares size before the request leaves the browser, so the rejection costs a second rather than a round trip.

The error erases the choice. A rejected file that clears the field makes the customer select everything again. Here the rejected file is dropped from the list and the rest stays.

A slow connection shows nothing. Without a progress element, forty seconds of upload look like a frozen page, and people reload. A reload cancels the upload.

Removal is impossible. Attach the wrong contract, and the only way out is a page reload that loses the rest of the form.

The input stays the source of truth

<ot-upload> does not replace the field, it grows around it:

<ot-upload max-size="5242880" max-files="5">
  <input type="file" name="attachments" multiple accept="image/*,.pdf">
</ot-upload>

That is the whole integration. The form posts attachments exactly as before, the server keeps its own validation, and a developer who reads the template sees a file input rather than a framework.

The file list on the page and the file list in the browser are kept in sync through DataTransfer where the browser supports it, which means the preview and the actual submission cannot drift apart.

Validation that speaks before the request

Validation reads the native input first and the <ot-upload> attributes second, so accept, multiple and disabled behave the way the browser defines them. max-size and max-files cover the two limits that a browser does not enforce on its own.

A rejected file fires ot-upload-error with a reason of type, size, count or disabled and a message meant for a person. That reason is what lets a form show “this PDF is 12 MB, the limit is 5 MB” instead of “invalid file”.

Progress and removal without a spinner

Progress is a native <progress> element, updated by setProgress(). Your own upload code calls it with a number from 0 to 100, and the bar reflects reality rather than an animation. A call with null or resetProgress() hides it again.

Removal is a button per row, and remove() takes an index, a file or an item id. The field updates with it, so what the customer sees is what gets sent.

For teams that already have markup, the parts are explicit: data-upload-dropzone, data-upload-list, data-upload-error, data-upload-progress, data-upload-status. The component fills the pieces you provide and builds the rest if you provide nothing.

Where it does not fit

Uploading a 2 GB video, resumable uploads across network switches and chunked transfer to S3 are a different problem, and a component around a native input will not solve them. Ten thousand files in one request is also a different problem, and the answer there is a server-side queue and a different interface.

For a form with five attachments, this is the correct scope, and the page stays a page.

What it costs

One stylesheet, one script, MIT, no dependencies. For a project quoted at 30–50 $ per hour the change is a morning, and it removes a class of support tickets that nobody invoices: the ones about files.

Related: web development for how a form scope is priced, and html table sorting for the same argument about keeping native markup instead of replacing it.