File upload

● Stable · v0.1

Choose one or more documents to attach, then watch each one upload, succeed, or fail — right where you picked it. A dropzone for the empty state, a clear row per file for everything after.

In context

9:41

Try it

On the web this opens your real file picker and simulates the upload. On a phone it asks the app to open the document picker instead.

Every state

A first-run owner needs to know, at a glance, whether a file is going up, went up, or failed — so each file carries its own status, not a single bar for the whole field.

Purpose

File upload attaches documents — a licence, a statement, a signed form. The dropzone keeps the empty state inviting and tappable; once files are chosen, each becomes a row showing its name, size, and progress so nothing uploads silently.

Anatomy

  • Dropzone — a dashed, full-width tap target with an upload mark and a one-line prompt. It hides once a single-file field is filled, or when the max is reached.
  • File row — a type badge (PDF, JPG…), the file name, and a secondary line that becomes a progress bar while uploading or an error message if it fails.
  • Row actions — a check on success, Retry on failure, and a remove (×) to take a file back off.
  • Helper / error — quiet guidance (accepted types, size limit) that turns into a red message for a field-level validation error.

Behaviour

It's controlled: you own the files array and each file's status, progress (0–1), and error. On web, tapping the dropzone calls onSelect with the chosen files; on native it calls onRequestPick so you can open expo-document-picker. onRemove and onRetry fire per row.

Code

import { FileUpload } from '@daytwo/components'
const [files, setFiles] = useState([])
<FileUpload
label="Attach documents"
helperText="PDF, JPG or PNG · up to 10 MB each"
multiple
accept="image/*,.pdf"
files={files}
// Web: receives the picked files
onSelect={(picked) => addAndUpload(picked)}
// Native: open expo-document-picker here
onRequestPick={pickWithExpo}
onRemove={(id) => setFiles((f) => f.filter((x) => x.id !== id))}
onRetry={(id) => retry(id)}
/>

Each item in files is { id, name, size?, status?, progress?, error? } where status is 'idle' | 'uploading' | 'success' | 'error'.

When to use

  • To attach documents to a form — a licence, a receipt, a statement.
  • When you need to show upload progress and let a failed file be retried.
  • When more than one file may be attached (set multiple, optionally cap with max).

When NOT to use

  • For photos, use Image upload — a thumbnail grid reads better than a filename.
  • For a single quick value the owner could speak, consider Voice input.
  • As an action button — the dropzone picks files; saving the form is a button.

Anti-patterns

Do
A labelled dropzone that states the accepted types and size limit up front, so a file is rejected before it's even chosen.
Don't
An unlabelled field that accepts anything, then shows a bare 'Error' with no reason and no way to recover.
Say why it failed

A failed upload must say what went wrong — too big, wrong type, no connection — in words the owner can act on. "Error" tells them nothing.

Edge cases

Uploading
Show a per-file progress bar and percentage
WhyThe owner sees it's working, not frozen
Failed
Red row with the reason and a Retry
WhyA flaky market connection shouldn't lose the file
Too big / wrong type
Reject with a field-level error naming the limit
WhyFail fast, before a long upload over mobile data
Max reached
The dropzone hides once max files are added
WhyNo dead tap that can't add anything
Long file names
The name truncates to one line; size stays visible
WhyRows stay scannable
Disabled
Dropzone greys and won't open
WhyCommunicates "not yet" without a dead tap
Offline
Surface the failure and keep the file for retry
WhyCapture shouldn't be lost to a dropped signal

Tokens used

color.surface.primarycolor.background.secondarycolor.brand.primarycolor.brand.tintcolor.text.primarycolor.text.tertiarycolor.border.defaultcolor.border.subtlecolor.feedback.errorFgcolor.feedback.errorBgcolor.feedback.errorBordercolor.feedback.successSolidradius.mdradius.lgtouchTargets.mintypography.subhead