ComponentsFile
File upload
Lets people pick or drop files, checks them, and shows each one's progress while the app uploads it.
Import
import { FileUpload, FileUploadDropzone, FileUploadTrigger, FileUploadSubmit, FileUploadClear, FileUploadProgress, FileUploadErrors, FileUploadList, FileUploadItem, FileUploadPreview } from "@booleanpress/ui/file-upload"Usage
FileUpload holds the files and their state; the parts inside it pick, drop, list and send them. The component never touches the network: onUpload receives the files and reports progress back through onProgress.
import { FileUpload, FileUploadDropzone, FileUploadList, FileUploadSubmit } from "@booleanpress/ui/file-upload"
export function AttachmentUpload() {
return (
<FileUpload
accept="image/*,application/pdf"
multiple
maxSize={5_000_000}
onUpload={async (files, { onProgress, signal }) => {
for (const file of files) {
await sendToApi(file, { signal, onProgress: (percent) => onProgress(file, percent) })
}
}}
>
<FileUploadDropzone />
<FileUploadList />
<FileUploadSubmit />
</FileUpload>
)
}The parts are flat and each does one job, so the same pieces make a single button, a toolbar with a list, a drop zone or a field: FileUploadTrigger opens the system's picker, FileUploadDropzone takes dropped files (and, with no children, is itself one large button), FileUploadSubmit sends the queued files and FileUploadClear aborts and empties everything. FileUploadList shows a FileUploadItem per file (name, size in the provider's locale, a picture or type icon, a progress bar while uploading, the status and a remove button); layout="grid" makes cards with large pictures. FileUploadProgress is one bar for the whole batch and FileUploadErrors lists why files were refused. For a layout of your own, useFileUpload() returns the files and the actions.
Files are checked as they arrive, in this order: accept (extensions, image/* wildcards and exact types), maxSize in bytes, then maxFiles. A dropped folder is refused with its own message (folderNotAllowed), as the browser lists it among the files but cannot read it. A refused file never joins the list; its message shows in FileUploadErrors until the next files arrive, and onReject receives the reasons. Without multiple, a new file replaces the one chosen. With auto each batch uploads as soon as it is added. In onUpload, call onProgress(file, percent) as bytes go out, onError(file, message) for a file the server refused, and resolve when the batch is done; a rejected promise marks the whole batch as failed. signal aborts when the list is cleared, when every file of the batch is removed or replaced, or when the component unmounts: pass it to fetch. fileSignal(file) is one file's signal, which also aborts when that file is removed: pass it to that file's request, so removing a file during its upload stops it, and catch that request's abort so the rest of the batch carries on. defaultFiles seeds the list once, checked like added files; onFilesChange reports every change. formatFileSize(bytes, locale) writes a size the way the list does.
Examples
Basic
A Choose button, the names of the chosen files beside it, and an Upload button that sends them.
import {
FileUpload,
FileUploadErrors,
FileUploadSubmit,
FileUploadTrigger,
useFileUpload,
type FileUploadHelpers,
} from "@booleanpress/ui/file-upload"
// Stands in for the app's request: every file finishes after a fixed delay.
function upload(files: File[], { onProgress }: FileUploadHelpers) {
return new Promise<void>((resolve) =>
setTimeout(() => {
files.forEach((file) => onProgress(file, 100))
resolve()
}, 600)
)
}
function ChosenFiles() {
const { files } = useFileUpload()
return <span>{files.length > 0 ? files.map((entry) => entry.file.name).join(", ") : "No file chosen"}</span>
}
export default function FileUploadBasic() {
return (
<FileUpload accept="image/*" multiple maxSize={1_000_000} onUpload={upload} className="w-full max-w-xl">
<FileUploadErrors />
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-3">
<FileUploadTrigger />
<ChosenFiles />
</div>
<FileUploadSubmit />
</div>
</FileUpload>
)
}Auto upload
auto uploads each file as soon as it is chosen; the list shows its progress and then its status.
import { PlusIcon } from "lucide-react"
import { FileUpload, FileUploadErrors, FileUploadList, FileUploadTrigger, type FileUploadHelpers } from "@booleanpress/ui/file-upload"
// Stands in for the app's request: progress in fixed steps, every 250 ms.
function upload(files: File[], { onProgress, signal }: FileUploadHelpers) {
return new Promise<void>((resolve) => {
let percent = 0
const timer = setInterval(() => {
percent += 25
files.forEach((file) => onProgress(file, percent))
if (percent >= 100) {
clearInterval(timer)
resolve()
}
}, 250)
signal.addEventListener("abort", () => clearInterval(timer))
})
}
export default function FileUploadAuto() {
return (
<FileUpload accept="image/*" maxSize={1_000_000} auto onUpload={upload} className="w-full max-w-md items-center">
<FileUploadTrigger>
<PlusIcon />
Browse
</FileUploadTrigger>
<FileUploadErrors className="w-full" />
<FileUploadList className="w-full" />
</FileUpload>
)
}Advanced
A toolbar with Choose, Upload and Cancel above the batch's progress bar and the list; the whole card takes dropped files.
import {
FileUpload,
FileUploadClear,
FileUploadDropzone,
FileUploadErrors,
FileUploadList,
FileUploadProgress,
FileUploadSubmit,
FileUploadTrigger,
type FileUploadHelpers,
} from "@booleanpress/ui/file-upload"
// Stands in for the app's request: progress in fixed steps, every 200 ms.
function upload(files: File[], { onProgress, signal }: FileUploadHelpers) {
return new Promise<void>((resolve) => {
let percent = 0
const timer = setInterval(() => {
percent += 20
files.forEach((file) => onProgress(file, percent))
if (percent >= 100) {
clearInterval(timer)
resolve()
}
}, 200)
signal.addEventListener("abort", () => clearInterval(timer))
})
}
export default function FileUploadAdvanced() {
return (
<FileUpload accept="image/*" multiple maxSize={1_000_000} onUpload={upload} className="w-full max-w-xl">
<FileUploadDropzone className="rounded-md border-solid p-0 data-[dragging]:border-dashed">
<div className="flex flex-wrap items-center gap-2 p-5">
<FileUploadTrigger />
<FileUploadSubmit />
<FileUploadClear />
</div>
<div className="flex flex-col gap-3.5 px-4 pb-4">
<FileUploadErrors />
<FileUploadProgress />
<FileUploadList empty={<p>Drag and drop files here to upload.</p>} />
</div>
</FileUploadDropzone>
</FileUpload>
)
}In an input group
FileUploadTrigger asChild turns an input group's button into the picker, and the field shows the chosen name.
import { TagIcon, UploadIcon } from "lucide-react"
import { FileUpload, FileUploadTrigger, useFileUpload } from "@booleanpress/ui/file-upload"
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@booleanpress/ui/input-group"
function ImportField() {
const { files } = useFileUpload()
return (
<InputGroup>
<InputGroupAddon>
<TagIcon />
</InputGroupAddon>
<InputGroupInput aria-label="Contacts file" placeholder="No file chosen" readOnly value={files[0]?.file.name ?? ""} />
<InputGroupAddon align="inline-end">
<FileUploadTrigger asChild>
<InputGroupButton>
<UploadIcon />
Browse
</InputGroupButton>
</FileUploadTrigger>
</InputGroupAddon>
</InputGroup>
)
}
export default function FileUploadInputGroup() {
return (
<FileUpload accept=".csv,text/csv" className="w-full max-w-sm">
<ImportField />
</FileUpload>
)
}Custom upload
An onUpload of your own reports each file's progress through onProgress; here fixed timers stand in for the request.
import { FileUpload, FileUploadList, FileUploadSubmit, type FileUploadHelpers } from "@booleanpress/ui/file-upload"
const LOGS = [
new File([new Uint8Array(48_200)], "delivery-log-october.csv", { type: "text/csv" }),
new File([new Uint8Array(1_250_000)], "bounce-report-october.pdf", { type: "application/pdf" }),
]
// The app's own upload, faked with fixed timers: each file moves at its own pace and the batch ends when all are done.
function upload(files: File[], { onProgress, signal }: FileUploadHelpers) {
return new Promise<void>((resolve) => {
const progress = files.map(() => 0)
const timer = setInterval(() => {
files.forEach((file, index) => {
progress[index] = Math.min(100, progress[index] + 10 + index * 15)
onProgress(file, progress[index])
})
if (progress.every((percent) => percent === 100)) {
clearInterval(timer)
resolve()
}
}, 300)
signal.addEventListener("abort", () => clearInterval(timer))
})
}
export default function FileUploadCustomUpload() {
return (
<FileUpload multiple defaultFiles={LOGS} onUpload={upload} className="w-full max-w-xl">
<FileUploadList />
<div className="flex justify-end">
<FileUploadSubmit variant="default">Send to the archive</FileUploadSubmit>
</div>
</FileUpload>
)
}Drop zone
With no children the drop zone is one large button: drop files on it, or press it to browse.
import {
FileUpload,
FileUploadClear,
FileUploadDropzone,
FileUploadErrors,
FileUploadList,
FileUploadSubmit,
type FileUploadHelpers,
} from "@booleanpress/ui/file-upload"
// Stands in for the app's request: progress in fixed steps, every 200 ms.
function upload(files: File[], { onProgress, signal }: FileUploadHelpers) {
return new Promise<void>((resolve) => {
let percent = 0
const timer = setInterval(() => {
percent += 20
files.forEach((file) => onProgress(file, percent))
if (percent >= 100) {
clearInterval(timer)
resolve()
}
}, 200)
signal.addEventListener("abort", () => clearInterval(timer))
})
}
export default function FileUploadDropzoneExample() {
return (
<FileUpload accept="image/*,application/pdf" multiple maxSize={5_000_000} onUpload={upload} className="w-full max-w-xl">
<FileUploadDropzone />
<FileUploadErrors />
<FileUploadList />
<div className="flex justify-end gap-2">
<FileUploadClear variant="ghost" />
<FileUploadSubmit variant="default" />
</div>
</FileUpload>
)
}Image previews
layout="grid" shows each image as a card with its picture; the remove button shows on hover and on focus.
import { PlusIcon, UploadIcon } from "lucide-react"
import { FileUpload, FileUploadList, FileUploadSubmit, FileUploadTrigger, type FileUploadHelpers } from "@booleanpress/ui/file-upload"
// Pictures drawn here, as SVG, so the example needs no network.
function picture(name: string, from: string, to: string) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="200"><defs><linearGradient id="g" x2="1" y2="1"><stop stop-color="${from}"/><stop offset="1" stop-color="${to}"/></linearGradient></defs><rect width="320" height="200" fill="url(#g)"/><circle cx="244" cy="58" r="26" fill="#fff" fill-opacity=".75"/><path d="M0 200 110 92l80 66 52-40 78 72v10z" fill="#fff" fill-opacity=".4"/></svg>`
return new File([svg], name, { type: "image/svg+xml" })
}
const PICTURES = [
picture("newsletter-header.svg", "#0ea5e9", "#6366f1"),
picture("welcome-banner.svg", "#f59e0b", "#ef4444"),
picture("team-offsite.svg", "#10b981", "#0f766e"),
]
// Stands in for the app's request: every file finishes after a fixed delay.
function upload(files: File[], { onProgress }: FileUploadHelpers) {
return new Promise<void>((resolve) =>
setTimeout(() => {
files.forEach((file) => onProgress(file, 100))
resolve()
}, 700)
)
}
export default function FileUploadImagePreview() {
return (
<FileUpload accept="image/*" multiple defaultFiles={PICTURES} onUpload={upload} className="w-full max-w-xl">
<div className="flex flex-wrap gap-2">
<FileUploadTrigger variant="outline">
<PlusIcon />
Add images
</FileUploadTrigger>
<FileUploadSubmit variant="default">
<UploadIcon />
Upload all
</FileUploadSubmit>
</div>
<FileUploadList
layout="grid"
empty={
<p className="rounded-lg border border-dashed border-border p-10 text-center text-muted-foreground">
No images chosen. Add images to see them here.
</p>
}
/>
</FileUpload>
)
}Validation errors
accept, maxSize and maxFiles refuse a wrong type, a file that is too large and one file too many, each with its message.
import { FileUpload, FileUploadDropzone, FileUploadErrors, FileUploadList } from "@booleanpress/ui/file-upload"
// Five files offered at once: two pass, and one fails each rule.
const OFFERED = [
new File([new Uint8Array(84_000)], "logo.png", { type: "image/png" }),
new File(["Call notes, 3 October"], "notes.txt", { type: "text/plain" }),
new File([new Uint8Array(2_400_000)], "office-photo.jpg", { type: "image/jpeg" }),
new File([new Uint8Array(120_000)], "header.jpg", { type: "image/jpeg" }),
new File([new Uint8Array(96_000)], "footer.png", { type: "image/png" }),
]
export default function FileUploadValidation() {
return (
<FileUpload
accept="image/png,image/jpeg"
multiple
maxSize={1_000_000}
maxFiles={2}
defaultFiles={OFFERED}
className="w-full max-w-xl"
>
<FileUploadDropzone description="PNG or JPG, up to 1 MB each, 2 files at most" />
<FileUploadErrors />
<FileUploadList />
</FileUpload>
)
}Disabled
disabled stops picking, dropping, uploading and removing; the files already listed stay.
import { FileUpload, FileUploadDropzone, FileUploadList } from "@booleanpress/ui/file-upload"
const ATTACHED = [new File([new Uint8Array(48_200)], "delivery-log-october.csv", { type: "text/csv" })]
export default function FileUploadDisabled() {
return (
<FileUpload disabled multiple defaultFiles={ATTACHED} className="w-full max-w-xl">
<FileUploadDropzone description="Uploads are paused while the archive is moved" />
<FileUploadList />
</FileUpload>
)
}Upload error
An onUpload that rejects: the file's row shows "Upload failed" while the rest of the page stays as it was.
import { FileUpload, FileUploadList, FileUploadSubmit } from "@booleanpress/ui/file-upload"
const REPORT = new File([new Uint8Array(210_000)], "bounce-report-october.csv", { type: "text/csv" })
// The server refuses the whole batch after a moment: the rejection marks every file of it as failed.
function upload() {
return new Promise<void>((_, reject) => {
setTimeout(() => reject(new Error("Acme Archive answered with 503")), 800)
})
}
export default function FileUploadUploadError() {
return (
<FileUpload defaultFiles={[REPORT]} onUpload={upload} className="w-full max-w-xl">
<FileUploadList />
<div className="flex justify-end">
<FileUploadSubmit variant="default">Send to the archive</FileUploadSubmit>
</div>
</FileUpload>
)
}Accessibility
- Semantics
- Every way to add files is a native
button; the file input itself is hidden. The list is auloflirows; a row's progress is aprogressbarnamed after its file, and its status is text. Messages about added, refused, uploaded and failed files go to a polite live region (role="status") inside the root, so they are read without moving focus. - Labels
- The buttons carry their own words (
chooseFiles,upload,cancel); a remove button is named "Remove {file name}" (removeItem). The drop zone's button is named by its two lines. WithasChild, name your element yourself. Refusal messages name the file and the rule:fileTooLarge,fileTypeNotAllowed,tooManyFiles,folderNotAllowed. - Focus
- The picker is a system dialog: focus returns to the button that opened it. Removing a file moves focus to the next row's remove button, else the previous one's, else the first button that adds files, so it never falls to the page.
- Known limits
- Dragging files is a pointer gesture only; every drop zone also opens the picker by click, Enter or Space, so nothing depends on dragging (WCAG 2.5.7).
- A refused file is not listed. Its message stays in
FileUploadErrorsuntil the next files arrive or the list is cleared. - Removing a file during its upload aborts its
fileSignal(file); a request given only the batch'ssignalstops when the last file of the batch is removed. - A dropped folder is refused, not opened: its files are not added. People choose or drop the files themselves.
- Files leave through your upload function, not with a form's native submit: FileUpload takes no
name, and a form's Reset leaves its list alone. - Sizes are in steps of 1,000 with the short units B, KB, MB and GB, the same in every language; the number is formatted by
Intlin the provider's locale. - An image's picture is a blob URL made in the browser after it loads; the server renders the type icon in its place.
- On a card the remove button is hidden until the card is hovered or holds focus; on touch screens it always shows.
Keyboard
| Key | Behaviour |
|---|---|
| EnterorSpace | On Choose, or on a drop zone with no children, opens the system's file picker. |
| EnterorSpace | On Upload, sends the queued files; on Cancel, aborts the uploads and empties the list. |
| EnterorSpace | On a file's remove button, removes it and moves focus to the next file's remove button. |
| Tab | Moves through the buttons and each file's remove button in reading order. |
API
FileUpload
Renders a div and passes it every other prop.
| Prop | Type | Default | Description |
|---|---|---|---|
accept | string | The file types to take, as the native input's accept: "image/*", ".csv,.txt", "application/pdf". | |
auto | boolean | false | Uploads files as soon as they are added, so no Upload button is needed. |
defaultFiles | File[] | Files in the list from the start, checked like added ones. Read once, on mount. | |
disabled | boolean | false | Stops picking, dropping, uploading and removing; the parts show as disabled. |
maxFiles | number | The most files the list holds, with multiple. Files over it are refused with tooManyFiles. | |
maxSize | number | The largest file, in bytes. A larger one is refused with the provider's fileTooLarge. | |
multiple | boolean | false | Takes several files; without it, a new file replaces the one chosen. |
onFilesChange | ((files: FileUploadFile[]) => void) | Called with the whole list whenever a file is added, removed or changes status. | |
onReject | ((rejections: FileUploadRejection[]) => void) | Called with the files that were refused, and why. | |
onUpload | FileUploadHandler | Sends the files; the component itself never touches the network. |
FileUploadDropzone
Renders a div and passes it every other prop.
| Prop | Type | Default | Description |
|---|---|---|---|
description | ReactNode | The prompt's second line, without children. Defaults to the provider's browseFiles. | |
title | ReactNode | The prompt's first line, without children. Defaults to the provider's dropFilesHere. |
FileUploadTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | Gives the part's behaviour to your element, such as an InputGroupButton, and none of Button's look: variant, size and the other style props are not passed on. | |
loading | boolean | Shows the spinner in place of the leading icon, sets aria-busy and aria-disabled and ignores presses (a click,
Enter, Space, a form's submission), while the button keeps its focus and its place in the tab order. With
asChild the child (a link) gets the same. | |
raised | boolean | null | Button's raised shadow. | |
rounded | boolean | null | Button's pill shape. | |
severity | "success" | "info" | "warning" | "help" | "danger" | "contrast" | null | Button's severity colour. | |
size | "default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null | Button's size. Without children the part shows its own icon and words. | |
variant | "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null | The button's look; default (the primary fill) by default. |
FileUploadSubmit
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | Gives the part's behaviour to your element, such as an InputGroupButton, and none of Button's look: variant, size and the other style props are not passed on. | |
loading | boolean | Shows the spinner in place of the leading icon, sets aria-busy and aria-disabled and ignores presses (a click,
Enter, Space, a form's submission), while the button keeps its focus and its place in the tab order. With
asChild the child (a link) gets the same. | |
raised | boolean | null | Button's raised shadow. | |
rounded | boolean | null | Button's pill shape. | |
severity | "success" | "info" | "warning" | "help" | "danger" | "contrast" | null | Button's severity colour. | |
size | "default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null | Button's size. Without children the part shows its own icon and words. | |
variant | "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null | secondary | The button's look; secondary by default. It is disabled while no file is queued. |
FileUploadClear
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | Gives the part's behaviour to your element, such as an InputGroupButton, and none of Button's look: variant, size and the other style props are not passed on. | |
loading | boolean | Shows the spinner in place of the leading icon, sets aria-busy and aria-disabled and ignores presses (a click,
Enter, Space, a form's submission), while the button keeps its focus and its place in the tab order. With
asChild the child (a link) gets the same. | |
raised | boolean | null | Button's raised shadow. | |
rounded | boolean | null | Button's pill shape. | |
severity | "success" | "info" | "warning" | "help" | "danger" | "contrast" | null | Button's severity colour. | |
size | "default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null | Button's size. Without children the part shows its own icon and words. | |
variant | "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null | secondary | The button's look; secondary by default. It is disabled while there is nothing to clear. |
FileUploadProgress
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | ||
getValueLabel | ((value: number, max: number) => string) | ||
max | number | ||
showValue | boolean | Writes the value inside the filled part: getValueLabel's text, a percentage by default. Not drawn at sm, in
segments or while indeterminate. | |
size | "default" | "sm" | "lg" | The bar's height: sm 8 px, default 18 px, lg 24 px. | |
steps | number | Draws the bar in this many equal segments, such as the steps of a setup. |
FileUploadErrors
Renders a ul and passes it every other prop.
FileUploadList
Renders a ul and passes it every other prop.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ((file: FileUploadFile) => ReactNode) | Draws each file yourself; by default each is a FileUploadItem. | |
empty | ReactNode | Shown in place of the list while it is empty. | |
layout | "grid" | "list" | list | list puts one file per row; grid lays the files out as cards with a large picture. |
FileUploadItem
Renders a li and passes it every other prop.
| Prop | Type | Default | Description |
|---|---|---|---|
filerequired | FileUploadFile | The entry to show, from useFileUpload().files or the list's render function. | |
preview | boolean | true | Shows the picture of an image, or the icon of the file's type. |
FileUploadPreview
Renders a span and passes it every other prop.
| Prop | Type | Default | Description |
|---|---|---|---|
filerequired | File | The file to show: a picture for an image, the icon of its type otherwise. |
Also exported: useFileUpload, a hook for the parts' shared state; call it inside the component's provider; formatFileSize, a helper the parts use.
Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.
Data attributes: data-slot="file-upload" (FileUpload), data-slot="file-upload-dropzone" (FileUploadDropzone), data-slot="file-upload-progress" (FileUploadProgress), data-slot="file-upload-errors" (FileUploadErrors), data-slot="file-upload-list" (FileUploadList), data-slot="file-upload-item-status" (FileUploadItem), data-slot="file-upload-preview" (FileUploadPreview), and data-disabled, data-dragging, data-kind, data-layout, data-status.
Provider strings: folderNotAllowed, fileTypeNotAllowed, fileTooLarge, tooManyFiles, uploadFailed, uploadComplete, fileAdded, filesAdded, dropFilesHere, browseFiles, chooseFiles, upload, cancel, removeItem (BooleanUIProvider's strings).
Theming
The drop zone is a dashed --border edge that turns --primary while files are dragged over it; its icon and second line are --muted-foreground. Rows are divided by --border; a file with no picture shows its type icon on --secondary. Progress bars are Progress at 4 px. The status uses the success and destructive tag tokens, and refusal messages --destructive-subtle, --destructive-border and --destructive-strong.
The tokens its classes read. Change their values in your theme, and it follows.
| Token | Used for |
|---|---|
--border | border |
--destructive-border | border |
--destructive-strong | text |
--destructive-subtle | background |
--foreground | text |
--muted-foreground | text |
--primary | border |
--ring | outline |
--secondary | background |
--secondary-foreground | text |