File Upload
A headless file picker with drag-and-drop, paste, validation and upload progress. Composes a dropzone or trigger button with a file list.
Playground
Drop files here or click to upload
<FileUpload accept="image/*" multiple>
<FileUploadDropzone>
<ArrowUpToLineIcon className="size-6 stroke-1 text-muted-foreground" />
<p className="pt-2 text-base font-medium text-foreground">
Drop files here or click to upload
</p>
<FileUploadList>
<FilesList />
</FileUploadList>
</FileUploadDropzone>
</FileUpload>Preview
FileUploadDropzone accepts click, drop, paste and keyboard activation. Render accepted files inside FileUploadList by reading the store with useFileUpload and mapping to FileUploadItem.
Drop files here or click to upload
Any file, multiple allowed
<FileUpload multiple>
<FileUploadDropzone>
<ArrowUpToLineIcon className="size-6 stroke-1 text-muted-foreground" />
<p className="pt-2 text-base font-medium text-foreground">
Drop files here or click to upload
</p>
<p className="text-sm text-accent-foreground">
Any file, multiple allowed
</p>
<FileUploadList>
<FilesList /> {/* maps useFileUpload state to FileUploadItem */}
</FileUploadList>
</FileUploadDropzone>
</FileUpload>Installation
npx shadcn@latest add https://espresso-base-ui.vercel.app/r/file-upload.jsonUsage
import {
FileUpload,
FileUploadClear,
FileUploadDropzone,
FileUploadItem,
FileUploadItemDelete,
FileUploadItemMetadata,
FileUploadItemPreview,
FileUploadItemProgress,
FileUploadList,
FileUploadTrigger,
useFileUpload,
} from "@/components/ui/file-upload"function Files() {
const fileStates = useFileUpload((state) => Array.from(state.files.values()))
return (
<>
{fileStates.map((fs) => (
<FileUploadItem key={fs.file.name} value={fs.file}>
<FileUploadItemPreview />
<FileUploadItemMetadata />
<FileUploadItemDelete asChild>
<Button variant="ghost" size="icon-sm">
<Trash />
</Button>
</FileUploadItemDelete>
</FileUploadItem>
))}
</>
)
}
<FileUpload multiple onValueChange={setFiles}>
<FileUploadDropzone>
<p>Drop files here</p>
<FileUploadList>
<Files />
</FileUploadList>
</FileUploadDropzone>
</FileUpload>Validation
accept, maxFiles and maxSize reject non-conforming files, briefly flagging the dropzone invalid; handle rejections in onFileReject (or run custom checks in onFileValidate).
Drop images here
PNG / JPG / GIF – up to 2 MB each, max 3 files
<FileUpload
accept="image/*"
maxFiles={3}
maxSize={2 * 1024 * 1024}
multiple
onFileReject={(file, message) => toast.error(file.name + ": " + message)}
>
<FileUploadDropzone>
<ArrowUpToLineIcon className="size-6 stroke-1 text-muted-foreground" />
<p className="pt-2 text-base font-medium text-foreground">
Drop images here
</p>
<p className="text-sm text-accent-foreground">
PNG / JPG / GIF – up to 2 MB each, max 3 files
</p>
<FileUploadList>
<FilesList />
</FileUploadList>
</FileUploadDropzone>
</FileUpload>Upload progress
Pass onUpload to start uploading as soon as files are accepted – report per-file progress through the provided callbacks and FileUploadItemProgress renders it as a linear bar, a circular ring or a fill overlay.
Drop files to upload
Fake upload simulates ~2s per file
<FileUpload
multiple
onUpload={async (files, { onProgress, onSuccess, onError }) => {
await Promise.all(
files.map((file) => uploadToServer(file, onProgress, onSuccess))
)
}}
>
<FileUploadDropzone>
<ArrowUpToLineIcon className="size-6 stroke-1 text-muted-foreground" />
<p className="pt-2 text-base font-medium text-foreground">
Drop files to upload
</p>
<FileUploadList>
{/* items render <FileUploadItemProgress variant="linear" /> */}
<FilesList progress="linear" />
</FileUploadList>
</FileUploadDropzone>
</FileUpload>API reference
FileUpload is fully composable: the root owns the file store and a visually hidden input type="file", and every part supports asChild to merge onto your own element. Files work controlled (value + onValueChange) or uncontrolled (defaultValue).
FileUpload
| Prop | Type | Default | Description |
|---|---|---|---|
| value | File[] | – | Controlled list of files. |
| defaultValue | File[] | – | Initial files when uncontrolled. |
| onValueChange | (files: File[]) => void | – | Called with the full file list whenever it changes. |
| onAccept | (files: File[]) => void | – | Called once per selection with all files that passed validation. |
| onFileAccept | (file: File) => void | – | Called for each accepted file. |
| onFileReject | (file: File, message: string) => void | – | Called for each rejected file with the rejection reason. |
| onFileValidate | (file: File) => string | null | undefined | – | Custom validator – return a message to reject the file. |
| onUpload | (files, { onProgress, onSuccess, onError }) => Promise<void> | void | – | Upload handler run for accepted files; drive item progress via the callbacks. |
| accept | string | – | Accepted types as a comma-separated list of MIME types, wildcards ("image/*") or extensions. |
| maxFiles | number | – | Maximum number of files; extras are rejected. |
| maxSize | number | – | Maximum size per file in bytes. |
| multiple | boolean | false | Allow selecting more than one file. |
| disabled | boolean | false | Disables the input, dropzone, trigger and clear parts. |
| invalid | boolean | false | Marks the upload invalid (styles the dropzone). |
| required | boolean | false | Marks the hidden file input as required. |
| name | string | – | Name of the hidden file input for form submission. |
| label | string | "File upload" | Screen-reader label announced for the hidden file input. |
| dir | "ltr" | "rtl" | – | Reading direction, inherited by all parts. |
| asChild | boolean | false | Merge root props onto the child element. |
FileUploadList
| Prop | Type | Default | Description |
|---|---|---|---|
| orientation | "vertical" | "horizontal" | "vertical" | Stack items vertically or lay them out as a wrapping row of tiles. Reflected as data-orientation. |
| forceMount | boolean | false | Render the list even while it has no files. |
FileUploadItem
| Prop | Type | Default | Description |
|---|---|---|---|
| value | File | – | The file this item represents; state is looked up in the store. |
FileUploadItemProgress
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "linear" | "circular" | "fill" | "linear" | Progress rendering – bar, SVG ring, or a translucent fill over the item. |
| size | number | 40 | Diameter of the circular variant in pixels. |
| forceMount | boolean | false | Keep rendering after progress reaches 100. |
FileUploadItemMetadata
| Prop | Type | Default | Description |
|---|---|---|---|
| size | "default" | "sm" | "default" | Typography scale of the default name + size rendering; pass children to fully replace it. |
| Component | Description |
|---|---|
| FileUploadDropzone | Click / drag-and-drop / paste target (data-slot="file-upload-dropzone"). Exposes data-dragging, data-invalid and data-disabled. |
| FileUploadTrigger | Button that opens the file picker (data-slot="file-upload-trigger"). |
| FileUploadList | role="list" container for items; hidden until files exist (data-slot="file-upload-list"). |
| FileUploadItem | role="listitem" card for one file with an sr-only status line (data-slot="file-upload-item"). |
| FileUploadItemPreview | Thumbnail – an object-URL image for image files, else a type-matched file icon (data-slot="file-upload-preview"). Accepts a render prop override. |
| FileUploadItemMetadata | File name, formatted size and any error message (data-slot="file-upload-metadata"). |
| FileUploadItemProgress | role="progressbar" bound to the file's upload progress (data-slot="file-upload-progress"). |
| FileUploadItemDelete | Button that removes its file from the store (data-slot="file-upload-item-delete"). |
| FileUploadClear | Button that removes all files; renders only while files exist unless forceMount (data-slot="file-upload-clear"). |
| useFileUpload | Selector hook over the store state ({ files, dragOver, invalid }) – use it inside FileUpload to render the item list. |
Accessibility & styling hooks
The real input type="file" stays in the tree (visually hidden) so forms and assistive tech see a native control, labelled via aria-labelledby from the label prop. The dropzone is a focusable role="region" that opens the picker on Enter or Space and reflects aria-invalid and aria-disabled; items announce name, size and upload status through aria-describedby and progress uses a proper role="progressbar".
For styling, every part carries a data-slot attribute, and the dropzone additionally exposes data-dragging while a drag hovers it, plus data-invalid and data-disabled – target these from CSS for custom drop states.