File upload
A labelled upload field: the dropzone, the files it took with their progress, and a way to remove each.
A client component over FileDropzone: the dropzone takes and validates the files, and its rejections are its own alert list; the field lists what is held — name, size, a progress bar while uploading names the file — with a remove button named for each. The files and their progress are yours to hold: pass value and uploading, and drop a file's entry from uploading when its upload finishes. Removing a file retires the rejection list, as it does in the dropzone's own list. An upload that fails keeps its row, saying so and why, with a Retry named for the file (failed, onRetry); a problem with the field as a whole — a missing receipt, a shared size limit passed — goes in error, under the drop area, announced and read with it from then on.
Install
npx shadcn@latest add @vibra/file-uploadNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { FileUpload } from "@/components/ui/file-upload"
/** A stand-in PDF of a given size; nothing leaves the page. */
function samplePdf(name: string, size: number): File {
return new File([new Uint8Array(size)], name, { type: "application/pdf" })
}
/** How far a stand-in upload moves on each tick, in percent. */
const STEP = 5
export default function FileUploadDemo() {
const [files, setFiles] = React.useState<File[]>(() => [samplePdf("msa-northwind-signed.pdf", 1_160_000)])
const [uploading, setUploading] = React.useState<Record<string, number>>({ "msa-northwind-signed.pdf": 30 })
// Stands in for the upload itself: every file with an entry moves on each
// tick, and leaves the map — which is what "done" means — at 100.
React.useEffect(() => {
if (Object.keys(uploading).length === 0) return
const timer = window.setTimeout(() => {
setUploading((current) => {
const next: Record<string, number> = {}
for (const [name, percent] of Object.entries(current)) {
if (percent + STEP < 100) next[name] = percent + STEP
}
return next
})
}, 700)
return () => window.clearTimeout(timer)
}, [uploading])
return (
<FileUpload
className="max-w-md"
label="Contract documents"
accept={[".pdf"]}
maxSize={10 * 1024 * 1024}
maxFiles={5}
value={files}
uploading={uploading}
onFilesAccepted={(accepted) => {
setFiles((current) => [...current, ...accepted])
setUploading((current) => ({
...current,
...Object.fromEntries(accepted.map((file) => [file.name, 0])),
}))
}}
onRemove={(file) => {
setFiles((current) => current.filter((held) => held !== file))
setUploading((current) => {
const next = { ...current }
delete next[file.name]
return next
})
}}
/>
)
}A CSV import, one file
One file at a time, read as it lands: the line under the field says how many rows it holds and what comes next.
"use client"
import * as React from "react"
import { FileUpload } from "@/components/ui/file-upload"
/** Counts what a real import would: the rows under the header, and the header's columns. */
async function measure(file: File) {
const lines = (await file.text()).split(/\r?\n/).filter((line) => line.trim() !== "")
const separator = file.name.toLowerCase().endsWith(".tsv") ? "\t" : ","
return { rows: Math.max(0, lines.length - 1), columns: lines[0]?.split(separator).length ?? 0 }
}
// One file at a time: a second drop is turned away until the first is
// removed. The file is read as it lands, and the line under the field says
// what it holds and what comes next — the import itself waits for that step.
export default function FileUploadCsv() {
const [files, setFiles] = React.useState<File[]>([])
const [summary, setSummary] = React.useState<{ rows: number; columns: number } | null>(null)
return (
<div className="flex w-full max-w-md flex-col gap-3">
<FileUpload
label="Import customers"
description={
<>
A CSV or TSV with a header row.{" "}
<a href="/templates/customers.csv" className="text-foreground underline underline-offset-4 focus-ring">
Download the template
</a>
</>
}
accept={[".csv", ".tsv"]}
maxSize={50 * 1024 * 1024}
maxFiles={1}
multiple={false}
value={files}
onFilesAccepted={(accepted) => {
setFiles(accepted)
setSummary(null)
void measure(accepted[0]).then(setSummary)
}}
onRemove={() => {
setFiles([])
setSummary(null)
}}
/>
<p role="status" aria-live="polite" className="text-sm text-muted-foreground">
{summary
? `${summary.rows.toLocaleString("en-US")} customers in ${summary.columns} columns. Next, match the columns to Northwind's fields.`
: files.length > 0
? "Reading the file…"
: null}
</p>
</div>
)
}Several, counted against a cap
A count and a size under the list, and one button that clears them all; a seventh receipt is turned away by name.
"use client"
import * as React from "react"
import { formatBytes } from "@/lib/format"
import { FileUpload } from "@/components/ui/file-upload"
const MAX_FILES = 6
/** A stand-in file of a given size; nothing leaves the page. */
const sample = (name: string, size: number, type: string) => new File([new Uint8Array(size)], name, { type })
// Several files under one cap, counted in the line under the label as they
// arrive, with their size. The drop area knows the cap too: a seventh
// receipt is turned away by name, with the limit it passed.
export default function FileUploadReceipts() {
const [files, setFiles] = React.useState(() => [sample("hotel-dublin-folio.pdf", 482_000, "application/pdf")])
const total = files.reduce((sum, file) => sum + file.size, 0)
return (
<FileUpload
className="max-w-md"
label="Receipts for trip TR-2207"
description={
files.length === 0
? `Up to ${MAX_FILES} receipts. None yet.`
: `Up to ${MAX_FILES} receipts. ${files.length} so far, ${formatBytes(total, 1)}.`
}
accept={[".jpg", ".png", ".pdf"]}
maxSize={5 * 1024 * 1024}
maxFiles={MAX_FILES}
value={files}
onFilesAccepted={(accepted) => setFiles((current) => [...current, ...accepted])}
onRemove={(file) => setFiles((current) => current.filter((held) => held !== file))}
/>
)
}The rules, said first
Formats, size and count in the drop area's words, the recording advice beside them; a file that breaks a rule is refused with the rule.
"use client"
import * as React from "react"
import { FileUpload } from "@/components/ui/file-upload"
/** A stand-in file of a given size; nothing leaves the page. */
const sample = (name: string, size: number, type: string) => new File([new Uint8Array(size)], name, { type })
// The rules said before the drop — two formats, a size, a count — in the
// words the drop area writes from accept, maxSize and maxFiles, with the
// recording advice beside them. A file that breaks one is turned away by
// name, with the rule it broke.
export default function FileUploadVoice() {
const [files, setFiles] = React.useState(() => [sample("hiro-sample-quiet-room.wav", 4_200_000, "audio/wav")])
return (
<FileUpload
className="max-w-md"
label="Voice samples for the support agent"
description="Record in a quiet room, one to five minutes each."
accept={[".mp3", ".wav"]}
maxSize={10 * 1024 * 1024}
maxFiles={3}
value={files}
onFilesAccepted={(accepted) => setFiles((current) => [...current, ...accepted])}
onRemove={(file) => setFiles((current) => current.filter((held) => held !== file))}
/>
)
}Replacing one that's there
The current logo above the field, one file to replace it, and a status line when the new one is in.
"use client"
import * as React from "react"
import { FileUpload } from "@/components/ui/file-upload"
import { LogoMark } from "@/components/ui/logo-mark"
// Replacing one thing that is already there: the current logo sits above the
// field with where it shows, one file replaces it, and the line beside the
// logo says when the new one is in.
export default function FileUploadLogo() {
const [files, setFiles] = React.useState<File[]>([])
const [uploading, setUploading] = React.useState<Record<string, number>>({})
const done = files.length > 0 && Object.keys(uploading).length === 0
// Stands in for the upload: each tick moves it on; it leaves the map at 100.
React.useEffect(() => {
if (Object.keys(uploading).length === 0) return
const timer = window.setTimeout(() => {
setUploading((current) =>
Object.fromEntries(Object.entries(current).flatMap(([name, percent]) => (percent + 25 < 100 ? [[name, percent + 25]] : [])))
)
}, 400)
return () => window.clearTimeout(timer)
}, [uploading])
return (
<div className="flex w-full max-w-md flex-col gap-3">
<div className="flex items-center justify-between gap-4 rounded-lg bg-surface px-3 py-2 ring-1 ring-border">
<LogoMark name="Northwind" size="sm" />
<span role="status" aria-live="polite" className="text-xs text-muted-foreground">
{done ? "Replaced from the next page load" : "In the sidebar and on every invoice"}
</span>
</div>
<FileUpload
label="Replace the workspace logo"
description="An SVG, or a PNG at least 256 pixels square."
accept={[".svg", ".png"]}
maxSize={1024 * 1024}
maxFiles={1}
multiple={false}
value={files}
uploading={uploading}
onFilesAccepted={(accepted) => {
setFiles(accepted)
setUploading(Object.fromEntries(accepted.map((file) => [file.name, 0])))
}}
onRemove={() => {
setFiles([])
setUploading({})
}}
/>
</div>
)
}Chosen, then sent together
In a dialog: files wait in the list until the upload button, which counts them, starts every bar at once.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { FileUpload } from "@/components/ui/file-upload"
// Files chosen first and sent together: they wait in the list, removable,
// until the upload button starts every bar at once. The button counts what
// it will send, and the status line says when they have all arrived.
export default function FileUploadStaged() {
const [files, setFiles] = React.useState<File[]>([])
const [uploading, setUploading] = React.useState<Record<string, number>>({})
const [sent, setSent] = React.useState(0)
const busy = Object.keys(uploading).length > 0
// Stands in for the uploads: each tick moves every bar on; a file leaves
// the map at 100, and when the last one does, the batch is in.
React.useEffect(() => {
if (!busy) return
const timer = window.setTimeout(() => {
const next = Object.fromEntries(
Object.entries(uploading).flatMap(([name, percent]) => (percent + 25 < 100 ? [[name, percent + 25]] : []))
)
setUploading(next)
if (Object.keys(next).length === 0) {
setSent(files.length)
setFiles([])
}
}, 300)
return () => window.clearTimeout(timer)
}, [busy, uploading, files.length])
return (
<Dialog>
<DialogTrigger render={<Button variant="outline" />}>Add to the Q3 board pack</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add to the Q3 board pack</DialogTitle>
<DialogDescription>Files wait here until you upload them. Board members see each one as it lands.</DialogDescription>
</DialogHeader>
<FileUpload
label="Files"
accept={[".pdf", ".pptx", ".xlsx"]}
maxSize={25 * 1024 * 1024}
value={files}
uploading={uploading}
disabled={busy}
onFilesAccepted={(accepted) => {
setSent(0)
setFiles((current) => [...current, ...accepted])
}}
onRemove={(file) => setFiles((current) => current.filter((held) => held !== file))}
/>
<p role="status" aria-live="polite" className="text-xs text-muted-foreground">
{sent > 0 ? `${sent} ${sent === 1 ? "file" : "files"} added to the board pack.` : null}
</p>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>Close</DialogClose>
<Button
disabled={files.length === 0 || busy}
focusableWhenDisabled
onClick={() => setUploading(Object.fromEntries(files.map((file) => [file.name, 0])))}
>
{busy ? "Uploading…" : files.length > 0 ? `Upload ${files.length} ${files.length === 1 ? "file" : "files"}` : "Upload"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}A failed upload, with retry
The row keeps the file and says why it failed; Retry, named for the file, runs its bar again.
"use client"
import * as React from "react"
import { FileUpload } from "@/components/ui/file-upload"
/** A stand-in file of a given size; nothing leaves the page. */
const sample = (name: string, size: number, type: string) => new File([new Uint8Array(size)], name, { type })
const FILES = [sample("q3-board-deck.pdf", 3_400_000, "application/pdf")]
// An upload that didn't make it stays in the list with the reason in words
// and a Retry named for the file; retrying runs its bar again from nothing.
export default function FileUploadFailed() {
const [files, setFiles] = React.useState(FILES)
const [uploading, setUploading] = React.useState<Record<string, number>>({})
const [failed, setFailed] = React.useState<Record<string, string>>({
"q3-board-deck.pdf": "The connection dropped at 64%.",
})
// Stands in for the retried upload: each tick moves it on, and it leaves
// the map — which is what "done" means — at 100.
React.useEffect(() => {
if (Object.keys(uploading).length === 0) return
const timer = window.setTimeout(() => {
setUploading((current) =>
Object.fromEntries(Object.entries(current).flatMap(([name, percent]) => (percent + 25 < 100 ? [[name, percent + 25]] : [])))
)
}, 400)
return () => window.clearTimeout(timer)
}, [uploading])
const without = <T,>(map: Record<string, T>, name: string) =>
Object.fromEntries(Object.entries(map).filter(([key]) => key !== name))
return (
<FileUpload
className="max-w-md"
label="Board pack for 14 October"
accept={[".pdf", ".xlsx", ".pptx"]}
maxSize={25 * 1024 * 1024}
value={files}
uploading={uploading}
failed={failed}
onRetry={(file) => {
setFailed((current) => without(current, file.name))
setUploading((current) => ({ ...current, [file.name]: 0 }))
}}
onFilesAccepted={(accepted) => {
setFiles((current) => [...current, ...accepted])
setUploading((current) => ({ ...current, ...Object.fromEntries(accepted.map((file) => [file.name, 0])) }))
}}
onRemove={(file) => {
setFiles((current) => current.filter((held) => held !== file))
setFailed((current) => without(current, file.name))
setUploading((current) => without(current, file.name))
}}
/>
)
}Required, with an error
Sending without the receipt puts the error under the drop area, announced and read with the field.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { FileUpload } from "@/components/ui/file-upload"
// A required file in a form. Sending without it puts the error under the
// drop area, where it is announced and read with the field from then on;
// attaching a receipt clears it.
export default function FileUploadRequired() {
const [files, setFiles] = React.useState<File[]>([])
const [tried, setTried] = React.useState(false)
const missing = tried && files.length === 0
return (
<form
noValidate
className="flex w-full max-w-md flex-col gap-3"
onSubmit={(event) => {
event.preventDefault()
setTried(true)
}}
>
<FileUpload
label="Receipt for the $184.20 team dinner"
description="A photo or a PDF of it."
accept={[".jpg", ".png", ".pdf"]}
maxSize={5 * 1024 * 1024}
maxFiles={1}
multiple={false}
value={files}
error={missing ? "Attach the receipt: claims over $25 need one." : undefined}
onFilesAccepted={setFiles}
onRemove={() => setFiles([])}
/>
<div className="flex items-center justify-between gap-3">
<p role="status" aria-live="polite" className="text-xs text-muted-foreground">
{tried && files.length > 0 ? "Claim EXP-3318 sent to Petra Lindqvist." : null}
</p>
<Button type="submit" size="sm">
Send claim
</Button>
</div>
</form>
)
}One limit across every file
A shared size limit the drop area can't know: a file that would pass it is refused through error, and a meter shows the room left.
"use client"
import * as React from "react"
import { formatBytes } from "@/lib/format"
import { FileUpload } from "@/components/ui/file-upload"
const LIMIT = 10 * 1024 * 1024
/** A stand-in file of a given size; nothing leaves the page. */
const sample = (name: string, size: number, type: string) => new File([new Uint8Array(size)], name, { type })
// One limit shared by every file on a support ticket, where the drop area
// only knows a limit per file. The line under the label keeps the room left;
// a file that would pass the shared limit is refused through the field's
// error, which names it and says what to do instead.
export default function FileUploadBudget() {
const [files, setFiles] = React.useState(() => [sample("error-console.png", 7_150_000, "image/png")])
const [refused, setRefused] = React.useState<string[]>([])
const used = files.reduce((sum, file) => sum + file.size, 0)
return (
<FileUpload
className="max-w-md"
label="Attachments on ticket #4821"
description={`${formatBytes(LIMIT - used, 1)} of ${formatBytes(LIMIT, 0)} left on this ticket.`}
accept={[".png", ".mp4", ".log"]}
maxSize={LIMIT}
value={files}
error={
refused.length > 0
? `${refused.join(", ")} would take the ticket past ${formatBytes(LIMIT, 0)}. Remove a file, or share a link instead.`
: undefined
}
onFilesAccepted={(accepted) => {
const kept: File[] = []
const turned: string[] = []
let total = used
for (const file of accepted) {
if (total + file.size <= LIMIT) {
kept.push(file)
total += file.size
} else {
turned.push(file.name)
}
}
setFiles((current) => [...current, ...kept])
setRefused(turned)
}}
onRemove={(file) => {
setFiles((current) => current.filter((held) => held !== file))
setRefused([])
}}
/>
)
}Locked, with the reason
A sent invoice's attachments: read-only without onRemove, the drop area disabled, and the line under the label says why.
"use client"
import { FileUpload } from "@/components/ui/file-upload"
/** A stand-in file of a given size; nothing leaves the page. */
const sample = (name: string, size: number, type: string) => new File([new Uint8Array(size)], name, { type })
const SENT = [sample("timesheet-august.xlsx", 96_000, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")]
// A sent invoice keeps its attachments as they went out. With no onRemove the
// list is read-only, disabled takes the drop area out of reach, and the line
// under the label says why and what to do instead.
export default function FileUploadLocked() {
return (
<FileUpload
className="max-w-md"
label="Attachments on INV-2041"
description="Sent to Keystone on 2 September. Issue a credit note to change what went with it."
value={SENT}
disabled
onFilesAccepted={() => {}}
/>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | React.ReactNode | — | The field's name; it names the drop area and the file list. |
| description | React.ReactNode | — | A line under the label, read with the drop area as its description. |
| value | File[] | [] | The files held; they count towards maxFiles, so the cap spans more than one drop. |
| uploading | Record<string, number> | — | Upload progress by file name, 0–100. A file with an entry shows a bar at that percent; a file without one is done. |
| onFilesAccepted | (files: File[]) => void | — | Called with the files that passed the dropzone's rules; add them to value. |
| onFilesRejected | (errors: DropzoneError[]) => void | — | Called with the files turned down, and why; the field already shows them. |
| onRemove | (file: File) => void | — | Adds a "Remove <name>" button to each file; without it the list is read-only. |
| accept / maxSize / maxFiles / multiple | FileDropzoneProps | — | Passed to the dropzone, which writes its hint from them. |
| failed | Record<string, string> | — | Uploads that failed, by file name, with the reason in the reader's words. The row says so until the file is retried (an uploading entry wins) or removed. |
| onRetry | (file: File) => void | — | Adds a "Retry <name>" button to each failed file. |
| error | React.ReactNode | — | A problem with the field as a whole, shown under the drop area, announced, and read with the drop area after. |
| disabled | boolean | false | Disables the drop area and the remove buttons together. |
Dependencies
Source
"use client"
import * as React from "react"
import { CircleAlertIcon, FileTextIcon, RotateCwIcon, XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatBytes } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { FileDropzone, type FileDropzoneProps } from "@/components/ui/file-dropzone"
import { Progress } from "@/components/ui/progress"
export type FileUploadProps = Omit<React.ComponentProps<"div">, "children"> &
Pick<
FileDropzoneProps,
"onFilesAccepted" | "onFilesRejected" | "accept" | "maxSize" | "maxFiles" | "multiple"
> & {
/** The field's name; it names the drop area and the file list. */
label: React.ReactNode
/** A line under the label, read with the drop area as its description. */
description?: React.ReactNode
/** The files held. They count towards maxFiles, so the cap spans more than one drop. */
value?: File[]
/** Adds a "Remove <name>" button to each file; without it the list is read-only. */
onRemove?: (file: File) => void
/**
* Upload progress by file name, 0–100. A file with an entry shows a bar at
* that percent; a file without one is done.
*/
uploading?: Record<string, number>
/**
* Uploads that failed, by file name, with the reason in the reader's
* words. A failed file says so in its row until it is retried (given an
* uploading entry) or removed.
*/
failed?: Record<string, string>
/** Adds a "Retry <name>" button to each failed file. */
onRetry?: (file: File) => void
/**
* A problem with the field as a whole — "Attach the receipt" — shown
* under the drop area, announced, and read with the drop area after.
*/
error?: React.ReactNode
/** Disables the drop area and the remove buttons together. */
disabled?: boolean
}
/** Kept on the bar whatever the caller sends: a percent is never below 0 or past 100. */
function clampPercent(percent: number): number {
return Math.min(100, Math.max(0, percent))
}
/**
* A labelled upload field over FileDropzone: the dropzone takes and validates
* the files and reports what it turned down; the field lists what is held,
* each with its size, a progress bar while it uploads, and a remove button.
*/
function FileUpload({
label,
description,
value = [],
onRemove,
uploading,
failed,
onRetry,
error,
onFilesAccepted,
onFilesRejected,
accept,
maxSize,
maxFiles,
multiple,
disabled = false,
className,
...props
}: FileUploadProps) {
const labelId = React.useId()
const descriptionId = React.useId()
const errorId = React.useId()
const describedBy = [description ? descriptionId : null, error ? errorId : null].filter(Boolean).join(" ")
// The dropzone retires its rejection list when a file leaves its own list.
// This field draws that list instead, so a removal remounts the dropzone,
// which clears what it last turned down in the same way.
const [round, setRound] = React.useState(0)
return (
<div
data-slot="file-upload"
data-disabled={disabled || undefined}
data-invalid={error ? true : undefined}
className={cn("flex w-full flex-col gap-2", className)}
{...props}
>
<div className="flex flex-col gap-1">
<span id={labelId} data-slot="file-upload-label" className="text-sm font-medium">
{label}
</span>
{description ? (
<p id={descriptionId} data-slot="file-upload-description" className="text-sm text-muted-foreground">
{description}
</p>
) : null}
</div>
<FileDropzone
key={round}
aria-labelledby={labelId}
aria-describedby={describedBy || undefined}
// An error draws the drop area's edge in the danger tone too.
className={error ? "[&_[data-slot=file-dropzone-panel]]:border-danger" : undefined}
value={value}
listFiles={false}
onFilesAccepted={onFilesAccepted}
onFilesRejected={onFilesRejected}
accept={accept}
maxSize={maxSize}
maxFiles={maxFiles}
multiple={multiple}
disabled={disabled}
/>
{error ? (
<p id={errorId} role="alert" data-slot="file-upload-error" className="text-sm text-danger">
{error}
</p>
) : null}
{value.length > 0 ? (
<ul data-slot="file-upload-files" aria-labelledby={labelId} className="flex flex-col gap-1.5">
{value.map((file) => {
const progress = uploading?.[file.name]
const percent = progress === undefined ? undefined : clampPercent(progress)
// An upload under way wins: a retry shows its bar, not the old failure.
const reason = percent === undefined ? failed?.[file.name] : undefined
const state = percent !== undefined ? "uploading" : reason !== undefined ? "failed" : "done"
return (
<li
key={`${file.name}-${file.size}-${file.lastModified}`}
data-slot="file-upload-file"
data-state={state}
className="flex flex-col gap-2 rounded-lg bg-card px-2.5 py-2 ring-1 ring-border"
>
<div className="flex min-w-0 items-center gap-2">
{reason === undefined ? (
<FileTextIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
) : (
<CircleAlertIcon aria-hidden="true" className="size-4 shrink-0 text-danger" />
)}
<span className="min-w-0 flex-1 truncate text-sm">{file.name}</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{formatBytes(file.size)}
</span>
{reason !== undefined && onRetry ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`Retry ${file.name}`}
disabled={disabled}
onClick={() => onRetry(file)}
>
<RotateCwIcon aria-hidden="true" />
</Button>
) : null}
{onRemove ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`Remove ${file.name}`}
disabled={disabled}
onClick={() => {
setRound((current) => current + 1)
onRemove(file)
}}
>
<XIcon aria-hidden="true" />
</Button>
) : null}
</div>
{reason === undefined ? null : (
<p role="alert" className="text-xs text-danger">
Upload failed. {reason}
</p>
)}
{percent === undefined ? null : (
<div className="flex items-center gap-2">
<Progress value={percent} aria-label={`Uploading ${file.name}`} className="flex-1" />
<span
aria-hidden="true"
className="w-9 shrink-0 text-end text-xs text-muted-foreground tabular-nums"
>
{Math.round(percent)}%
</span>
</div>
)}
</li>
)
})}
</ul>
) : null}
</div>
)
}
export { FileUpload }