Skip to content
cennge-ui
GitHubv0.3.1, changelog

FileUpload

A drop zone that takes files by drag and drop or the file picker, turns away the wrong ones with a reason, and lists the rest.

  • trace-webkit.zip182 kB
  • ci-run-1287.log7.1 kB
PNG or JPG, up to 1 MB
  1. Click the zone or drop files on it; a file already in the list is not added twice.
  2. Drop a PDF, or a file over 10 MB: it stays out, and the reason shows under the zone.
  3. Remove a file and focus moves to the next one, or the one above when it was last, or back to the zone when none are left.
Live: click it, drop files on it, use the keys.

Field marks

Import
import { FileUpload } from "cennge-ui";
Size
5.6 KB gzip for this import, its dependencies aside
Pattern
Native <button> that opens a hidden <input type="file">
Keyboard
Enter Space
State
Controlled with value and onValueChange (an array of File), or uncontrolled
SSR
Rendered on the server in the Next.js check; hydrates without a warning
Checks
Type, size and count, each refused file with its reason
Forms
With name, a plain submit sends the listed files
Tests
Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: file-upload

When to use#

Use a FileUpload where people attach files to something: screenshots to an issue, a log from a failed run, an image for a repository. It checks each file against accept, maxSize, minSize and maxFiles as it arrives, keeps the ones that pass and says why the others did not.

It uploads nothing by itself. You get the File objects from onValueChange and send them where they go, or give the field a name and let the form send them.

Similar#

  • A plain <input type="file"> opens the same picker, but shows no list of what was picked and gives no reasons. With a name, FileUpload keeps one inside that holds exactly the files in its list, so FormData reads what the reader sees.

Type, size and count#

accept takes MIME types, wildcards and extensions, like the native attribute, as a string or an array. A refused file stays out of the list with its reason under the zone. onReject receives each one with its codes: file-invalid-type, file-too-large, file-too-small or too-many-files.

limits.tsx
import { useState } from "react";
import { FileUpload } from "cennge-ui";

export default function Screenshots() {
  const [files, setFiles] = useState<File[]>([]);
  return (
    <FileUpload
      label="Screenshots"
      description="PNG or JPG, up to 2 MB, at most 3"
      accept={["image/png", "image/jpeg"]}
      maxSize={2 * 1024 * 1024}
      maxFiles={3}
      value={files}
      onValueChange={setFiles}
      className="w-full max-w-96"
    />
  );
}

Images get a preview. Its object URL is released when the file leaves the list.

One file, compact#

variant="button" is the small form for tight places; it still takes drops. With multiple={false} a new file replaces the one in the list.

The .tgz that npm pack wrote
one-file.tsx
import { useState } from "react";
import { FileUpload } from "cennge-ui";

export default function ReleaseAsset() {
  const [asset, setAsset] = useState<File[]>([]);
  return (
    <FileUpload
      label="Release asset"
      description="The .tgz that npm pack wrote"
      variant="button"
      multiple={false}
      accept=".tgz"
      value={asset}
      onValueChange={setAsset}
      className="w-full max-w-96"
    />
  );
}

Sent with the form#

With a name, the files are set on a real file input inside the field, so new FormData(form) and a plain form submit carry them. Remove one from the list and it is gone from the form too. Inside a Form, an error your server returns for logs shows under the field.

in-a-form.tsx
import { useState } from "react";
import { Button, FileUpload } from "cennge-ui";

export default function BugReport() {
  const [sent, setSent] = useState<string[] | null>(null);

  return (
    <form
      className="flex w-full max-w-96 flex-col gap-4"
      onSubmit={(event) => {
        event.preventDefault();
        const data = new FormData(event.currentTarget);
        // Send it as it is: fetch(url, { method: "POST", body: data })
        const logs = data.getAll("logs") as File[];
        setSent(logs.filter((file) => file.name).map((file) => file.name));
      }}
    >
      <FileUpload
        label="Logs"
        name="logs"
        accept={[".log", ".txt"]}
        description="From the failed CI run"
      />
      <div className="flex flex-wrap items-center gap-3">
        <Button type="submit" color="primary">
          Send report
        </Button>
        {sent ? <p className="text-sm text-muted">Sent: {sent.length ? sent.join(", ") : "no files"}</p> : null}
      </div>
    </form>
  );
}

In another language#

messages replaces the field's own words: the prompt, the button, the names of the list and of the remove buttons, and the reasons. File sizes are formatted for the locale of CenngeProvider.

translated.tsx
import { CenngeProvider, FileUpload, type FileErrorCode } from "cennge-ui";

const reasons: Record<FileErrorCode, string> = {
  "file-invalid-type": "nur PDF",
  "file-too-large": "größer als 5 MB",
  "file-too-small": "zu klein",
  "too-many-files": "zu viele Dateien",
};

export default function GermanAttachments() {
  return (
    // The provider's locale formats the file sizes: 1,2 MB.
    <CenngeProvider locale="de-DE">
      <FileUpload
        label="Anhänge"
        description="PDF, bis 5 MB"
        accept=".pdf"
        maxSize={5 * 1024 * 1024}
        messages={{
          prompt: "Dateien hierher ziehen oder",
          browse: "auswählen",
          dropping: "Zum Hinzufügen loslassen",
          fileList: "Ausgewählte Dateien",
          remove: (name) => `${name} entfernen`,
          error: (code, file) => `${file.name}: ${reasons[code]}`,
        }}
        className="w-full max-w-96"
      />
    </CenngeProvider>
  );
}

Accessibility#

The zone is a <button>, so Enter and Space open the picker; the label names it, and the prompt and the hint describe it. Reasons for refused files appear in a role="alert" region that is always in the page, so screen readers announce each new one. Every file has a remove button named after it ("Remove trace-webkit.zip"). After a removal, focus moves to the remove button of the file now in that place, or of the one above when the last file went, and to the zone when the list is empty.

Props#

PropTypeDefault
label
React.ReactNode
–
description
React.ReactNodeHint such as "PNG or JPG, up to 5 MB". Shown inside the dropzone.
–
errorMessage
React.ReactNodeShown while isInvalid.
–
value
File[]
–
defaultValue
File[]
–
onValueChange
(files: File[]) => void
–
accept
string | string[]MIME types, wildcards or extensions, like the native attribute: "image/*,.pdf" or ["image/*", ".pdf"].
–
maxSize
numberBytes.
–
minSize
numberBytes.
–
maxFiles
numberTotal files kept.
unlimited, or 1 when multiple is false
multiple
booleanSeveral files at once. Off: a new file replaces the current one.
true
onReject
(rejections: FileRejection[]) => voidFiles that were refused, with the reasons.
–
variant
"dropzone""button""dropzone": a large area. "button": compact, still accepts drops.
"dropzone"
radius
"none""sm""md""lg""full"
"lg"
icon
React.ReactNodeReplaces the upload icon.
–
showFileList
boolean
true
name
stringForm field name: the files are set on a real file input, so a plain form submit sends them.
–
isDisabled
boolean
–
isInvalid
boolean
–
isRequired
boolean
–
messages
Partial<FileUploadMessages>Text overrides for translation.
–
id
string
–
className
string
–
classNames
Partial<Record<FileUploadSlots, string>>
–