Designing the complex form

Part 05 · Field types

File upload and autocomplete — the heavy interactions

Two field types that look simple on a comp and break in ten different ways under real data.

01

File upload — the anatomy

A file upload field has five visible states the user passes through, and most implementations design only the first one. The result is a field that looks great empty and falls apart the moment a real file enters it.

  1. 01Empty — the drop zone or trigger. Designed; reviewed; shipped.
  2. 02Uploading — progress visible, with a cancel that actually aborts.
  3. 03Uploaded — filename, size, thumbnail if relevant, and a Remove control.
  4. 04Error — the upload failed; show why and offer Retry, not just a generic "try again."
  5. 05Multi-file — when there's more than one, the list has to be scannable and individually-actionable.

The empty state of an upload field is design polish. The error state is where users stay or leave.

02

Four paths in — drag, click, paste, camera

Most upload fields support one or two of these. The good ones support all four. Each one is mechanically different and supports different users.

Drag-drop

The obvious one. The detail people miss: the hover state when a file is being dragged over the drop zone. The drop zone needs to visually change — border, fill, label — so the user knows the drop will register. Without it, drops feel like they vanish.

Click to browse

Also obvious. The detail people miss: the accept attribute. "image/*" is too broad; the browser shows every image-adjacent file. List MIME types explicitly.

<input
  type="file"
  accept="image/png,image/jpeg,image/webp"
  multiple
/>

Paste

Under-used and high-value. Listen for paste on the drop zone or on document; check the clipboard for files. This is the path users want for screenshots — they hit Cmd-Shift-4 on macOS, the screenshot is on the clipboard, and they expect to paste it directly into your field. Adding paste support is 15 lines of code and a massive improvement for any field that takes images.

Camera (mobile)

<!-- Rear camera, environment-facing -->
<input type="file" accept="image/*" capture="environment" />

<!-- Front camera, selfie mode -->
<input type="file" accept="image/*" capture="user" />
Two attributes for two camera modes

On mobile, capture opens the camera directly instead of the gallery. For documents, receipts, photos at point-of-use, this is the right path. The user doesn't have to switch apps, take the photo, switch back, find the photo in the gallery, pick it. They tap, shoot, done.

03

Upload state — progress, cancel, retry, partial fail

Once the file's been selected, the network takes over. Network is flaky. The field has to handle the flake gracefully.

  • Progress — show real bytes, not a fake spinner. XMLHttpRequest's progress event gives loaded/total. fetch() alone doesn't, but a ReadableStream upload or a library closes the gap.
  • Cancel — has to abort the in-flight request, not just hide the UI. AbortController on fetch, xhr.abort() on XMLHttpRequest. Users who hit cancel expect the upload to stop.
  • Retry — when the network blips, don't make the user re-select the file. Keep the local file reference, offer Retry. They've already done the hard part of finding the right file.
  • Partial fail — when 7 of 10 files succeed and 3 fail, show each one's state. "Upload failed for 3 files" is useless. "image-2.png — upload failed [Retry]" lets the user act.

04

Switching to autocomplete

Autocomplete is a different field type from select, and a different ARIA role — combobox, not listbox alone. The user types freely; the results filter; the user picks one (or, if the pattern allows, leaves their typed text as the value).

The mechanics, in order of importance

  1. 01Debounce the fetch, not the keystroke. Every keystroke updates the input's visible value instantly. The network call to fetch results is debounced — usually 200ms. These are different things and people conflate them.
  2. 02Highlight matched substring in results. The user's eye finds the relevant part of each option faster, and fuzzy matches become legible.
  3. 03Allow clearing. A visible X in the input, and Escape on the keyboard. Both are expected.
  4. 04Selection puts the text in the input and fires change. The input doesn't go blank after selection. The user can keep typing from the selected value if they want to refine.
  5. 05Free-text fallback (if applicable) — if the user types something not in the list, decide explicitly whether to accept it as the value or block submission until they pick a real option. Either is fine; ambiguity is not.

05

Empty states and zero-results

An autocomplete with no thought given to its empty and zero-results states is a field that abandons users at the worst moments. There are four states; each one is design.

Empty (no input)

Show something useful. Recent picks, suggested picks, popular options. "Type to search" alone is a wasted moment; users will use the suggestion list if you give them one. For a search of products, show recently-ordered. For a tag picker, show popular tags. For a person picker, show frequent collaborators.

Loading

Skeleton rows or a small spinner inside the dropdown, never blocking the input. The user has to be able to keep typing while the results catch up. If the spinner blocks input, your debounce is doing the wrong thing.

Zero results

"No products match 'sealnt'. Did you mean 'sealant'?" The next step is part of the design. Going blank is the failure mode that kills trust in the field.

Error

When the server is down or the request fails, surface it. "Search is temporarily unavailable. [Retry]" Don't strand the user with an empty list — they'll assume there are no results when actually the system failed.

06

ARIA combobox — what most implementations get wrong

The W3C ARIA Authoring Practices guide for combobox is one of the most-skipped reads in the industry, and it's where most homegrown autocompletes fail. The pattern has specific roles, specific keyboard behavior, and specific announcement requirements.

<div role="combobox"
     aria-expanded="true"
     aria-haspopup="listbox"
     aria-controls="results-list">
  <input id="search"
         aria-autocomplete="list"
         aria-controls="results-list"
         aria-activedescendant="result-2" />
</div>

<ul id="results-list" role="listbox">
  <li id="result-1" role="option" aria-selected="false">Sealant</li>
  <li id="result-2" role="option" aria-selected="true">Sealing tape</li>
  <li id="result-3" role="option" aria-selected="false">Selector</li>
</ul>
The minimum-viable combobox markup

Where homegrown comboboxes go wrong

  • Focus moves into the listbox when arrow keys are pressed. It shouldn't — focus stays in the input; the listbox's active option is set via aria-activedescendant. Moving real focus into the listbox breaks typing.
  • Options are announced only by their visible text, not as part of a labelled listbox. Screen-reader users hear floating words with no context.
  • Escape doesn't close the listbox, or closes it but doesn't return aria-expanded to false. The combobox stays open according to assistive tech even though it's visually gone.
  • Tab moves between options. It shouldn't; arrow keys do that. Tab leaves the combobox entirely.
  • Listbox doesn't have an id matching the combobox's aria-controls. Assistive tech can't follow the association.

If your autocomplete doesn't follow the ARIA combobox pattern, your accessibility statement is fiction. Worth saying out loud.

File upload and autocomplete are the heaviest field types in this series. They have the most states, the most edge cases, and the highest cost when they fail. They're also the fields that decide whether a long form gets finished — because they tend to be the last interaction before submit.