File Upload
Controlled file list with button or dropzone surfaces, pluggable upload lifecycle, and a headless hook.
File Upload manages a controlled FileUploadItem[] list with progress, retry, and remove. Use FileUpload when you want a label and helper text, or FileUploadControl for the field only. Surfaces: button, dropzone, picture-card, and avatar. Transport is injected via upload, with optional createXhrUploader for multipart XHR. List rows reuse File Type Icon.
Preview is a public contract only: preview toggles affordances, and onPreview(item, event) can take over the action (cancellable via event.preventDefault()). Without a handler, safe URLs open in a new tab. Built-in PDF/DOCX/image overlays live in Plus File Upload With Preview — public File Upload never imports a viewer engine.
Built-in copy uses useFileUploadI18n() under a tree with I18nProvider (registry item fui-i18n).
When To Use
- Collect attachments with a rich list (status, percent, retry) rather than a naked
<input type="file">. - You need a pluggable upload (
upload(file, ctx)) so the app owns the HTTP contract — f-ui does not ship a built-inactionURL. - Prefer a button + list, dropzone, picture-card, or avatar surface.
- Use
useFileUploadwhen you want the same state machine with a fully custom layout.
When To Hit The Upload API
Choosing files and sending bytes are different moments. Pick a mode and say so in copy (see DWP — upload vs submit).
| Mode | When bytes hit the API | How | Use when |
|---|---|---|---|
| Immediate | On select / drop | Pass upload; autoUpload defaults to true | Avatar, standalone attachment, early OCR OK |
| Deferred | Form Submit or an explicit Upload control | autoUpload={false}, then startUpload / host Submit | Simple forms with no mid-flow processing |
| Gate | A mid-flow action (Run match, Continue, …) | autoUpload={false}; host uploads in that action, then processes | Large forms where Add ≠ Upload ≠ business Submit |
Large multi-step forms: prefer Gate — keep items idle until the processing step or Save draft (both must upload before the draft can remember files); business Submit only references completed file ids.
// Gate sketch — select stays local; host uploads inside Run match / Save draft
<FormField
kind="file"
name="invoicePack"
componentProps={{
multiple: true,
autoUpload: false,
upload,
}}
/>Save Draft Vs Files (read this twice)
File Upload does not know what “Save draft” means. The host owns persistence. If you only JSON.stringify form values, File blobs are lost and idle selections never hit storage.
Industry pattern (Moodle draft file area, Open Forms temporary → claimed):
- Bytes land in temporary / draft storage (Immediate on select, or upload on Save / Gate action).
- Save draft claims those temps onto the draft entity (
fileId/ URL list). - Final submit promotes (or keeps) claimed files; TTL / cron deletes unclaimed orphans.
What each status means on Save draft
| Item status | Bytes on server? | Survives Save draft / reload? | What the host must do on Save draft |
|---|---|---|---|
idle | No — only in the browser | No | Upload first, then persist fileId/url, or tell the user the selection was discarded |
uploading | In flight | No until done | Wait / block Save (“wait for uploads”) |
error | Usually no | No | Fix/remove; do not claim |
done | Yes (your upload succeeded) | Yes, if you persist metadata | Save uid + name + url/fileId on the draft record |
Mode × Save draft (cheat sheet)
| Mode | Typical Save draft behavior |
|---|---|
| Immediate | Select already called upload. Save draft persists done ids only. Still run orphan TTL for abandoned drafts. |
| Deferred | Nothing on server yet. Save draft must upload then claim, or refuse Save until Upload. |
| Gate | Same as Deferred until Run match / Save draft uploads. Do not pretend idle rows are saved. |
Wrong vs right
// ❌ WRONG — “saved” but files vanish on refresh
onSaveDraft={(values) => localStorage.setItem("draft", JSON.stringify(values))}
// ✅ RIGHT — claim done files; upload idle first when using Gate/Deferred
async function onSaveDraft(values: { attachments: FileUploadItem[] }) {
const ready = await ensureUploaded(values.attachments); // host: startUpload / your API
await api.saveDraft({
...values,
attachmentIds: ready
.filter((i) => i.status === "done")
.map((i) => ({ id: i.uid, name: i.name, url: i.url })),
});
}Operator copy: if Save does not upload, say so (“Selections not uploaded yet won’t be in the draft”). If Save uploads, say so (“Saving uploads the invoice pack, then stores the draft”).
What The ✕ Does
Default ✕ is Remove from the list (label: Remove / 移除). It is not object-storage DELETE.
| Item status | ✕ does |
|---|---|
idle | Drop the selection from the list |
uploading | Abort the in-flight request, then drop from the list |
error | Drop the failed row |
done | Drop from the list only — server object remains unless the host deletes it |
Same stance as Vaadin Upload and Ant’s host-owned cleanup model.
Optional onRemove gate (Ant-aligned)
<FileUpload
upload={upload}
onRemove={async (item) => {
if (item.status === "done" && item.url) {
const ok = await deleteObject(item.url); // host API
return ok; // false → keep row
}
}}
/>- Awaited before the list mutates.
- Return
falseor reject → keep the item (DELETE failed / confirm cancelled). - Omit
onRemove→ list remove (+ abort) only.
Optional confirmRemove (done only)
Set confirmRemove to open a confirm dialog before removing done items. Idle / uploading / error never confirm. Compose with onRemove for storage DELETE — confirm runs first, then your gate.
<FileUpload
confirmRemove
upload={upload}
onRemove={async (item) => {
if (item.status === "done" && item.url) {
const ok = await deleteObject(item.url);
return ok;
}
}}
/>Features
| Area | Behavior |
|---|---|
| Value | Controlled value / defaultValue / onValueChange as FileUploadItem[] (empty = []) |
| Surfaces | button, dropzone, picture-card, avatar |
| Transport | Injected upload; auto-starts when provided (autoUpload default true) — see When To Hit The Upload API |
| Gates | accept, maxSize, maxCount, beforeUpload (client-only); onRemove (list remove gate); confirmRemove (confirm dialog for done only, default false) |
| List | File Type Icon, progress, remove, retry on error |
| Preview | preview (default true); onPreview(item, event) takes over; else safe URL → new tab |
| Disabled | Blocks upload/remove/retry; does not block preview of existing items (preview={false} to hide) |
| XHR helper | Optional createXhrUploader — demo-grade multipart progress, not part of the f-ui HTTP API |
| i18n | Component bundles (en, zh-CN) via useFileUploadI18n |
Installing
pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/file-upload.jsonnpx shadcn@latest add https://ui.isaacfei.com/r/file-upload.jsonyarn dlx shadcn@latest add https://ui.isaacfei.com/r/file-upload.jsonbun x shadcn@latest add https://ui.isaacfei.com/r/file-upload.jsonOr with a namespace: npx shadcn@latest add @f-ui/file-upload.
The CLI installs react-aria-components and lucide-react, pulls button, tooltip, and label from the default shadcn registry, and resolves file-type-icon and fui-i18n from the public f-ui registry.
Usage
import { FileUpload } from '@/components/f-ui/file-upload/file-upload';
import { createXhrUploader } from '@/components/f-ui/file-upload/create-xhr-uploader';
const upload = createXhrUploader({ action: '/api/upload' });
<FileUpload label="Attachments" multiple upload={upload} />Examples
Button + List
Default variant="button" with a simulated upload. Pick files to see progress, then remove or retry if needed.
Pick one or more files. Upload is simulated in this demo.
"use client";
import { FileUpload } from "@/components/f-ui/file-upload/file-upload";
import { createFakeUpload } from "./fake-upload";
export function FileUploadDemo() {
return (
<div className="max-w-md">
<FileUpload
description="Pick one or more files. Upload is simulated in this demo."
label="Attachments"
multiple
upload={createFakeUpload()}
/>
</div>
);
}Dropzone
variant="dropzone" defaults to an Ant Design Dragger-style surface: the entire zone opens the file dialog (activateOnClick, default true), with icon + title + optional hint. Title copy is “Click or drag…” — no nested browse link. Pass dropzoneHint for the in-zone secondary line; labeled description stays below the control. Replace the three layers with dropzoneContent. Call ref.open() or hook open() from an external button; set activateOnClick={false} when nesting a custom trigger to avoid double-open (Mantine / Ark).
Click or drag files to this area
Support for images and PDF. Mismatched types show a reject state on drag.
Outer field description (below the control).
"use client";
import { FileUpload } from "@/components/f-ui/file-upload/file-upload";
import { createFakeUpload } from "./fake-upload";
export function FileUploadDropzoneDemo() {
return (
<div className="max-w-lg">
<FileUpload
accept="image/*,.pdf"
description="Outer field description (below the control)."
dropzoneHint="Support for images and PDF. Mismatched types show a reject state on drag."
label="Dropzone"
multiple
upload={createFakeUpload({ durationMs: 900 })}
variant="dropzone"
/>
</div>
);
}Picture Card
variant="picture-card" — Ant Pictures Wall: square tiles with hover preview/remove, plus an add tile until maxCount.
Upload up to eight images. Hover a tile for preview or remove.
"use client";
import { FileUpload } from "@/components/f-ui/file-upload/file-upload";
import { createFakeUpload } from "./fake-upload";
export function FileUploadPictureCardDemo() {
return (
<FileUpload
accept="image/*"
description="Upload up to eight images. Hover a tile for preview or remove."
label="Pictures"
maxCount={8}
multiple
upload={createFakeUpload({ durationMs: 1000 })}
variant="picture-card"
/>
);
}Avatar
variant="avatar" — single circular tile. Defaults to maxCount={1} (replace). Prefer accept="image/…".
JPG/PNG only. Defaults to a single replaceable image.
"use client";
import { FileUpload } from "@/components/f-ui/file-upload/file-upload";
import { createFakeUpload } from "./fake-upload";
export function FileUploadAvatarDemo() {
return (
<FileUpload
accept="image/png,image/jpeg"
description="JPG/PNG only. Defaults to a single replaceable image."
label="Avatar"
upload={createFakeUpload({ durationMs: 900 })}
variant="avatar"
/>
);
}Controlled File List
Drive the list with value / onValueChange. Seed with existing done items (for example hydrated from your API).
items: 1 · quarterly-report.pdf(done)
"use client";
import { useState } from "react";
import { FileUpload } from "@/components/f-ui/file-upload/file-upload";
import type { FileUploadItem } from "@/components/f-ui/file-upload/file-upload-types";
import { createFakeUpload } from "./fake-upload";
const initial: FileUploadItem[] = [
{
uid: "seed-1",
name: "quarterly-report.pdf",
size: 245_760,
type: "application/pdf",
status: "done",
url: "https://example.com/files/quarterly-report.pdf",
},
];
export function FileUploadControlledDemo() {
const [value, setValue] = useState<FileUploadItem[]>(initial);
return (
<div className="max-w-md space-y-3">
<FileUpload
label="Controlled list"
multiple
onValueChange={setValue}
upload={createFakeUpload()}
value={value}
/>
<p className="text-muted-foreground text-xs">
items: {value.length} ·{" "}
{value.map((item) => `${item.name}(${item.status})`).join(", ") || "—"}
</p>
</div>
);
}Confirm Before Remove
confirmRemove asks before dropping a finished upload from the list. Idle selections still remove immediately. Pair with onRemove when the host must DELETE object storage.
- not-uploaded-yet.pdf
Done files ask before remove; idle does not
Try ✕ on the done file (confirm) vs the idle file (immediate).
"use client";
import { useState } from "react";
import { FileUpload } from "@/components/f-ui/file-upload/file-upload";
import type { FileUploadItem } from "@/components/f-ui/file-upload/file-upload-types";
import { createFakeUpload } from "./fake-upload";
const initial: FileUploadItem[] = [
{
uid: "done-1",
name: "quarterly-report.pdf",
size: 245_760,
type: "application/pdf",
status: "done",
url: "https://example.com/files/quarterly-report.pdf",
},
{
uid: "idle-1",
name: "not-uploaded-yet.pdf",
size: 12_000,
type: "application/pdf",
status: "idle",
},
];
export function FileUploadConfirmRemoveDemo() {
const [value, setValue] = useState<FileUploadItem[]>(initial);
return (
<div className="max-w-md space-y-3">
<FileUpload
confirmRemove
label="Attachments"
description="Done files ask before remove; idle does not"
multiple
value={value}
onValueChange={setValue}
upload={createFakeUpload()}
autoUpload={false}
onRemove={async (item) => {
if (item.status !== "done") return true;
await new Promise((r) => setTimeout(r, 400));
return true;
}}
/>
<p className="text-muted-foreground text-xs">
Try ✕ on the done file (confirm) vs the idle file (immediate).
</p>
</div>
);
}Custom Upload
Pass any upload(file, ctx) that calls onProgress, onSuccess, or onError and respects signal for abort.
Inject your own upload(file, ctx). This demo steps progress to 100%.
"use client";
import { FileUpload } from "@/components/f-ui/file-upload/file-upload";
import type { FileUploadRequest } from "@/components/f-ui/file-upload/file-upload-types";
const upload: FileUploadRequest = (file, { onProgress, onSuccess, onError, signal }) => {
return new Promise<void>((resolve) => {
let step = 0;
const id = window.setInterval(() => {
if (signal.aborted) {
window.clearInterval(id);
resolve();
return;
}
step += 1;
onProgress(step * 25);
if (step >= 4) {
window.clearInterval(id);
if (file.size > 2 * 1024 * 1024) {
onError({ message: "Demo rejects files over 2 MB" });
} else {
onSuccess({
url: `blob:demo/${encodeURIComponent(file.name)}`,
response: { ok: true },
});
}
resolve();
}
}, 200);
});
};
export function FileUploadCustomUploadDemo() {
return (
<div className="max-w-md">
<FileUpload
description="Inject your own upload(file, ctx). This demo steps progress to 100%."
label="Custom upload"
maxSize={2 * 1024 * 1024}
multiple
upload={upload}
/>
</div>
);
}XHR Uploader Helper
createXhrUploader posts multipart via XMLHttpRequest with upload progress. Wire action (and optional headers) to your endpoint; the live preview simulates progress so demos stay offline.
createXhrUploader posts multipart via XHR with progress. This preview simulates progress offline.
"use client";
import { FileUpload } from "@/components/f-ui/file-upload/file-upload";
import { createXhrUploader } from "@/components/f-ui/file-upload/create-xhr-uploader";
import { createFakeUpload } from "./fake-upload";
// Production: createXhrUploader({ action: "/api/upload", method: "POST" }).
// Docs preview stays offline unless NEXT_PUBLIC_DEMO_UPLOAD_URL is set.
const upload =
typeof process !== "undefined" && process.env.NEXT_PUBLIC_DEMO_UPLOAD_URL
? createXhrUploader({
action: process.env.NEXT_PUBLIC_DEMO_UPLOAD_URL,
method: "POST",
name: "file",
})
: createFakeUpload({ durationMs: 1000 });
export function FileUploadXhrDemo() {
return (
<div className="max-w-md">
<FileUpload
description="createXhrUploader posts multipart via XHR with progress. This preview simulates progress offline."
label="XHR uploader"
multiple
upload={upload}
/>
</div>
);
}Preview Contract (New Tab / Callback Takeover)
Without onPreview, clicking a previewable name opens a safe URL in a new tab. When onPreview is set, it fully owns the action — the browser fallback is suppressed. Use Plus File Upload With Preview when you want the Document Preview overlay instead. This demo also shows disabled still allowing preview.
No onPreview — clicking a safe URL opens a new tab.
Log: —
"use client";
import { useState } from "react";
import { FileUpload } from "@/components/f-ui/file-upload/file-upload";
import type {
FileUploadItem,
FileUploadPreviewEvent,
} from "@/components/f-ui/file-upload/file-upload-types";
import { Button } from "@/components/ui/button";
const SEED: FileUploadItem[] = [
{
uid: "contract-pdf",
name: "loan-application.pdf",
status: "done",
type: "application/pdf",
url: "/demo-documents/loan-application.pdf",
},
{
uid: "contract-docx",
name: "demo.docx",
status: "done",
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
url: "/demo-documents/demo.docx",
},
];
/**
* Public File Upload preview contract only — no Plus imports.
* PDF / DOCX fixtures pinned from Extend UI
* `bca60b9e204ef0db248466dfa44367ad4c23d404`.
*/
export function FileUploadPreviewContractDemo() {
const [mode, setMode] = useState<"fallback" | "takeover">("fallback");
const [log, setLog] = useState("—");
return (
<div className="max-w-md space-y-3">
<div className="flex flex-wrap gap-2">
<Button
onClick={() => {
setMode("fallback");
setLog("—");
}}
type="button"
variant={mode === "fallback" ? "default" : "outline"}
>
New-tab fallback
</Button>
<Button
onClick={() => {
setMode("takeover");
setLog("—");
}}
type="button"
variant={mode === "takeover" ? "default" : "outline"}
>
Callback takes over
</Button>
</div>
<p className="text-muted-foreground text-sm">
{mode === "fallback"
? "No onPreview — clicking a safe URL opens a new tab."
: "onPreview is set — the browser fallback is suppressed."}
</p>
<p className="text-sm">Log: {log}</p>
<FileUpload
disabled
label="Preview contract"
onPreview={
mode === "takeover"
? (item: FileUploadItem, _event: FileUploadPreviewEvent) => {
setLog(`Handler received ${item.name} (no new tab)`);
}
: undefined
}
value={SEED}
/>
</div>
);
}Headless Usage
useFileUpload is the view-model: items, addFiles, remove, retry, clear, open, fileInputProps, and drop feedback. The demo uses native <input type="file">, <ul> / <li>, and <button> — not FileUploadTrigger or FileUploadList.
value: —
"use client";
import type { FileUploadItem } from "@/components/f-ui/file-upload/file-upload-types";
import { useFileUpload } from "@/components/f-ui/file-upload/use-file-upload";
import { createFakeUpload } from "./fake-upload";
export function FileUploadHeadlessDemo() {
const s = useFileUpload({
multiple: true,
upload: createFakeUpload({ durationMs: 800 }),
});
const visible = s.items.filter((item) => item.status !== "removed");
return (
<div>
<label htmlFor="headless-file-upload">Files</label>
<div>
<input {...s.fileInputProps} id="headless-file-upload" />
<button
disabled={s.disabled}
onClick={() => s.open()}
type="button"
>
Browse
</button>
{visible.length > 0 ? (
<button onClick={() => s.clear()} type="button">
Clear
</button>
) : null}
</div>
{visible.length > 0 ? (
<ul>
{visible.map((item: FileUploadItem) => (
<li key={item.uid}>
{item.name} — {item.status}
{item.status === "uploading" && item.percent != null
? ` (${item.percent}%)`
: ""}
{item.status === "error" ? (
<button
onClick={() => {
void s.retry(item.uid);
}}
type="button"
>
Retry
</button>
) : null}
<button onClick={() => s.remove(item.uid)} type="button">
Remove
</button>
</li>
))}
</ul>
) : null}
<p>
value:{" "}
{visible.length > 0
? visible.map((item) => `${item.name}:${item.status}`).join(", ")
: "—"}
</p>
</div>
);
}Formily
File Upload is Formily-free on the public registry. For forms, use Plus Form (Formily) with FormField kind="file" — pass upload / accept / variant / maxCount / autoUpload / onRemove through componentProps. Formily's bare required mark does not mean “a finished upload”; pair required with validateFileRequired (idle / uploading / done count). The kind’s built-in rules do not treat “still uploading” as a field error (progress must not paint the FormField red). Use validateFileNotUploading in onFinish / submit guards only. For large forms with mid-flow OCR/match, use Gate timing (autoUpload={false}). Schema / table authoring uses f.file on the Field Types catalog.
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import type { FileUploadItem } from "@/components/f-ui/file-upload/file-upload-types";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { validateFileRequired } from "@/components/f-ui/formily/validators/file-upload";
import { Button } from "@/components/ui/button";
import { createFakeUpload } from "./fake-upload";
interface AttachmentValues {
attachments: FileUploadItem[];
}
const upload = createFakeUpload();
export function FileUploadFormFieldDemo() {
const form = useMemo(
() =>
createForm<AttachmentValues>({
initialValues: { attachments: [] },
}),
[],
);
const [submitted, setSubmitted] = useState<AttachmentValues | null>(null);
return (
<div className="w-full max-w-sm space-y-4">
<Form
form={form}
onSubmit={(values) => {
setSubmitted({ ...values });
toast.success("Submitted successfully");
}}
>
<FormField
name="attachments"
label="Attachments"
description="Required — upload at least one file. Progress is not an error; submit waits until uploads finish."
kind="file"
required
validator={[{ validator: validateFileRequired }]}
componentProps={{
multiple: true,
upload,
}}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
{submitted ? (
<pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
<code>{JSON.stringify(submitted, null, 2)}</code>
</pre>
) : null}
</div>
);
}Composition
FileUpload (optional label + description shell)
└── FileUploadControl
├── hidden <input type="file"> (shared; open() clicks it)
├── FileUploadTrigger + FileUploadList (button)
├── FileUploadDropzone + FileUploadList (dropzone)
├── FileUploadPictureCard (picture-card tiles; no text list)
└── FileUploadAvatar (avatar tile; no text list)Edge Cases & Security
Client gates (accept, maxSize, maxCount, beforeUpload) improve UX only — they are not a security boundary. Always validate type, size, and content on the server. Rejected drops do not enter the list; beforeUpload may return false, "LIST_IGNORE", a replacement File, or a Promise of those. Client gates (accept, maxSize, maxCount) keep rejected files out of the list and show reasons in an inline alert under the control. Drag-over reject chrome on the dropzone (data-reject) is separate and transient.
| Threat | Default |
|---|---|
| Oversized / wrong MIME client-side | Blocked by maxSize / accept when set; still re-check on the server |
| Malicious file content | Not inspected by the control — scan and authorize in your upload handler |
| Half-finished uploads | Items stay uploading until onSuccess / onError; abort on remove |
Orphan done objects | ✕ does not DELETE storage — use onRemove (or TTL / draft cleanup) |
| Unsafe preview URLs | javascript: / arbitrary data: rejected; no new-tab fallback |
| Preview handler throws | Fallback is not run; the error propagates to the app |
Long Filenames
List rows keep the extension visible while the stem truncates, and expose the full name via title (hover / assistive tech). Picture-card non-image fallbacks use line-clamp with the same full title. Narrow the demo column to see overflow stay inside the layout.
- failed-upload-with-verbose-path-failed-upload-with-verbose-path-failed-upload-with-verbose-path-.xlsxUpload timed out
Narrow column — stem truncates; extension and title stay readable.
- picture-card-fallback-name-picture-card-fallback-name-picture-card-fallback-name-picture-card-fallback-name-.docx
Non-image fallback uses line-clamp + full title.
"use client";
import { useState } from "react";
import { FileUpload } from "@/components/f-ui/file-upload/file-upload";
import type { FileUploadItem } from "@/components/f-ui/file-upload/file-upload-types";
import { createFakeUpload } from "./fake-upload";
const longPdf = `${"quarterly-financial-summary-with-extra-context-".repeat(4)}.pdf`;
const longArchive = `${"backup-archive-snapshot-".repeat(5)}.tar.gz`;
const longDocx = `${"picture-card-fallback-name-".repeat(4)}.docx`;
const listSeed: FileUploadItem[] = [
{
uid: "long-pdf",
name: longPdf,
size: 1_048_576,
type: "application/pdf",
status: "done",
url: "https://example.com/files/long.pdf",
},
{
uid: "long-tar-gz",
name: longArchive,
size: 4_194_304,
type: "application/gzip",
status: "done",
url: "https://example.com/files/long.tar.gz",
},
{
uid: "long-error",
name: `${"failed-upload-with-verbose-path-".repeat(3)}.xlsx`,
size: 51_200,
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
status: "error",
error: { message: "Upload timed out" },
},
];
const pictureSeed: FileUploadItem[] = [
{
uid: "pic-long",
name: longDocx,
size: 204_800,
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
status: "done",
},
];
export function FileUploadLongFilenameDemo() {
const [listValue, setListValue] = useState<FileUploadItem[]>(listSeed);
const [pictureValue, setPictureValue] =
useState<FileUploadItem[]>(pictureSeed);
return (
<div className="max-w-sm space-y-6">
<FileUpload
description="Narrow column — stem truncates; extension and title stay readable."
label="List row"
multiple
onValueChange={setListValue}
upload={createFakeUpload()}
value={listValue}
/>
<FileUpload
description="Non-image fallback uses line-clamp + full title."
label="Picture card"
multiple
onValueChange={setPictureValue}
upload={createFakeUpload()}
value={pictureValue}
variant="picture-card"
/>
</div>
);
}API Reference
Props
| Prop | Type | Default |
|---|---|---|
label | ReactNode | — |
description | ReactNode | — |
value | FileUploadItem[] | — |
defaultValue | FileUploadItem[] | [] |
onValueChange | (items: FileUploadItem[]) => void | — |
upload | (file, ctx) => void | Promise<void> | — |
beforeUpload | (file, fileList) => … | — |
autoUpload | boolean | true when upload is set |
onRemove | (item) => boolean | void | Promise<…> | — gate before list remove; not storage DELETE |
confirmRemove | boolean | false — confirm dialog before remove for done only |
accept | string | — |
multiple | boolean | false |
maxCount | number | — (1 replaces; avatar defaults to 1) |
maxSize | number | — (bytes) |
disabled | boolean | false |
variant | "button" | "dropzone" | "picture-card" | "avatar" | "button" |
activateOnClick | boolean | true (dropzone: whole area opens dialog) |
dropzoneHint | ReactNode | — (in-zone secondary line; dropzone only) |
dropzoneContent | ReactNode | — (replaces default icon + title + hint) |
preview | boolean | true — hide all non-mutating preview affordances when false |
onPreview | (item: FileUploadItem, event: FileUploadPreviewEvent) => void | — presence takes over; suppresses new-tab fallback |
onReject | (rejections: FileUploadRejection[]) => void | — |
surface | "default" | "compact" | "tableCell" | "default" |
className | string | — |
classNames | Partial<Record<FileUploadSlot, string>> | — |
t | FileUploadTranslateFn | — |
locale | string | — |
FileUploadPreviewEvent: { defaultPrevented; preventDefault() } — Plus recipes (and custom handlers) use preventDefault() to cancel their default overlay after running app logic.
FileUploadControl / FileUpload accept a ref with { open() }. useFileUpload returns the same open plus fileInputProps for a shared hidden input.
FileUploadControl accepts the same field props without label / description.
createXhrUploader({ action, method?, headers?, data?, name?, withCredentials? }) returns an upload function. Convenience only — not part of the f-ui HTTP contract.
Slots
| Slot | Applied to |
|---|---|
root | Control root wrapper |
trigger | Browse / trigger button |
dropzone | Dropzone surface |
dropzoneIcon | Default Inbox icon |
dropzoneTitle | Primary dropzone title |
dropzoneHint | Secondary in-zone hint |
pictureCard | Picture-card grid |
pictureCardItem | Each picture tile |
pictureCardAdd | Add (+) tile |
avatar | Avatar tile |
list | File list |
item | Each list row |
progress | Progress bar track |
rejection | Inline rejection alert |
Hook
| Function | useFileUpload(options: UseFileUploadOptions) |
| Options | Same shape as FileUploadFieldProps. |
| Return | UseFileUploadReturn: items, addFiles, remove, retry, startUpload, clear, open, fileInputProps, activateOnClick, disabled, accept, multiple, variant, dropFeedback / setDropFeedback, drag helpers. |
Types
file-upload-types.ts: FileUploadItem, FileUploadStatus, FileUploadPreviewEvent, FileUploadPreviewKind, FileUploadFieldProps, FileUploadSlot, FileUploadVariant, FileUploadRequest. file-upload.tsx re-exports the hook, i18n helpers, and common types.
For out-of-the-box Document Preview overlays, install Plus File Upload With Preview.