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
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>
)
}
<script setup>
import * as fileUpload from "@zag-js/file-upload"
import { useMachine, normalizeProps } from "@zag-js/vue"
import { useId, computed } from "vue"
const service = useMachine(fileUpload.machine, {
id: useId(),
maxFiles: 5,
})
const api = computed(() => fileUpload.connect(service, normalizeProps))
</script>
<template>
<div v-bind="api.getRootProps()">
<label v-bind="api.getLabelProps()">Upload files</label>
<div v-bind="api.getDropzoneProps()">
<p>Drag and drop files here</p>
<button v-bind="api.getTriggerProps()">Choose files</button>
<input v-bind="api.getHiddenInputProps()" />
</div>
<ul v-bind="api.getItemGroupProps()">
<li
v-for="file in api.acceptedFiles"
:key="file.name"
v-bind="api.getItemProps({ file })"
>
<span v-bind="api.getItemNameProps({ file })">{{ file.name }}</span>
<button v-bind="api.getItemDeleteTriggerProps({ file })">Remove</button>
</li>
</ul>
</div>
</template>
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.
Maximum number of files that can be selected.
Minimum file size in bytes.
Maximum file size in bytes.
The controlled list of accepted files.
The initial list of accepted files when rendered.
Whether the file input is disabled.
Whether the file input is read-only (prevents adding or removing files).
Whether the file input is required.
Whether drag and drop is enabled on the dropzone.
Whether to block file drops outside the dropzone on the document.
Whether to enable directory selection (webkit only).
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.
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.
| Part | Description |
|---|
root | The root container |
label | The label element |
dropzone | The drag-and-drop area |
trigger | The button to open the file dialog |
item-group | Container for the file list |
item | An individual file item |
item-name | The file name |
item-size-text | The formatted file size |
item-preview | Preview container for the file |
item-delete-trigger | Button 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
| Key | Description |
|---|
Enter / Space | Activates the trigger button to open the file dialog. |
Tab | Moves focus between the trigger, dropzone, and file item delete buttons. |
Delete / Backspace | (on delete trigger) Removes the focused file from the list. |