Composites

Upload

Two shapes over one machine. ImageUploadField is a single row for the picture that already has a place on screen — an avatar, a workspace logo. FileUpload is the tall drop zone for documents. Both run Uppy headless and post ordinary multipart/form-data to a route you own, so no server code has to change to adopt them.

Every preview on this page uploads for real, to /api/demo-upload. That route measures the bytes and throws them away — it keeps nothing — and it sleeps 700 ms on purpose so the uploading state is visible at all. On localhost a real upload finishes before you can see it.

Two peers, and they are optional

@comitor/ui/uploader is its own entry point with two optional peer dependencies. An app that never imports the entry pays nothing — not a byte of Uppy reaches its bundle. Install them only when you use it:

terminal
pnpm add @uppy/core @uppy/xhr-upload

Forget them and nothing crashes — but nothing warns you up front either. The components render normally and the Choose button stays enabled, because both packages are only import()ed at the moment a file is actually picked. The failure then arrives as a single onError call carrying labels.missingDependency, so pass an onError handler — without one the click is silent.

ImageUploadField — one row

Preview · Upload · Remove · one hint line. Built for the case where the image is already on screen at 56 px and the form around it has other things to say.

Nguyễn Văn An

Drag an image here or click Upload picture. PNG, JPEG, WebP or GIF, up to 5 MB.

Nothing uploaded yet.

The whole row is the drop target, not just the button — someone dragging a file from the desktop aims at the picture. The dashed border is drawn at rest in border-transparent and only changes colour while a file is over it, so the row costs no extra whitespace and never shifts: measured at 85 px tall at rest, while dragging, and just after the file leaves.

The hint line is the feature discovery

Drag-and-drop is invisible. Nothing on screen announces it, and no one hovers a settings row hoping something will happen. labels.hint is the only place a user learns the capability exists, which is why the default says so:

Good — “Drag an image here or click Upload picture. JPG, PNG or WebP, up to 2 MB.”

Trims the feature away — “JPG, PNG or WebP. Up to 2 MB.”

The second one reads tighter and is the version that keeps getting written. It turns drag-and-drop off for everybody except the person who tries it by accident. dropPrompt is the separate string shown on the overlay while a file is actually over the row.

editable, not disabled

For a viewer without permission to change the image, editable={false} renders the preview alone. Not greyed-out buttons — no buttons, and no sr-only file input left in the DOM for a screen reader to find and announce.

editable (default)

Công ty Acme

PNG, JPEG or WebP.

editable={false}

Công ty Acme

An if written in the app hides only what is visible. That is the whole distinction: disabled describes a control you cannot use right now; the absence of permission means the control is not part of your interface at all.

FileUpload — the tall drop zone

The right shape for documents, where there is no existing thumbnail and a batch is normal. Same engine, same props, different layout — and it keeps maxNumberOfFiles, which the single-image field has no use for.

Drag files here, or

Do not reach for this one for an avatar. A tall dashed rectangle above a 56 px picture pushes the rest of the form down the page and makes changing a photo look like a heavier job than it is.

useFileUpload — when you need a third shape

Both components are thin layouts over this hook. It returns prop-getters instead of JSX, so a cover-image dropper, a paste-to-upload editor or a table cell can reuse the machine without inheriting a layout.

No cover yet

Reach for it before writing Uppy directly. Four defaults in @uppy/xhr-upload are wrong for this stack and the hook already handles them: withCredentials defaults to false so a cookie-authenticated route answers 401; shouldRetry retries every 4xx three times, so one 429 digs its own rate limit deeper; allowedMetaFields can put a string where your route expects the file; and getResponseError does not exist, so the error body has to be captured in onAfterResponse. Writing it by hand means rebuilding all four bugs.

The route on the other end

Nothing about the endpoint is Uppy-shaped — it is a plain multipart POST with one part named file, which is why adopting these components changes no server code. What the route must do is all of the enforcement:

app/api/account/avatar/route.ts
// The route the uploader posts to. Nothing about it is Uppy-specific:
// it is a plain multipart POST with one part named "file".
export async function POST(request: Request) {
  const form = await request.formData()
  const file = form.get('file')

  // instanceof File, not a truthiness check — a multipart part can be a string.
  if (!(file instanceof File)) return fail(400, 'NO_FILE')
  if (file.size > MAX_BYTES) return fail(413, 'FILE_TOO_LARGE')

  const bytes = new Uint8Array(await file.arrayBuffer())

  // The browser-declared type is a CLAIM. Read the magic number.
  const kind = sniffImage(bytes)
  if (!kind) return fail(415, 'UNSUPPORTED_TYPE')

  // The SERVER names the key. A client-chosen key is a path-traversal bug
  // and a way to overwrite someone else's object.
  const key = `account/avatar/${userId}/${randomUUID()}.${kind.ext}`
  await putPrivateObject(key, bytes, kind.mime)

  // Store the KEY. A signed URL written to a column is a value that expires
  // inside your database.
  await db.user.update({ where: { id: userId }, data: { image: key } })

  return Response.json({ image: await toDisplayUrl(key) })
}
  • The browser checks are a courtesy, not a control. maxFileSize and allowedFileTypes save a pointless upload; they stop nobody holding curl. Re-check both server-side, and check the type by reading the leading bytes rather than trusting the declared MIME.
  • The server names the key. A client-supplied key is a path-traversal bug and a way to overwrite another tenant's object.
  • Store the key, not a URL. With a private bucket the display URL is signed and expires; writing it to a column stores a value that goes stale inside your own database. Re-sign on read.
  • These go through your server on purpose. A presigned PUT straight to storage is faster and takes away the one moment when anything can inspect the bytes — real MIME, real size, and later, content scanning. Choose it when you have decided the server does not need to see the file, which is a different question from whether the bucket is public.

In context

The compact row inside the settings card it was designed for.

Profile picture

This picture appears next to your name in every Comitor app.

NA

Drag an image here or click Upload picture. Up to 5 MB.

ImageUploadField props

PropTypeDefaultDescription
endpointrequiredstringWhere the multipart POST goes. Your own route — never a storage provider directly.
maxFileSizerequirednumberBytes. Enforced in the browser before a single byte is sent, so an oversized file costs nothing. Your server still has to enforce it too.
allowedFileTypesrequiredstring[]MIME types, e.g. ["image/png", "image/jpeg"]. Also becomes the file picker’s accept filter.
previewrequiredReactNodeWhat you see today — usually a LetterAvatar. The field draws no image of its own, so the same avatar component appears here and everywhere else in the product.
onUploadedrequired(result: { body: T; fileName: string }) => voidFires once per file with the parsed JSON body your route returned.
hasImagebooleanfalseWhether there is something to remove. Drives the disabled state of the Remove button, which is always rendered so the row never reflows.
editablebooleantruefalse renders the preview alone — no buttons, no drop target, and no sr-only <input> left behind in the DOM. See the note below on why this is not `disabled`.
busybooleanfalseYour own pending state, e.g. while a removal request is in flight. Combined with the upload’s internal busy state.
onRemove() => voidCalled by the Remove button. Omit it and the button stays disabled.
onError({ code?, status?, message }) => voidHTTP failures carry code and status read out of your error body. Client-side rejections (wrong type, too large) carry only message — they never reached the network.
fieldNamestring'file'The multipart part name, if your route expects something else.
labelsPartial<ImageUploadFieldLabels>DEFAULT_IMAGE_UPLOAD_FIELD_LABELSchoose · uploading · remove · hint · dropPrompt · uploadFailed · missingDependency. Package defaults are Vietnamese; the constant is exported so an app can read a key rather than retype it.
classNamestringMerged onto the outer wrapper.

FileUpload props

Identical except for the two ends: it gains maxNumberOfFiles and loses the preview and removal props, which only mean something when there is one image.

PropTypeDefaultDescription
endpointrequiredstringSame contract as ImageUploadField.
maxFileSizerequirednumberBytes, per file.
allowedFileTypesrequiredstring[]MIME types.
onUploadedrequired(result: { body: T; fileName: string }) => voidFires once per file, not once per batch.
maxNumberOfFilesnumber1Above 1 the picker becomes multiple and the drop zone accepts a batch.
disabledbooleanfalseGreys the zone out. Unlike ImageUploadField there is no editable prop here — a document uploader with nothing to show has nothing to render in a read-only state.
onError({ code?, status?, message }) => voidAs above.
fieldNamestring'file'The multipart part name.
labelsPartial<FileUploadLabels>DEFAULT_FILE_UPLOAD_LABELSdropHint · choose · uploading · uploadFailed · missingDependency. Vietnamese by default, and the constant is exported alongside the component.
classNamestringMerged onto the wrapper.

useFileUpload returns

Options are the same as FileUpload's props minus the styling ones. What comes back:

PropTypeDefaultDescription
open()() => voidOpens the file picker. Wire it to your own button.
start(files)(files: FileList | File[]) => voidUpload files you already have — from a paste handler, say.
cancel()() => voidAborts the run in flight.
inputPropsobjectSpread onto an <input type="file">. Carries ref, accept, multiple, disabled and onChange — and deliberately no className, because the hook does not get to decide your layout.
dropPropsobjectSpread onto whatever should accept a drop. preventDefault is already handled.
draggingbooleanA file is currently over the drop target.
busybooleanAn upload is in flight.
percentnumber0–100, by bytes rather than by file count.

Accessibility

  • The keyboard path is a real button and a real file input. The input is sr-only, not display: none — hidden that way it would be unreachable and unfocusable. Drag-and-drop is layered on top and is never the only route.
  • Drag state is drawn with the border, not a tint. An earlier version used bg-primary/5 and the package's contrast gate rejected it at 1.03:1 — a signal the eye cannot resolve is not a signal. The border also uses the -ink tier: line and text roles need --primary-ink, while --primary is the fill tier and lands at 1.40:1 against 3:1 required.
  • Progress is announced. The bar carries the uploading label as its accessible name and only appears while an upload is in flight — a bar sitting at 0% reads as broken.
  • Failures land in text, not in colour. onError hands you a string; where it goes is yours, but it has to go somewhere a screen reader reaches. Client-side rejections arrive with no code — they never became an HTTP response — so a handler that only reads code silently shows nothing for the most common failure of all.
  • Layout does not move. Remove is always rendered and merely disabled when there is nothing to remove, and the dashed border exists at rest in transparent, so neither uploading nor dragging reflows the page under a pointer.