Skip to main content
A file upload component lets users select and manage files through a styled dropzone or trigger button. It provides drag-and-drop support, file type filtering, size limits, and custom validation — without handling the actual upload process.
The file upload component manages UI state and file selection only. Uploading files to a server is handled separately in your application logic.
Features
  • Trigger button to open the file dialog
  • Drag and drop to add files
  • Maximum file count and size constraints
  • Accepted file type filtering
  • Custom validation logic
  • Clipboard paste support
  • File transformation before acceptance

Installation

npm install @zag-js/file-upload @zag-js/react

Usage

Import the file upload package and connect the machine to your framework:
import * as fileUpload from "@zag-js/file-upload"
import { useMachine, normalizeProps } from "@zag-js/react"
import { useId } from "react"
The file upload package exports two key functions:
  • machine — Behavior logic.
  • connect — Maps behavior to JSX props and event handlers.
import * as fileUpload from "@zag-js/file-upload"
import { useMachine, normalizeProps } from "@zag-js/react"
import { useId } from "react"

export function FileUpload() {
  const service = useMachine(fileUpload.machine, {
    id: useId(),
    maxFiles: 5,
    accept: "image/*",
  })

  const api = fileUpload.connect(service, normalizeProps)

  return (
    <div {...api.getRootProps()}>
      <label {...api.getLabelProps()}>Upload images</label>

      <div {...api.getDropzoneProps()}>
        <p>Drag and drop images here</p>
        <button {...api.getTriggerProps()}>Choose files</button>
        <input {...api.getHiddenInputProps()} />
      </div>

      <ul {...api.getItemGroupProps()}>
        {api.acceptedFiles.map((file) => (
          <li key={file.name} {...api.getItemProps({ file })}>
            <span {...api.getItemNameProps({ file })}>{file.name}</span>
            <span {...api.getItemSizeTextProps({ file })}>
              {api.getFileSize(file)}
            </span>
            <button {...api.getItemDeleteTriggerProps({ file })}>Remove</button>
          </li>
        ))}
      </ul>
    </div>
  )
}

Accepted file types

Use accept to filter by MIME type or file extension.
// Accept all images
const service = useMachine(fileUpload.machine, {
  accept: "image/*",
})

// Accept specific types with extensions
const service = useMachine(fileUpload.machine, {
  accept: {
    "image/png": [".png"],
    "text/html": [".html", ".htm"],
  },
})

File count and size limits

const service = useMachine(fileUpload.machine, {
  maxFiles: 5,
  minFileSize: 1024,           // 1 KB minimum
  maxFileSize: 1024 * 1024 * 10, // 10 MB maximum
})

Listening for file changes

const service = useMachine(fileUpload.machine, {
  onFileChange(details) {
    // details => { acceptedFiles: File[], rejectedFiles: { file: File, errors: FileError[] }[] }
    console.log("accepted:", details.acceptedFiles)
    console.log("rejected:", details.rejectedFiles)
  },
})

Separate accept and reject callbacks

const service = useMachine(fileUpload.machine, {
  onFileAccept(details) {
    // details => { files: File[] }
    console.log("accepted:", details.files)
  },
  onFileReject(details) {
    // details => { files: { file: File, errors: FileError[] }[] }
    console.log("rejected:", details.files)
  },
})

Controlled files

Use acceptedFiles and onFileChange to manage accepted files externally.
const [files, setFiles] = useState([])

const service = useMachine(fileUpload.machine, {
  acceptedFiles: files,
  onFileChange(details) {
    setFiles(details.acceptedFiles)
  },
})

Custom validation

Set validate to return an array of error strings for invalid files, or null for valid files.
const service = useMachine(fileUpload.machine, {
  validate(file, details) {
    const errors = []

    if (file.size > 10 * 1024 * 1024) {
      errors.push("FILE_TOO_LARGE")
    }

    if (!file.name.endsWith(".pdf")) {
      errors.push("ONLY_PDF_ALLOWED")
    }

    // Reject duplicate filenames
    const isDuplicate = details.acceptedFiles.some(
      (f) => f.name === file.name,
    )
    if (isDuplicate) {
      errors.push("FILE_EXISTS")
    }

    return errors.length > 0 ? errors : null
  },
})
Built-in error codes: TOO_MANY_FILES, FILE_INVALID_TYPE, FILE_TOO_LARGE, FILE_TOO_SMALL. You can also return any custom string.

Transforming files before acceptance

Use transformFiles to process files (e.g. compress or convert) before they are added to acceptedFiles.
const service = useMachine(fileUpload.machine, {
  accept: "image/*",
  transformFiles: async (files) => {
    return Promise.all(
      files.map(async (file) => {
        const compressed = await compressImage(file)
        return new File([compressed], file.name, { type: file.type })
      }),
    )
  },
})
While transformation is in progress, api.transforming is true.

Disabling drag and drop

const service = useMachine(fileUpload.machine, {
  allowDrop: false,
})

Directory selection

Set directory to true to enable selecting entire directories (webkit browsers only).
const service = useMachine(fileUpload.machine, {
  directory: true,
})

Mobile camera capture

Set capture to open the camera directly on mobile devices.
const service = useMachine(fileUpload.machine, {
  capture: "environment", // or "user" for front-facing camera
})

Clipboard paste

Listen for the paste event and call api.setFiles to accept clipboard files.
<textarea
  onPaste={(event) => {
    if (event.clipboardData?.files) {
      api.setFiles(Array.from(event.clipboardData.files))
    }
  }}
/>

Usage within forms

Set name and render api.getHiddenInputProps() to participate in form submissions.
const service = useMachine(fileUpload.machine, {
  name: "avatar",
})
<input {...api.getHiddenInputProps()} />

API Reference

accept
Record<string, string[]> | string | string[]
Accepted file MIME types or extensions. Passed to the native accept attribute.
maxFiles
number
default:"1"
Maximum number of files that can be selected.
minFileSize
number
default:"0"
Minimum file size in bytes.
maxFileSize
number
default:"Infinity"
Maximum file size in bytes.
acceptedFiles
File[]
The controlled list of accepted files.
defaultAcceptedFiles
File[]
The initial list of accepted files when rendered.
disabled
boolean
Whether the file input is disabled.
readOnly
boolean
Whether the file input is read-only (prevents adding or removing files).
required
boolean
Whether the file input is required.
allowDrop
boolean
default:"true"
Whether drag and drop is enabled on the dropzone.
preventDocumentDrop
boolean
default:"true"
Whether to block file drops outside the dropzone on the document.
directory
boolean
Whether to enable directory selection (webkit only).
capture
"user" | "environment"
The camera to use for media capture on mobile devices.
validate
(file: File, details: FileValidateDetails) => FileError[] | null
Custom validation function. Return an array of error strings, or null if valid.
transformFiles
(files: File[]) => Promise<File[]>
Async function to transform files before they are accepted.
name
string
The name attribute for form submission.
onFileChange
(details: { acceptedFiles: File[], rejectedFiles: FileRejection[] }) => void
Callback invoked when the accepted or rejected files change.
onFileAccept
(details: { files: File[] }) => void
Callback invoked when files are accepted.
onFileReject
(details: { files: FileRejection[] }) => void
Callback invoked when files are rejected.

Styling

Each part includes a data-part attribute you can target in CSS.

Parts

PartDescription
rootThe root container
labelThe label element
dropzoneThe drag-and-drop area
triggerThe button to open the file dialog
item-groupContainer for the file list
itemAn individual file item
item-nameThe file name
item-size-textThe formatted file size
item-previewPreview container for the file
item-delete-triggerButton to remove a file

Dragging state

When a file is dragged over the dropzone, data-dragging is added to root and dropzone.
[data-part="root"][data-dragging] {
  /* styles for the active drag state */
}

[data-part="dropzone"][data-dragging] {
  background: var(--highlight-color);
  border-color: var(--accent-color);
}

Disabled state

[data-part="root"][data-disabled] { }
[data-part="dropzone"][data-disabled] { }
[data-part="trigger"][data-disabled] { }
[data-part="label"][data-disabled] { }

Accessibility

The file upload uses a visually-hidden <input type="file"> for native browser support and keyboard accessibility. The dropzone has appropriate ARIA attributes, and file items include accessible labels via translations.

Keyboard interactions

KeyDescription
Enter / SpaceActivates the trigger button to open the file dialog.
TabMoves focus between the trigger, dropzone, and file item delete buttons.
Delete / Backspace(on delete trigger) Removes the focused file from the list.

Build docs developers (and LLMs) love