f-ui
Components

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-in action URL.
  • Prefer a button + list, dropzone, picture-card, or avatar surface.
  • Use useFileUpload when 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).

ModeWhen bytes hit the APIHowUse when
ImmediateOn select / dropPass upload; autoUpload defaults to trueAvatar, standalone attachment, early OCR OK
DeferredForm Submit or an explicit Upload controlautoUpload={false}, then startUpload / host SubmitSimple forms with no mid-flow processing
GateA mid-flow action (Run match, Continue, …)autoUpload={false}; host uploads in that action, then processesLarge 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):

  1. Bytes land in temporary / draft storage (Immediate on select, or upload on Save / Gate action).
  2. Save draft claims those temps onto the draft entity (fileId / URL list).
  3. Final submit promotes (or keeps) claimed files; TTL / cron deletes unclaimed orphans.

What each status means on Save draft

Item statusBytes on server?Survives Save draft / reload?What the host must do on Save draft
idleNo — only in the browserNoUpload first, then persist fileId/url, or tell the user the selection was discarded
uploadingIn flightNo until doneWait / block Save (“wait for uploads”)
errorUsually noNoFix/remove; do not claim
doneYes (your upload succeeded)Yes, if you persist metadataSave uid + name + url/fileId on the draft record

Mode × Save draft (cheat sheet)

ModeTypical Save draft behavior
ImmediateSelect already called upload. Save draft persists done ids only. Still run orphan TTL for abandoned drafts.
DeferredNothing on server yet. Save draft must upload then claim, or refuse Save until Upload.
GateSame 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
idleDrop the selection from the list
uploadingAbort the in-flight request, then drop from the list
errorDrop the failed row
doneDrop 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 false or 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

AreaBehavior
ValueControlled value / defaultValue / onValueChange as FileUploadItem[] (empty = [])
Surfacesbutton, dropzone, picture-card, avatar
TransportInjected upload; auto-starts when provided (autoUpload default true) — see When To Hit The Upload API
Gatesaccept, maxSize, maxCount, beforeUpload (client-only); onRemove (list remove gate); confirmRemove (confirm dialog for done only, default false)
ListFile Type Icon, progress, remove, retry on error
Previewpreview (default true); onPreview(item, event) takes over; else safe URL → new tab
DisabledBlocks upload/remove/retry; does not block preview of existing items (preview={false} to hide)
XHR helperOptional createXhrUploader — demo-grade multipart progress, not part of the f-ui HTTP API
i18nComponent bundles (en, zh-CN) via useFileUploadI18n

Installing

pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/file-upload.json
npx shadcn@latest add https://ui.isaacfei.com/r/file-upload.json
yarn dlx shadcn@latest add https://ui.isaacfei.com/r/file-upload.json
bun x shadcn@latest add https://ui.isaacfei.com/r/file-upload.json

Or 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.

Required — upload at least one file. Progress is not an error; submit waits until uploads finish.

"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.

ThreatDefault
Oversized / wrong MIME client-sideBlocked by maxSize / accept when set; still re-check on the server
Malicious file contentNot inspected by the control — scan and authorize in your upload handler
Half-finished uploadsItems stay uploading until onSuccess / onError; abort on remove
Orphan done objects✕ does not DELETE storage — use onRemove (or TTL / draft cleanup)
Unsafe preview URLsjavascript: / arbitrary data: rejected; no new-tab fallback
Preview handler throwsFallback 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

PropTypeDefault
labelReactNode
descriptionReactNode
valueFileUploadItem[]
defaultValueFileUploadItem[][]
onValueChange(items: FileUploadItem[]) => void
upload(file, ctx) => void | Promise<void>
beforeUpload(file, fileList) => …
autoUploadbooleantrue when upload is set
onRemove(item) => boolean | void | Promise<…>— gate before list remove; not storage DELETE
confirmRemovebooleanfalse — confirm dialog before remove for done only
acceptstring
multiplebooleanfalse
maxCountnumber— (1 replaces; avatar defaults to 1)
maxSizenumber— (bytes)
disabledbooleanfalse
variant"button" | "dropzone" | "picture-card" | "avatar""button"
activateOnClickbooleantrue (dropzone: whole area opens dialog)
dropzoneHintReactNode— (in-zone secondary line; dropzone only)
dropzoneContentReactNode— (replaces default icon + title + hint)
previewbooleantrue — 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"
classNamestring
classNamesPartial<Record<FileUploadSlot, string>>
tFileUploadTranslateFn
localestring

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

SlotApplied to
rootControl root wrapper
triggerBrowse / trigger button
dropzoneDropzone surface
dropzoneIconDefault Inbox icon
dropzoneTitlePrimary dropzone title
dropzoneHintSecondary in-zone hint
pictureCardPicture-card grid
pictureCardItemEach picture tile
pictureCardAddAdd (+) tile
avatarAvatar tile
listFile list
itemEach list row
progressProgress bar track
rejectionInline rejection alert

Hook

FunctionuseFileUpload(options: UseFileUploadOptions)
OptionsSame shape as FileUploadFieldProps.
ReturnUseFileUploadReturn: 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.

On this page