Upload file
Drag & drop or browse to upload
File must be PNG, JPG, or PDF smaller than 2 MB.
Drop or click to browse
Overview
The component lives in @flatpay-dk/ui as <FileUpload>. It renders the dropzone, wires drag-and-drop, opens the native picker on click, and renders a tidy file list beneath. The actual upload pipeline (XHR, progress, retries, server validation) stays in product code — the component is fully controlled.
The component doesn't upload
The caller passes the files array and reacts to onFilesAdded / onFileRemove. That keeps every product’s upload concerns (auth, signed URLs, chunking, retries) in product code where they belong, instead of baked into the design system.
Anatomy
A dashed-border zone (8 px radius, 1 px dashed border, 28 px padding) sits above an optional list of file rows. The zone itself is one big tap target — click anywhere opens the picker; drop anywhere accepts files.
- Zone. 1 px dashed border on a faint background tint. Switches to a solid border + heavier background tint while a file is dragged over.
- Icon. 24 px upload glyph in a 52 px tinted circle. Inherits foreground colour at rest; turns rose on error.
- Primary text.“Upload file” for single, “Upload files” for multi. Inter Tight Semibold 16 px.
- Helper.“Drag & drop or browse to upload” — the word browsereads as a link but isn’t one. The whole zone is the click target.
- Limitations. File-type and size constraints in plain English. Sit inside the zone for multi (more breathing room), beneath the zone for single.
- File row. 32 px thumbnail (image preview when available, file glyph otherwise), filename + size or progress bar or error message, trailing remove button. Rows divide with a 1 px border, no border on the last row.
Single vs. multiple
Pass multipleto accept several files at once. The zone widens, limitations move inside, and the file list grows row by row. Use single when the user is attaching one specific document (a logo, a receipt, an identity scan); use multiple when they’re uploading a batch (proof of business, marketing collateral, monthly statements).
Upload file
Drag & drop or browse to upload
File must be PNG, JPG, or PDF smaller than 2 MB.
Single · default
One file at a time. Picking a second replaces the first.
Upload files
Drag & drop or browse to upload
Files must be PNG, JPG, or PDF smaller than 2 MB.
Multi · with file list
Files queue beneath the zone. The zone stays interactive while uploads run.
States
The zone has four visible states (rest, hover, drag-over, focus) plus disabled. File rows have three (uploading, complete, error). Errors live as close to the cause as possible — per-file errors on the row, validation errors on the zone.
Upload files
Drag & drop or browse to upload
Files must be CSV, JPG, or PDF smaller than 2 MB.
settlement-2026-04.csv
64%merchant-storefront.jpg
412 KB
Multi · Uploading + complete
A settlement export still climbing while a storefront photo finishes. The progress bar is inline; the size line returns once the upload completes.
Upload files
Drag & drop or browse to upload
Files must be PNG, JPG, or PDF smaller than 2 MB.
loan-decision-letter.pdf
1.4 MB
warehouse-photo-original.jpg
Larger than 2 MB. Resize before re-uploading.
Multi · Per-file error
One file fails validation; the rest are unaffected. The error message lives on the failed row, not the zone.
Upload file
Drag & drop or browse to upload
File must be PNG or JPG smaller than 2 MB.
Disabled
The dropzone refuses click, drag, and keyboard. Helper text reads in a quieter ramp.
Behavior
- Browse. Click anywhere on the zone, or focus it and press Space / Enter, to open the native picker.
- Drop. Drag files over the page; the zone switches to drag-over styling. Drop fires
onFilesAddedwith the dropped File objects. - Validate. Pass
acceptfor MIME / extension limits andmaxSizein bytes. Files exceeding the size are filtered out before the callback fires; a transient error message appears beneath the zone naming the offender. - Progress.While a file is uploading, the row renders a 1 px progress bar and a tabular percentage. Set the file’s
progressto a number 0-100; flipstatusto"complete"when done. - Error. A failed upload swaps the size line for an error message in rose. The zone stays usable so the user can retry by dropping again.
- Remove. Each row carries a trash button — 36 px tap target, focus-ringed, labelled Remove {filename}. Fires
onFileRemovewith the file’s id.
Accessibility
- Keyboard. The zone is
role="button", keyboard-focusable, and activates the picker on Space / Enter. Drag and drop is mouse / touch only by nature; the picker is the accessible path. - Screen readers. Pass
aria-labelwhen the surrounding context doesn’t already name the field. The progress bar exposesrole="progressbar"with aria-valuenow / aria-valuemin / aria-valuemax. Errors live in arole="alert"line so they’re announced as they appear. - Disabled.Disabled zones aren’t in the tab order, refuse drag and drop, and ignore clicks. Remove buttons go to
aria-disabledwhen the parent is disabled.
Code
A controlled flow. Caller manages the files array and decides what uploading means — these examples drive a simulated XHR; production code swaps in the real one.
tsx
import { FileUpload, type UploadFile } from "@flatpay-dk/ui";
function MerchantDocs() {
const [files, setFiles] = useState<UploadFile[]>([]);
async function handleAdded(added: File[]) {
// Optimistically render new files as "uploading".
const queued = added.map<UploadFile>((file) => ({
id: crypto.randomUUID(),
name: file.name,
size: file.size,
mimeType: file.type,
status: "uploading",
progress: 0,
previewUrl: file.type.startsWith("image/")
? URL.createObjectURL(file)
: undefined,
}));
setFiles((prev) => [...prev, ...queued]);
// Run the real upload; report progress back into the array.
for (const item of queued) {
try {
await uploadWithProgress(item, (pct) => {
setFiles((prev) =>
prev.map((f) =>
f.id === item.id ? { ...f, progress: pct } : f,
),
);
});
setFiles((prev) =>
prev.map((f) =>
f.id === item.id
? { ...f, status: "complete", progress: 100 }
: f,
),
);
} catch (err) {
setFiles((prev) =>
prev.map((f) =>
f.id === item.id
? {
...f,
status: "error",
errorMessage: "Upload failed. Please try again.",
}
: f,
),
);
}
}
}
return (
<FileUpload
multiple
accept="image/png,image/jpeg,application/pdf"
maxSize={2 * 1024 * 1024}
limitations="Files must be PNG, JPG, or PDF smaller than 2 MB."
files={files}
onFilesAdded={handleAdded}
onFileRemove={(id) =>
setFiles((prev) => prev.filter((f) => f.id !== id))
}
/>
);
}Best practices
- Always state the limits. Pass
limitationswith the actual file types and size cap. Discovering the rule by failing the upload is the worst version of the experience. - Give a real maxSize.A server limit you forget to enforce on the client wastes the user’s bandwidth. Match the server’s real ceiling and let the component filter eagerly.
- Stay in the page. File upload is the one moment a modal feels right because the zone needs space, but this component is designed to live inline. Reach for a modal only when the upload itself is the entire task.
- Don’t auto-submit.The uploaded file is attached to a record; don’t mutate the record until the user explicitly saves. The component never sets state on the caller’s behalf.
Props
<FileUpload>
| Prop | Type | Default | Description |
|---|---|---|---|
| files* | UploadFile[] | — | The current list of files known to the caller. Drives the file list and per-file states. |
| onFilesAdded | (files: File[]) => void | — | Fires when the user drops files or picks them through the dialog. Caller validates and queues uploads. |
| onFileRemove | (id: string) => void | — | Fires when the user clicks the remove button on a file row. |
| multiple | boolean | false | Allow multiple file selection. Switches the layout to the wider multi mode. |
| accept | string | — | Comma-separated MIME / extension list passed straight to the underlying input. |
| maxSize | number | — | Bytes per file. Files exceeding this are filtered before onFilesAdded fires; a transient error names the offender. |
| disabled | boolean | false | Refuses click, drag, keyboard activation, and remove buttons. |
| limitations | ReactNode | — | Helper text below (single) or inside (multi) the zone — file types and size limits in plain English. |
| error | ReactNode | — | Top-level error message rendered beneath the zone with role=alert. Use for validation issues that aren't tied to a specific file. |
| emptyTitle | string | — | Override the empty-state title. Defaults to "Upload file" / "Upload files". |
| emptyHelper | ReactNode | — | Override the empty-state secondary line. Defaults to "Drag & drop or browse to upload". |
| aria-label | string | — | Accessible name for the dropzone. Falls back to the title. |
UploadFile
| Prop | Type | Default | Description |
|---|---|---|---|
| id* | string | — | Caller-controlled stable id. Used as React key + remove handle. |
| name* | string | — | Display name. Usually file.name. |
| size* | number | — | Bytes. Formatted by the component (KB / MB). |
| status* | "uploading" | "complete" | "error" | — | Drives whether the row shows a progress bar, the size, or an error message. |
| progress | number | — | 0-100 when status is uploading. Drives the inline progress bar and percentage. |
| errorMessage | string | — | Shown under the filename when status is error. |
| previewUrl | string | — | Optional thumbnail URL. For images, set this to an object URL so the row shows the image preview instead of a generic glyph. |
| mimeType | string | — | Drives the leading icon for non-image files (file glyph for documents, image glyph when there's no previewUrl yet). |