Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground
  • 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.

  • deployment-log-4021.txt284 KB
  • architecture.png1.2 MB
  • trace.har48 MB Larger than the 10 MB limit

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.

Stated

0 of 0 uploaded

Hidden

0 of 0 uploaded

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.

Drop zoneUploading is the task

0 of 0 uploaded

ButtonUploading is incidental
PNG or PDF, 10 MB

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.

  • trace.har48 MB Larger than the 10 MB limit
  • notes.docx92 KB Only PNG, JPG and PDF are accepted

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

Idle
Drop to upload
Drag over
  • architecture.png1.2 MB
  • Uploading
  • deployment-log-4021.txt284 KB
  • Done
  • trace.har48 MB Larger than the 10 MB limit
  • Failed
    Button
    Progress
    1 of 3 uploaded
    Count

    Anatomy

    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.

    1. 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.

    2. 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.

    3. 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.

    4. 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.

    5. File row48px, icon + name + size

      The name truncates from the end but keeps the extension visible — the extension is what users check.

    6. Per-file progress2px bar inside the row

      Per file, never aggregate. One bar for five files hides which one is stuck.

    7. Row actionsRetry on error, remove always

      Retry re-sends one file. A single global retry re-uploads the four that already succeeded.

    Design tokens used

    Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

    Color

    TokenValueUsed 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

    TokenValueUsed for
    --space-3Row padding and gap to the list

    Radius

    TokenValueUsed for
    --radius-lgZone corners
    --radius-mdFile row corners

    Motion

    TokenValueUsed for
    --duration-fastDrag-over transition

    Recommended sizes

    Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

    SizeHeightPaddingLabel gapMin widthWhen to use
    Compact zone64px16px——A field among others, where uploading is one step of many.
    Default zone96px24px——The default. Uploading is a main action on the screen.
    Full zone160px32px——An import screen where the upload is the entire task.
    File row48px8px 12px12px—64px when a thumbnail preview is shown.
    Progress bar2px———Inside the row, under the filename. Per file, never aggregate.
    Thumbnail32px——32pxImages only, generated client-side from an object URL — revoke it on unmount.

    Do

    if (file.size > maxSize) reject(file, 'Larger than 10 MB')
    Validate before a byte is sentSize and type are known at selection. Uploading 48 MB to be told the limit is 10 MB spends a minute of the user’s time and their data on an avoidable error.
    <label><input type="file" class="sr-only" />…</label>
    Make the zone a label around a real file inputClick, keyboard and the platform picker all work with no JavaScript. Drag-and-drop is then an enhancement rather than the only way in.
  • trace.har48 MB Larger than the 10 MB limit
  • Give every file its own progress and retryOne aggregate bar hides which file is stuck, and a global retry re-uploads the four that already succeeded.
  • notes.docx92 KB Only PNG, JPG and PDF are accepted
  • Keep rejected files in the listA file that silently disappears is a file the user believes uploaded. The row with its reason is the only way they find out otherwise.

    Don't

    <div onClick={pick}>
    Do not build the zone from a divA div with a click handler is unreachable by keyboard and announces as nothing. The file input is the control; everything else is decoration around it.
    Drop files here
    Do not hide the constraints until failureThe user picks a file, waits, and is told it was never going to work. Both facts were available before they opened the picker.
    Uploading 5 files
    Do not use one aggregate progress barFive files behind one bar means a single stalled upload looks like the whole batch is slow, and there is nothing to retry individually.
    accept="image/png" → drag a .exe → accepted
    Do not rely on the accept attribute for validationIt filters the picker and nothing else. Drag-and-drop bypasses it entirely, and so does anyone who switches the picker to “All files”.

    Accessibility

    Not a checklist to run at the end. These are the requirements the component was built from.

    1.3.1Info and RelationshipsA2.1.1KeyboardA2.5.7Dragging MovementsAA3.3.1Error IdentificationA4.1.3Status MessagesAA

    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

    TabReaches the file input through the label, then each file row’s controls.
    Enter / SpaceOpens the platform file picker. This is why the input must be real.
    TabWithin a row, reaches Retry then Remove. Both need names that include the filename.
    EscCancels 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.
    AttributeApplied toNotes
    <label>The drop zoneWrapping a real input[type=file]. This is the whole accessibility story — a div cannot be made equivalent.
    aria-describedbyThe file inputPoints 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 rejectionAssertive, because the user needs to know immediately that a file they chose is not going.
    aria-labelRow controls"Remove architecture.png", not "Remove". A list of five files otherwise has five identical buttons.
    aria-busyA row that is uploadingSo the state is exposed rather than only animated.

    Code

    Example usage

    tsx
    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

    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

    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

    PropTypeDefaultDescription
    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.
    acceptstring—Filters the picker only. Always validate the type again after selection — drag-and-drop bypasses it.
    maxSizenumber—Bytes. Checked before upload, and stated in the zone.
    multiplebooleanfalseSingle-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.

    Notes

    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.