Skip to content

Early previewhamur is not on npm yet. The components are being built in the open, follow along on GitHub.

InputsReact

File Upload

A drop zone and the list of the files it holds.

Rendered by React 19.3.1

Loading React…
import { FileUpload } from '@hamur/react/file-upload'

const brief = new File(['%PDF-1.7'], 'brief.pdf', { type: 'application/pdf', lastModified: 0 })

function RemoveIcon() {
  return (
    <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
      <path d="M4 4l8 8M12 4l-8 8" />
    </svg>
  )
}

export default function FileUploadExample() {
  return (
    <FileUpload.Root className="file-upload" multiple defaultValue={[brief]}>
      <FileUpload.Dropzone className="file-upload-dropzone">
        Drop files here or click to browse
      </FileUpload.Dropzone>
      <FileUpload.List className="file-upload-list">
        {(file: File) => (
          <FileUpload.Item file={file} className="file-upload-item">
            <FileUpload.ItemName className="file-upload-name" />
            <FileUpload.ItemSize className="file-upload-size" />
            <FileUpload.ItemRemove className="file-upload-remove">
              <RemoveIcon />
            </FileUpload.ItemRemove>
          </FileUpload.Item>
        )}
      </FileUpload.List>
      <FileUpload.List rejected className="file-upload-list">
        {(file: File) => (
          <FileUpload.Item file={file} className="file-upload-item">
            <FileUpload.ItemName className="file-upload-name" />
            <FileUpload.ItemError className="file-upload-error" />
            <FileUpload.ItemRemove className="file-upload-remove">
              <RemoveIcon />
            </FileUpload.ItemRemove>
          </FileUpload.Item>
        )}
      </FileUpload.List>
      <FileUpload.Clear className="button file-upload-clear">
        Remove all
      </FileUpload.Clear>
    </FileUpload.Root>
  )
}
Not measured yetTests passing in React
Not measured yetSize
Not measured yetMemory
Not measured yetSpeed
[N] commits

Installation

npm i @hamur/react

Not on npm yet. The package goes out under the chosen name.

Anatomy

Import the component and assemble its parts:

<FileUpload.Root>
  <FileUpload.Dropzone />
  <FileUpload.List>
    <FileUpload.Item>
      <FileUpload.ItemName />
      <FileUpload.ItemSize />
      <FileUpload.ItemRemove />
      <FileUpload.ItemError />
    </FileUpload.Item>
  </FileUpload.List>
  <FileUpload.Clear />
</FileUpload.Root>

// Not in this example: FileUpload.ItemPreview, FileUpload.Trigger

Examples

Images

accept="image/*", maxFiles={4} and maxSize of 1 MB: a "Choose images" Trigger inside the drop zone, previews in a grid, and the files past the limits listed with the reason.

FileUploadImages.tsx
import { Field } from '@hamur/react/field'
import { FileUpload } from '@hamur/react/file-upload'

function RemoveIcon() {
  return (
    <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
      <path d="M4 4l8 8M12 4l-8 8" />
    </svg>
  )
}

export default function FileUploadImagesExample() {
  return (
    <Field.Root className="field">
      <Field.Label className="file-upload-label">Photos</Field.Label>
      <FileUpload.Root
        className="file-upload"
        multiple
        accept="image/*"
        maxFiles={4}
        maxSize={1_000_000}
      >
        <FileUpload.Dropzone className="file-upload-dropzone">
          Drop images here or
          <FileUpload.Trigger className="button">Choose images</FileUpload.Trigger>
        </FileUpload.Dropzone>
        <FileUpload.List className="file-upload-grid">
          {(file: File) => (
            <FileUpload.Item file={file} className="file-upload-tile">
              <FileUpload.ItemPreview className="file-upload-preview" />
              <FileUpload.ItemName className="file-upload-name" />
              <FileUpload.ItemRemove className="file-upload-remove">
                <RemoveIcon />
              </FileUpload.ItemRemove>
            </FileUpload.Item>
          )}
        </FileUpload.List>
        <FileUpload.List rejected className="file-upload-list">
          {(file: File) => (
            <FileUpload.Item file={file} className="file-upload-item">
              <FileUpload.ItemName className="file-upload-name" />
              <FileUpload.ItemError className="file-upload-error" />
              <FileUpload.ItemRemove className="file-upload-remove">
                <RemoveIcon />
              </FileUpload.ItemRemove>
            </FileUpload.Item>
          )}
        </FileUpload.List>
      </FileUpload.Root>
      <Field.Description className="muted">Up to 4 images, 1 MB each.</Field.Description>
    </Field.Root>
  )
}

Form

A required PDF in a Field of a Form: Send without a file shows "Attach your résumé before you send.", and with one the form submits resume= and the file's name.

FileUploadForm.tsx
import * as React from 'react'
import { Field } from '@hamur/react/field'
import { FileUpload } from '@hamur/react/file-upload'
import { Form } from '@hamur/react/form'

function RemoveIcon() {
  return (
    <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
      <path d="M4 4l8 8M12 4l-8 8" />
    </svg>
  )
}

export default function FileUploadFormExample() {
  const [submitted, setSubmitted] = React.useState('')
  return (
    <Form
      className="stack"
      onFormSubmit={(_values, details) => {
        const form = details.event.target as HTMLFormElement
        setSubmitted(
          [...new FormData(form)]
            .map(([key, value]) => `${key}=${value instanceof File ? value.name : value}`)
            .join('&'),
        )
      }}
    >
      <Field.Root name="resume" className="field">
        <Field.Label className="file-upload-label">Résumé</Field.Label>
        <FileUpload.Root className="file-upload" accept=".pdf,application/pdf" required>
          <FileUpload.Dropzone className="file-upload-dropzone">
            Drop your résumé here or click to choose it
          </FileUpload.Dropzone>
          <FileUpload.List className="file-upload-list">
            {(file: File) => (
              <FileUpload.Item file={file} className="file-upload-item">
                <FileUpload.ItemName className="file-upload-name" />
                <FileUpload.ItemSize className="file-upload-size" />
                <FileUpload.ItemRemove className="file-upload-remove">
                  <RemoveIcon />
                </FileUpload.ItemRemove>
              </FileUpload.Item>
            )}
          </FileUpload.List>
          <FileUpload.List rejected className="file-upload-list">
            {(file: File) => (
              <FileUpload.Item file={file} className="file-upload-item">
                <FileUpload.ItemName className="file-upload-name" />
                <FileUpload.ItemError className="file-upload-error" />
              </FileUpload.Item>
            )}
          </FileUpload.List>
        </FileUpload.Root>
        <Field.Description className="muted">One PDF.</Field.Description>
        <Field.Error className="error" match="valueMissing">
          Attach your résumé before you send.
        </Field.Error>
      </Field.Root>
      <button className="button" type="submit">
        Send
      </button>
      {submitted ? <pre className="output">{submitted}</pre> : null}
    </Form>
  )
}

Compact

A Trigger alone, without multiple: "Upload avatar" picks one image, a new pick replaces it, and the file shows as a chip with its size.

FileUploadCompact.tsx
import { FileUpload } from '@hamur/react/file-upload'

function RemoveIcon() {
  return (
    <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
      <path d="M4 4l8 8M12 4l-8 8" />
    </svg>
  )
}

export default function FileUploadCompactExample() {
  return (
    <FileUpload.Root className="file-upload-compact" accept="image/*">
      <FileUpload.Trigger className="button">Upload avatar</FileUpload.Trigger>
      <FileUpload.List className="file-upload-inline">
        {(file: File) => (
          <FileUpload.Item file={file} className="file-upload-chip">
            <FileUpload.ItemName className="file-upload-name" />
            <FileUpload.ItemSize className="file-upload-size" />
            <FileUpload.ItemRemove className="file-upload-remove">
              <RemoveIcon />
            </FileUpload.ItemRemove>
          </FileUpload.Item>
        )}
      </FileUpload.List>
      <FileUpload.List rejected className="file-upload-inline">
        {(file: File) => (
          <FileUpload.Item file={file} className="file-upload-chip">
            <FileUpload.ItemName className="file-upload-name" />
            <FileUpload.ItemError className="file-upload-error" />
            <FileUpload.ItemRemove className="file-upload-remove">
              <RemoveIcon />
            </FileUpload.ItemRemove>
          </FileUpload.Item>
        )}
      </FileUpload.List>
    </FileUpload.Root>
  )
}

API reference

36 props across 11 parts, in React syntax. className, style, render are listed once, under Shared props.

ClearFileUpload.Clear

Removes every file and the rejections. Focus stays on it after a press. Renders a <button>.

Clear props
PropTypeDefault
focusableWhenDisabledWhether the button stays focusable while there is nothing to clear, so focus stays on itbooleantrue
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
Clear data attributes
Data attributeDescription
data-disabledPresent when there are no files and no rejections to clear, or the file upload is disabled or read-only.

DropzoneFileUpload.Dropzone

The area files are dropped on. A button that opens the picker, named by its content and the Field label, unless it contains a Trigger or openOnPress is false: it is then a drop and paste target only. Renders a <div>.

Dropzone props
PropTypeDefault
openOnPressWhether a press opens the picker, which makes the Dropzone a button.booleantrue
nativeButtonWhether the component renders a native <button> element when replacing itbooleanfalse
Dropzone data attributes
Data attributeDescription
data-draggingPresent while files are dragged over the Dropzone.
data-drag-acceptPresent while the dragged files would all be accepted.
data-drag-rejectPresent while the dragged files would be rejected.
data-disabledPresent when the file upload is disabled.
data-readonlyPresent when the file upload is read-only.
data-validPresent when the file upload is in a valid state (when wrapped in Field.Root).
data-invalidPresent when the file upload is in an invalid state (when wrapped in Field.Root).
data-touchedPresent when the file upload has been touched (when wrapped in Field.Root).
data-dirtyPresent when the files have changed (when wrapped in Field.Root).
data-filledPresent when the file upload holds a file (when wrapped in Field.Root).
data-focusedPresent when a control of the file upload is focused (when wrapped in Field.Root).

ItemFileUpload.Item

One file of a list. Renders a <li>.

Item props
PropTypeDefault
filerequiredThe file the item shows.File—
statusAny status the application sets, such as uploading, error or done, as data-status.string—
Item data attributes
Data attributeDescription
data-rejectedPresent when the item is in the list of rejections.
data-statusThe status the application set.
data-disabledPresent when the file upload is disabled.
data-readonlyPresent when the file upload is read-only.

ItemErrorFileUpload.ItemError

Why a rejected file was not added: its messages, which also describe the item's remove button. Renders nothing for a file without errors. Renders a <span>.

ItemError props
PropTypeDefault
childrenThe text, or a function of the file's errors.React.ReactNode | ((errors: readonly FileUploadError[]) => React.ReactNode)—

ItemNameFileUpload.ItemName

The file's name. Renders a <span>.

ItemPreviewFileUpload.ItemPreview

A thumbnail of an image file, from an object URL created after mount and revoked when the file changes or the part unmounts. Renders nothing for a file that is not an image, or whose image fails to load. Decorative by default. Renders an <img>.

ItemPreview props
PropTypeDefault
altThe image's text alternative.string''
ItemPreview data attributes
Data attributeDescription
data-statusWhether the image is loading, loaded, or failed to load.

ItemRemoveFileUpload.ItemRemove

Removes the item's file, or dismisses its rejection. Named "Remove report.pdf" unless it has text of its own; focus moves to the next remove button after a press. Renders a <button>.

ItemRemove props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
ItemRemove data attributes
Data attributeDescription
data-disabledPresent when the file upload is disabled.
data-readonlyPresent when the file upload is read-only.

ItemSizeFileUpload.ItemSize

The file's size, in decimal units formatted with locale ("1.23 MB"). Renders a <span>.

ItemSize props
PropTypeDefault
childrenThe text, or a function of the formatted size and the size in bytes.React.ReactNode | ((formattedSize: string, bytes: number) => React.ReactNode)—

ListFileUpload.List

The files of the value, or with rejected the files of the last attempt that were not added. The element stays when the list is empty. Renders an <ul>.

List props
PropTypeDefault
rejectedWhether the list shows the files of the last attempt that were not added, instead of thebooleanfalse
childrenThe items, or a function of each file and its index that returns one, keyed by the file.React.ReactNode | ((file: File, index: number) => React.ReactNode)—
List data attributes
Data attributeDescription
data-rejectedPresent when the list shows the last attempt's rejections.
data-emptyPresent when the list has no files.

RootFileUpload.Root

Groups the parts of a file upload and holds its files. Renders the visually hidden file input that is both the picker and the form value, and the status region that announces changes. Renders a <div>.

Root props
PropTypeDefault
valueThe files.File[]—
defaultValueThe files when the component is initially rendered.File[]—
onValueChangeEvent handler called when the files change.((value: File[], eventDetails: FileUploadRootChangeEventDetails) => void)—
onFileRejectEvent handler called when files of an attempt are not added, each with its errors.((rejections: FileUploadRejection[], eventDetails: FileUploadRootRejectEventDetails) => void)—
acceptThe file types accepted, as HTML's accept token string: MIME types, type/* wildcardsstring—
multipleWhether the value holds several files that new files join.booleanfalse
maxFilesWith multiple, the most files the value holds.number—
maxSizeThe largest file accepted, in bytes.number—
minSizeThe smallest file accepted, in bytes.number0
validateChecks a file after the built-in checks pass.FileUploadValidate—
directoryWhether the picker selects folders.booleanfalse
captureThe camera a mobile picker opens, passed to the input."user" | "environment"—
allowDropWhether files dropped on a Dropzone are added.booleantrue
allowPasteWhether files pasted while focus is inside the component are added.booleantrue
preventDocumentDropWhether a file dropped outside every Dropzone and outside editable fields is prevented frombooleantrue
disabledWhether the component should ignore user interaction.booleanfalse
readOnlyWhether the user should be unable to change the files.booleanfalse
requiredWhether the user must add a file before submitting a form.booleanfalse
labelsThe texts, in English by default.Partial<FileUploadLabels>—
localeThe locale used by Intl.NumberFormat when formatting sizes and counts.Intl.LocalesArgument—
nameIdentifies the field when a form is submitted.string—
formIdentifies the form that owns the file input.string—
inputRefA ref to access the file input element.React.Ref<HTMLInputElement>—
Root data attributes
Data attributeDescription
data-disabledPresent when the file upload is disabled.
data-readonlyPresent when the file upload is read-only.
data-requiredPresent when the file upload is required.
data-draggingPresent while files are dragged over one of the Dropzones.
data-document-draggingPresent while files are dragged anywhere over the document.
data-processingPresent while a dropped folder is read or validate is pending.
data-max-reachedPresent when multiple is set and the value holds maxFiles files.
data-validPresent when the file upload is in a valid state (when wrapped in Field.Root).
data-invalidPresent when the file upload is in an invalid state (when wrapped in Field.Root).
data-touchedPresent when the file upload has been touched (when wrapped in Field.Root).
data-dirtyPresent when the files have changed (when wrapped in Field.Root).
data-filledPresent when the file upload holds a file (when wrapped in Field.Root).
data-focusedPresent when a control of the file upload is focused (when wrapped in Field.Root).

TriggerFileUpload.Trigger

Opens the file picker. Inside a Dropzone it is the one control, and the Dropzone stays a drop and paste target. Renders a <button>.

Trigger props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
Trigger data attributes
Data attributeDescription
data-disabledPresent when the file upload is disabled.
data-readonlyPresent when the file upload is read-only.
data-validPresent when the file upload is in a valid state (when wrapped in Field.Root).
data-invalidPresent when the file upload is in an invalid state (when wrapped in Field.Root).
data-touchedPresent when the file upload has been touched (when wrapped in Field.Root).
data-dirtyPresent when the files have changed (when wrapped in Field.Root).
data-filledPresent when the file upload holds a file (when wrapped in Field.Root).
data-focusedPresent when a control of the file upload is focused (when wrapped in Field.Root).

Shared props

Every part that renders an element takes these.

Shared props
PropType
classNameCSS class applied to the element, or a function that returns a class based on the component's state.string | ((state: State) => string | undefined)
styleStyle applied to the element, or a function that returns a style object based on the component's state.React.CSSProperties | ((state: State) => React.CSSProperties | undefined)
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, State>

Keyboard and accessibility

Keyboard

Keyboard
KeysDoesPart
TabMoves to the Dropzone, one Tab stop; the hidden input is skippedDropzone
EnterSpaceOpens the file picker once: Enter on keydown, Space on keyup; focus stays on the DropzoneDropzone
TabTriggerMoves to the Trigger, the one Tab stop; the Dropzone has no role and no Tab stopDropzone
EnterSpaceTriggerOpens the file pickerTrigger
ControlVMetaVAdds the pasted files while focus is inside the Root, with reason input-paste; a text paste is left aloneRoot
EnterSpaceItemRemoveRemoves its file; focus moves to the next remove button, then the previous one, then the DropzoneItemRemove
EnterSpaceClearEmpties the value and the rejections with reason clear-press; focus stays on ClearClear

Accessibility

What the parts set
PartSetsSo that
Dropzonerole="button", tabindex="0", named by its content and Field.Label, described by Field.Description; no aria-labelthe drop zone is read as the button it is, with the text it shows; a development build warns without a name
Dropzoneno role and no Tab stop when a Trigger is inside it, or with openOnPress={false}there is never a button inside a button
Triggera button with type="button", named by its content, labelled by Field.Label when it is the only controlthe picker opens from a real button that never submits the form
Rootone input type="file" with aria-hidden="true" and tabindex="-1"; focus that reaches it moves to the Dropzone or Triggerthe files are submitted with the form while the input is never a stop of its own
Lista ulthe files are read as a list with a count
Iteman li for each file, in the order of the valueeach file is one entry of the list
ItemRemovea button with type="button", named "Remove {name}" or, on a rejection, "Dismiss {name}" (labels), or by its texteach button says which file it removes
ItemErroran id that is the aria-describedby of the item's ItemRemovethe reason a file was not added is read with the button that dismisses it
Cleara button named "Remove all files" (labels.clear) or by its text; disabled but focusable while there is nothing to clearclearing is reached without a press on each file
Rootone polite, atomic role="status" region, empty on mount, always mounted"brief.pdf added", "2 files added", a removal and a rejection with its reason are announced

Focus order

  1. The Dropzone is one Tab stop, or the Trigger when there is no button Dropzone. Nothing takes focus on mount, and the hidden input is never a stop.
  2. Every ItemRemove is a Tab stop after it, in the order of the files, then Clear.
  3. After a removal focus moves to the next remove button, else the previous one, else the Dropzone (or the Trigger without one). After Clear focus stays on it.
  4. A read-only upload keeps its Tab stops with aria-disabled; a disabled one has none. A required, empty upload in a Form gets focus on submit, on its Dropzone.