File Upload
Drop zone, file list, per-file progress and retry — plus the validation that has to happen before a byte is sent.
Also called Dropzone, File Input, Attachment, Drag and Drop — in this system all of them are File Upload.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
- deployment-log-4021.txt284 KB
- architecture.png1.2 MB
- trace.har48 MB Larger than the 10 MB limit
1 of 3 uploaded
Attached to the deployment record.
The three file states
Uploading with progress, done with a check, failed with a reason and a retry. Each row owns its own state — an aggregate bar hides which file went wrong.
State the constraints up front
Accepted types and the size limit belong in the zone, before selection. Discovering them through a rejection is the most common way this component wastes people’s time.
Drop zone or button
A drop zone earns its space when uploading is the main task. Everywhere else — a comment box, a settings row — a plain button plus a file list is less visual weight for the same function.
Rejected files stay visible
A file that fails validation must appear in the list with its reason. Silently dropping it means the user thinks it uploaded.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
0 of 0 uploaded
Every part, every measurement, and the reason it is that number.
- deployment-log-4021.txt284 KB
- architecture.png1.2 MB
- trace.har48 MB Larger than the 10 MB limit
1 of 3 uploaded
A dashed zone that is really a label around a file input, followed by one row per file carrying its own state and its own controls.
- Zone height96px (64px compact)
Big enough to be an obvious drop target, small enough that it does not dominate a form where uploading is one field among several.
- Border2px dashed
Dashed is the near-universal signal for a drop target. A solid border reads as a container, and users stop trying to drag onto it.
- Constraints line12px, muted, always visible
Accepted types and the size limit. It is the difference between a rejection the user could have avoided and one they could not.
- Drag-over stateAccent border + tinted fill
Two changes at once. A border colour alone is easy to miss with a file hovering over the cursor.
- File row48px, icon + name + size
The name truncates from the end but keeps the extension visible — the extension is what users check.
- Per-file progress2px bar inside the row
Per file, never aggregate. One bar for five files hides which one is stuck.
- Row actionsRetry on error, remove always
Retry re-sends one file. A single global retry re-uploads the four that already succeeded.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-surface-inset | — | Idle zone fill |
| --ds-border | — | Idle dashed border |
| --ds-accent | — | Drag-over border and the “Choose files” text |
| --ds-accent-subtle | — | Drag-over fill |
| --ds-danger-border | — | Failed file row |
| --ds-danger-text | — | Rejection reason |
| --ds-success-text | — | Completed check |
| --ds-fg-muted | — | File size, constraints, file-type icon |
Spacing
| Token | Value | Used for |
|---|---|---|
| --space-3 | Row padding and gap to the list |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-lg | Zone corners | |
| --radius-md | File row corners |
Motion
| Token | Value | Used for |
|---|---|---|
| --duration-fast | Drag-over transition |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Label gap | Min width | When to use |
|---|---|---|---|---|---|
| Compact zone | 64px | 16px | — | — | A field among others, where uploading is one step of many. |
| Default zone | 96px | 24px | — | — | The default. Uploading is a main action on the screen. |
| Full zone | 160px | 32px | — | — | An import screen where the upload is the entire task. |
| File row | 48px | 8px 12px | 12px | — | 64px when a thumbnail preview is shown. |
| Progress bar | 2px | — | — | — | Inside the row, under the filename. Per file, never aggregate. |
| Thumbnail | 32px | — | — | 32px | Images only, generated client-side from an object URL — revoke it on unmount. |
if (file.size > maxSize) reject(file, 'Larger than 10 MB')<label><input type="file" class="sr-only" />…</label>accept="image/png" → drag a .exe → acceptedNot a checklist to run at the end. These are the requirements the component was built from.
Contrast
- The dashed border owes 3:1 — it is the only thing defining the drop target.
- The drag-over state changes both border and fill, so it is perceivable without relying on a colour shift alone.
- Rejection reasons owe 4.5:1 and must name the cause, not just colour the row red.
- Progress bars owe 3:1 against the row background; at 2px tall a low-contrast fill is invisible.
Keyboard
| Tab | Reaches the file input through the label, then each file row’s controls. |
| Enter / Space | Opens the platform file picker. This is why the input must be real. |
| Tab | Within a row, reaches Retry then Remove. Both need names that include the filename. |
| Esc | Cancels an in-flight upload when the row has a cancel control. |
Screen readers
- Announce each rejection with its reason: "trace.har rejected, larger than the 10 MB limit".
- Announce completion per file rather than a percentage stream: "architecture.png uploaded".
- Do not announce progress continuously. A percentage read every frame is unusable; announce at start, at completion, and on failure.
Focus & touch
- Removing a file moves focus to the next row, or back to the drop zone if it was the last. After a rejection, focus stays where it was and the alert announces — yanking focus to an error row while the user is still selecting files is disorienting.
- Drag-and-drop does not exist on touch, so the picker path must be complete on its own — WCAG 2.5.7 requires exactly this. Tapping the zone should open the platform sheet, which on mobile offers camera and photo library alongside files. Row controls need 44px targets, which usually means a 56px row rather than 48px.
| Attribute | Applied to | Notes |
|---|---|---|
| <label> | The drop zone | Wrapping a real input[type=file]. This is the whole accessibility story — a div cannot be made equivalent. |
| aria-describedby | The file input | Points at the constraints line, so accepted types and the size limit are read before the picker opens. |
| aria-live="polite" | The upload count | "2 of 3 uploaded". Progress bars alone announce nothing useful. |
| role="alert" | A rejection | Assertive, because the user needs to know immediately that a file they chose is not going. |
| aria-label | Row controls | "Remove architecture.png", not "Remove". A list of five files otherwise has five identical buttons. |
| aria-busy | A row that is uploading | So the state is exposed rather than only animated. |
Example usage
1import { FileUpload } from '@/ui/Input'23<Field label="Attachments" description="Attached to the deployment record.">4 <FileUpload5 multiple6 accept="image/png,image/jpeg,application/pdf"7 maxSize={10 * 1024 * 1024}8 files={files}9 onFilesChange={setFiles}10 />11</Field>1213// Validate at selection. Both facts are known before a byte moves.14function validate(file: File) {15 if (file.size > MAX) return `Larger than the ${format(MAX)} limit`16 // accept= only filters the picker: drag-and-drop bypasses it entirely.17 if (!ACCEPTED.includes(file.type)) return 'Only PNG, JPG and PDF are accepted'18 return null19}2021// Per-file upload, so one failure does not restart the others.22async function upload(item: Upload) {23 const controller = new AbortController()24 setState(item.id, { state: 'uploading', controller })25 try {26 await put(item.file, {27 signal: controller.signal,28 onProgress: (p) => setState(item.id, { progress: p }),29 })30 setState(item.id, { state: 'done', progress: 100 })31 } catch (e) {32 if (e.name === 'AbortError') return33 setState(item.id, { state: 'error', error: 'Upload failed' })34 }35}3637// Object URLs leak until revoked. One per preview, released on unmount.38React.useEffect(() => {39 const url = URL.createObjectURL(file)40 setPreview(url)41 return () => URL.revokeObjectURL(url)42}, [file])Framework-free HTML
<!-- A label around a real input. Click, keyboard and the platform picker
all work with zero JavaScript; drag-and-drop is the enhancement. -->
<label class="ds-dropzone">
<input
type="file"
multiple
accept="image/png,image/jpeg,application/pdf"
class="sr-only"
aria-describedby="upload-constraints"
/>
<svg aria-hidden="true">…</svg>
<span><strong>Choose files</strong> or drag them here</span>
<span id="upload-constraints">PNG, JPG or PDF, up to 10 MB each</span>
</label>
<ul class="ds-filelist">
<li aria-busy="true">
<svg aria-hidden="true">…</svg>
<span>architecture.png</span>
<span>1.2 MB</span>
<progress value="62" max="100">62%</progress>
<button type="button" aria-label="Cancel architecture.png">…</button>
</li>
</ul>
<p role="status" aria-live="polite">2 of 3 uploaded</p>
<p role="alert">trace.har rejected: larger than the 10 MB limit</p>CSS
.ds-dropzone {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 24px;
/* Dashed is the near-universal drop-target signal. A solid border reads
as a container and people stop trying to drag onto it. */
border: 2px dashed var(--ds-border);
border-radius: var(--radius-lg);
background: var(--ds-surface-inset);
cursor: pointer;
text-align: center;
}
/* The input is visually hidden, never display:none — that would remove it
from the tab order and from the accessibility tree. */
.ds-dropzone input[type='file'] {
position: absolute;
inline-size: 1px;
block-size: 1px;
clip-path: inset(50%);
overflow: hidden;
}
/* The ring belongs to the zone, since the input itself is invisible. */
.ds-dropzone:focus-within {
border-color: var(--ds-accent);
background: color-mix(in oklab, var(--ds-accent-subtle) 40%, transparent);
}
/* Two changes at once: a border shift alone is easy to miss with a file
hovering under the cursor. */
.ds-dropzone[data-over='true'] {
border-color: var(--ds-accent);
background: var(--ds-accent-subtle);
}
.ds-filelist li {
display: flex;
align-items: center;
gap: 12px;
min-block-size: 48px;
padding-inline: 12px;
border: 1px solid var(--ds-border-subtle);
border-radius: var(--radius-md);
}
.ds-filelist li[data-state='error'] { border-color: var(--ds-danger-border); }
/* Truncate from the middle: the extension is what users check. */
.ds-filelist__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (pointer: coarse) {
.ds-filelist li { min-block-size: 56px; } /* 44px controls fit */
}Component API
FileUpload
| Prop | Type | Default | Description |
|---|---|---|---|
| files* | UploadItem[] | — | Controlled list, including rejected files. A rejected file that is not in the list is one the user thinks uploaded. |
| onFilesChange* | (files: UploadItem[]) => void | — | Fires on selection, drop, removal and every state transition. |
| accept | string | — | Filters the picker only. Always validate the type again after selection — drag-and-drop bypasses it. |
| maxSize | number | — | Bytes. Checked before upload, and stated in the zone. |
| multiple | boolean | false | Single-file uploads should replace rather than append, and say so. |
| variant | 'dropzone' | 'button' | 'dropzone' | Button where uploading is incidental to the surface. |
| onRetry | (id: string) => void | — | Per file. A global retry re-uploads the ones that already succeeded. |
Professional tips
- Show an image thumbnail from an object URL as soon as a file is selected. It confirms the right file was chosen before the upload finishes — and revoke the URL on unmount.
- Truncate long filenames from the middle so the extension stays visible. That is the part users check.
- Upload immediately on selection rather than waiting for form submit. By the time the user finishes the rest of the form, the file is already there.
- For single-file fields, say the new file replaces the old one. Users expect append and are surprised by silent replacement.
- Accept a paste of an image from the clipboard where it makes sense. Screenshots are the most common attachment in any support or bug flow.
Performance
- Upload in parallel with a cap of three or four. More than that and each file gets a slice of the same bandwidth, so everything finishes slower.
- Use resumable or chunked uploads past about 50 MB. A single failed request at 90% is otherwise a complete restart.
- Downscale images client-side with a canvas before uploading where quality permits. A 12 MP phone photo for a 200px avatar is entirely wasted transfer.
- Revoke every object URL. A gallery of previews that never revokes leaks the full file into memory for the life of the page.
Common mistakes
- A div-based drop zone, unreachable by keyboard and invisible to assistive tech.
- Validating after the upload, wasting the user’s time and data on an avoidable rejection.
- Relying on accept= as validation, which drag-and-drop bypasses entirely.
- One aggregate progress bar for a batch, hiding which file is stuck.
- A global retry that re-uploads files that already succeeded.
- Rejected files silently dropped, so the user believes they uploaded.
- Row controls named "Remove" with no filename, giving five identical buttons.
- Leaked object URLs holding every preview in memory.
Real-world recommendations
- Drag-and-drop is used far less than its visual prominence suggests. The click path is the majority path on desktop and the only path on touch — polish that first.
- Users choose the wrong file constantly. A thumbnail or a clear filename in the list catches it before submit, which is much cheaper than catching it afterwards.
- Mobile uploads mostly come straight from the camera. Make sure the accept attribute does not prevent the camera option appearing in the platform sheet.
- Size limits should be generous and clearly stated. A 2 MB limit in a world of 8 MP phone photos generates support tickets rather than smaller files.