Components · Forms

File upload

A dashed dropzone with click-to-browse, plus a file list that reports per-file progress and errors. Use it whenever the user attaches a document, image, or export to a record.

Documentedby Derek Fidler

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 onFilesAdded with the dropped File objects.
  • Validate. Pass accept for MIME / extension limits and maxSize in 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 progress to a number 0-100; flip status to "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 onFileRemove with 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 exposes role="progressbar" with aria-valuenow / aria-valuemin / aria-valuemax. Errors live in a role="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-disabled when 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 limitations with 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>

PropTypeDefaultDescription
files*UploadFile[]The current list of files known to the caller. Drives the file list and per-file states.
onFilesAdded(files: File[]) => voidFires when the user drops files or picks them through the dialog. Caller validates and queues uploads.
onFileRemove(id: string) => voidFires when the user clicks the remove button on a file row.
multiplebooleanfalseAllow multiple file selection. Switches the layout to the wider multi mode.
acceptstringComma-separated MIME / extension list passed straight to the underlying input.
maxSizenumberBytes per file. Files exceeding this are filtered before onFilesAdded fires; a transient error names the offender.
disabledbooleanfalseRefuses click, drag, keyboard activation, and remove buttons.
limitationsReactNodeHelper text below (single) or inside (multi) the zone — file types and size limits in plain English.
errorReactNodeTop-level error message rendered beneath the zone with role=alert. Use for validation issues that aren't tied to a specific file.
emptyTitlestringOverride the empty-state title. Defaults to "Upload file" / "Upload files".
emptyHelperReactNodeOverride the empty-state secondary line. Defaults to "Drag & drop or browse to upload".
aria-labelstringAccessible name for the dropzone. Falls back to the title.

UploadFile

PropTypeDefaultDescription
id*stringCaller-controlled stable id. Used as React key + remove handle.
name*stringDisplay name. Usually file.name.
size*numberBytes. Formatted by the component (KB / MB).
status*"uploading" | "complete" | "error"Drives whether the row shows a progress bar, the size, or an error message.
progressnumber0-100 when status is uploading. Drives the inline progress bar and percentage.
errorMessagestringShown under the filename when status is error.
previewUrlstringOptional thumbnail URL. For images, set this to an object URL so the row shows the image preview instead of a generic glyph.
mimeTypestringDrives the leading icon for non-image files (file glyph for documents, image glyph when there's no previewUrl yet).