# File Upload

> Select, validate, and review files while your application keeps control of every upload request.

Source: https://www.honestui.com/docs/product/file-upload

```tsx
"use client"

import * as React from "react"

import {
  FileUpload,
  FileUploadDescription,
  FileUploadDropzone,
  FileUploadIcon,
  FileUploadList,
  FileUploadTitle,
} from "@/registry/default/product/file-upload/file-upload"
import { formatFileSize } from "@/registry/default/product/file-upload/file-upload-utils"
import {
  Field,
  FieldDescription,
  FieldLabel,
} from "@/components/honest-ui/ui/field"
import { makeSampleFile, makeSamplePngFile } from "./file-upload-example-utils"
import { FileUploadSampleButton } from "./file-upload-sample-button"

/**
 * The everyday setup: drop or browse up to five files, real size and type
 * rules, honest rejection reasons, and rows you can remove without any
 * upload machinery behind them.
 */
export function FileUploadDemo() {
  const containerRef = React.useRef<HTMLDivElement>(null)
  const [lastEvent, setLastEvent] = React.useState("Nothing picked yet.")

  return (
    <div ref={containerRef} className="w-full min-w-0 max-w-xl">
      <Field>
        <FieldLabel htmlFor="file-upload-demo-zone">Upload files</FieldLabel>
        <FileUpload
          accept={{
            "image/png": [".png"],
            "image/jpeg": [".jpg", ".jpeg"],
            "application/pdf": [".pdf"],
          }}
          maxSize={10 * 1024 * 1024}
          maxFiles={5}
          multiple
          onValueChange={(items) => {
            const totalSize = items.reduce((sum, item) => sum + item.file.size, 0)

            setLastEvent(
              items.length === 0
                ? "Selection cleared."
                : `${items.length} of 5 files selected · ${formatFileSize(totalSize)} total`
            )
          }}
        >
          <FileUploadDropzone
            id="file-upload-demo-zone"
            aria-describedby="file-upload-demo-status"
          >
            <FileUploadIcon />
            <FileUploadTitle>Drop files here or click to browse</FileUploadTitle>
            <FileUploadDescription>PNG, JPG, PDF up to 10 MB</FileUploadDescription>
          </FileUploadDropzone>
          <FileUploadList />
        </FileUpload>
        <FieldDescription id="file-upload-demo-status">
          {lastEvent}
        </FieldDescription>
      </Field>

      <div className="mt-[var(--hui-space-3)]">
        <FileUploadSampleButton
          containerRef={containerRef}
          files={() => [
            makeSamplePngFile("meadow-shot.png", { size: 340 * 1024 }),
            makeSampleFile("trip-deck.pdf", {
              type: "application/pdf",
              size: 1.2 * 1024 * 1024,
            }),
          ]}
        />
      </div>
    </div>
  )
}

```

## Overview [#overview]

FileUpload lets people choose, drop, or paste files. It checks count, type, and size rules in the browser, then shows the selected files and any rejection reasons. Your application supplies upload progress, success, and failure states.

FileUpload does not send requests. Your application receives the selection through `onValueChange`, uploads each `File`, and updates `status`, `progress`, and `error`. There is no endpoint prop or storage adapter.

The default dropzone uses a dashed border, an upload icon, and short instructions. Drag, rejection, and success states pair color with text or an icon. Progress and errors stay in the row for the file they describe.

## Anatomy [#anatomy]

<Anatomy title="FileUpload">
  <AnatomyItem name="Dropzone" description="Clickable dashed area that also accepts drops, Enter, and Space">
    <AnatomyItem name="Icon" description="Small upload arrow tinted by drag state" />

    <AnatomyItem name="Title" description="Primary instruction, such as 'Drop files here or click to browse'" />

    <AnatomyItem name="Description" description="Muted requirements line, such as 'PNG, JPG, PDF up to 10 MB'" />
  </AnatomyItem>

  <AnatomyItem name="Trigger" description="Any button that opens the picker; Choose file, Replace, Add more files">
    <AnatomyItem name="Summary" description="Optional chosen-file text for button-only layouts" />
  </AnatomyItem>

  <AnatomyItem name="List" description="Bordered row container, including rejected rows until dismissed">
    <AnatomyItem name="Item" description="Preview, content column, and actions">
      <AnatomyItem name="Preview" description="Image thumbnail or file-type tile" />

      <AnatomyItem name="Content" description="Name, metadata, status, progress, reason" />

      <AnatomyItem name="Actions" description="Remove plus Retry when status is error" />
    </AnatomyItem>
  </AnatomyItem>

  <AnatomyItem name="Clear all" description="Quiet text action shown above one selected file" />
</Anatomy>

`<FileUploadList />` renders complete rows, including Retry on failed uploads. Each row part is also exported for custom layouts.

Installing `file-upload` brings `Button` and `Progress` along as registry dependencies, because rows render both internally.

## Behavior [#behavior]

### Ownership of the request [#ownership-of-the-request]

FileUpload reports selections. The application uploads them:

```tsx
<FileUpload value={files} onValueChange={setFiles} />
```

Items carry whatever state your uploader produces:

```ts
type FileUploadStatus = "pending" | "uploading" | "success" | "error"

type FileUploadItemData = {
  id: string
  file: File
  status?: FileUploadStatus
  progress?: number | null // null means indeterminate
  error?: string           // your sentence, shown verbatim
}
```

A fresh pick starts as `pending`. When a transfer begins, set `uploading` and update `progress` from your transport's progress events. If the length cannot be computed, use `progress: null`. The row then shows an indeterminate bar and the text `Uploading...`.

On failure, set `status: "error"` and add a specific `error` sentence. The original `File` stays in the item, so Retry calls your handler with it again:

```tsx
onRetry={(item) => startUpload(item)}
```

Retry renders only when a failed row has an `onRetry` handler. It performs no request itself.

### Validation order [#validation-order]

Count rules run first because they answer a different question than size rules. A batch either fits the remaining capacity or it does not, and partial acceptance hides that fact. Dropping three files into two open slots rejects the entire gesture with You can upload up to N files. Remove a file before adding another, printed under the dropzone and announced politely.

Then per-file rules run: empty files, `minSize`, `maxSize`, the `accept` mapping, and your `validateFile` callback. Each rejected file gets a row with its reason. `onReject` receives all rejections from that selection.

These checks are picker feedback, not a security boundary. Filenames and browser-reported MIME types are untrusted. Recheck type and size on the server, inspect file signatures when the format allows it, generate safe storage names, and scan files when your threat model calls for it. The [OWASP File Upload Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html) covers the server controls that this client component cannot provide.

Single-file mode replaces its selection wholesale, whether the new file comes from Replace, a re-pick, or a drop. Extra files in a single-file drop land as rejected rows saying only one file belongs there, because silently discarding someone's second photo teaches nothing.

### Drag feedback [#drag-feedback]

During a hover, browsers expose the number of files being carried, but not their names or sizes. The dropzone can check capacity at that point. A drag that fits uses the accent state. A drag that cannot fit uses the danger state, sets `dropEffect` to `none`, and announces the limit. Type and size checks run after the drop, when the browser exposes each file.

Non-file drags, a text selection for instance, leave the zone untouched.

### Capacity and removal [#capacity-and-removal]

Add more files disables itself at the cap instead of letting someone fill a queue the rules will reject. Clear all appears above one file, stays a ghost-weight text button, and skips destructive styling since nothing has been uploaded by FileUpload itself. Removing a row is instant with no confirm dialog, appropriate for local picks that nobody's server has seen.

### Focus after changes [#focus-after-changes]

After removing a selected or rejected row, focus moves to the next remove action, then the previous one. When no row remains, focus moves to the first available picker trigger or dropzone. Clear all follows the same fallback. Closing the native picker returns focus to the control that opened it.

## Accessibility [#accessibility]

Everything reachable by pointer works by keyboard. The dropzone is a real button, so Enter and Space open the picker without drag assistance, removed files report their removal target, and every icon-only action carries a generated accessible label such as Remove invoice-march.pdf, overridable per instance.

<kbd>Tab</kbd> walks trigger, rows, and actions in DOM order. Each row exposes filename, size and type, current status, and any error as visible text, which screen readers announce through normal reading rather than noisy per-row live regions.

One polite status region announces additions, completed uploads, failed uploads, and rejections. Percentage updates stay silent. Row-level Progress elements use labels such as `Uploading report.pdf`, so each progress bar is identifiable on its own.

The instruction inside the dropzone contributes to its accessible name. `FieldLabel` can name the button through its `id`, and `aria-describedby` connects outside help text. Invalid drags change the text and icon as well as the color. Reduced motion removes the indeterminate animation, while the `Uploading...` text remains.

| Key                                 | Action                                                          |
| ----------------------------------- | --------------------------------------------------------------- |
| <kbd>Tab</kbd>                      | Move across dropzone, triggers, rows, remove and retry controls |
| <kbd>Enter</kbd> / <kbd>Space</kbd> | Open the picker from the focused dropzone or trigger            |
| <kbd>Space</kbd>                    | Activate remove when a row action holds focus                   |

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;file-upload&#x22;]" />
  

  
    
      
        No extra packages are needed beyond the base setup.
      

      
        Copy and paste these files into your project.
      

      <ComponentSource name="file-upload" title="components/ui/file-upload/file-upload-types.ts" file="file-upload-types.ts" />

      <ComponentSource name="file-upload" title="components/ui/file-upload/file-upload-utils.ts" file="file-upload-utils.ts" />

      <ComponentSource name="file-upload" title="components/ui/file-upload/file-upload-context.tsx" file="file-upload-context.tsx" />

      <ComponentSource name="file-upload" title="components/ui/file-upload/file-upload.tsx" file="file-upload.tsx" />

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

Import the parts your layout needs. Composition mirrors the anatomy tree:

```tsx
import {
  FileUpload,
  FileUploadDropzone,
  FileUploadDescription,
  FileUploadIcon,
  FileUploadTitle,
} from "@/components/ui/file-upload/file-upload"

<FileUpload accept={{ "image/png": [".png"] }} maxSize={10 * 1024 * 1024} multiple>
  <FileUploadDropzone>
    <FileUploadIcon />
    <FileUploadTitle>Drop files here or click to browse</FileUploadTitle>
    <FileUploadDescription>PNG up to 10 MB</FileUploadDescription>
  </FileUploadDropzone>
</FileUpload>
```

Inside an existing [Field](/docs/components/field), point the label at the dropzone button. Use `aria-describedby` for instructions outside the dropzone:

```tsx
<Field>
  <FieldLabel htmlFor="attachments">Attachments</FieldLabel>
  <FileUpload>
    <FileUploadDropzone
      id="attachments"
      aria-describedby="attachments-help"
    >
      <FileUploadIcon />
      <FileUploadTitle>Drop attachments or browse</FileUploadTitle>
    </FileUploadDropzone>
  </FileUpload>
  <FieldDescription id="attachments-help">
    PDF or image up to 5 MB.
  </FieldDescription>
</Field>
```

Connecting an uploader takes one callback; wire statuses wherever your requests resolve:

```tsx
const [items, setItems] = useState<FileUploadItemData[]>([])

<FileUpload value={items} onValueChange={setItems} />

async function upload(item: FileUploadItemData) {
  patch(item.id, { status: "uploading", progress: null })

  await xhrSend(item.file, (percent) =>
    patch(item.id, { status: "uploading", progress: percent })
  )

  patch(item.id, { status: "success", progress: 100 })
}
```

FileUpload does not participate in native form submission. If the uploader sits in a form, submit the selected files in your form handler or build a `FormData` object yourself:

```tsx
async function submitFiles(items: FileUploadItemData[]) {
  const body = new FormData()

  for (const item of items) {
    body.append("attachments", item.file, item.file.name)
  }

  await fetch("/api/attachments", { method: "POST", body })
}
```

Paste support opts in per instance with `allowPaste`; directory browsing and camera capture are plain props (`directory`, `capture`) documented under [Less common inputs](#examples).

### Single-file flows [#single-file-flows]

Compact tasks read better with the row replacing the zone entirely. Both buttons below ride the same hidden input, so Replace never spawns a second picker circuit:

```tsx
<FileUpload value={value} onValueChange={setValue} accept={avatarTypes}>
  {value.length > 0 ? (
    <>
      <FileUploadList />
      <FileUploadTrigger render={<Button variant="outline" />}>
        Replace
      </FileUploadTrigger>
    </>
  ) : (
    <FileUploadDropzone>...</FileUploadDropzone>
  )}
</FileUpload>
```

Button-only layouts work the same way. The summary uses the familiar text `No file chosen` when the selection is empty:

```tsx
<FileUploadTrigger render={<Button variant="outline" />}>Choose file</FileUploadTrigger>
<FileUploadSummary />
```

### Locale and long names [#locale-and-long-names]

Long filenames truncate in the row, with the full name available through the native `title` tooltip. The default metadata uses English number formatting and binary KB, MB, and GB units. Compose a custom row if your product needs locale-specific file sizes.

## Don't do this [#dont-do-this]

### Sending requests from inside the component [#sending-requests-from-inside-the-component]

```tsx
// Bad. Storage credentials leak into every bundle that touches this form.
<FileUpload bucket="prod-assets" supabaseKey={publicAnonKey} />
```

```tsx
// Good. The server signs, the client uploads, the component stays dumb.
onAccept={(items) => enqueue(items.map((item) => signAndUpload(item.file)))}
```

Provider adapters hide authentication and retry decisions inside a presentation component. Keep transport in the application layer.

### Faking progress [#faking-progress]

```tsx
// Bad. Nine seconds of lies followed by sudden truth.
setInterval(() => bumpProgress(+4), 300)
await doRealUpload()
patch(item.id, { status: "success" })
```

Use `progress: null` when the transport cannot report bytes sent. Do not invent percentages.

## Examples [#examples]

The examples below cover selected files, upload states, rejection, alternate inputs, and controlled ownership.

```tsx
"use client"

import * as React from "react"

import {
  FileUpload,
  FileUploadAddMore,
  FileUploadClear,
  FileUploadList,
  type FileUploadItemData,
} from "@/registry/default/product/file-upload/file-upload"
import { formatFileSize } from "@/registry/default/product/file-upload/file-upload-utils"
import { makeSampleItem, makeSampleSvgFile } from "./file-upload-example-utils"

/**
 * Three already-picked files shown with consistent file-type tiles. Add more
 * files respects the five-file limit and Clear all needs no confirmation for
 * local picks.
 */
export function FileUploadSelected() {
  const [value, setValue] = React.useState<FileUploadItemData[]>(() => [
    {
      id: "sample-campsite",
      file: makeSampleSvgFile("campsite-photo", 190, 145),
    },
    makeSampleItem("trip-itinerary.pdf", {
      type: "application/pdf",
      size: 2.4 * 1024 * 1024,
    }),
    makeSampleItem("team-handbook.docx", {
      type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
      size: 820 * 1024,
    }),
  ])

  return (
    <div className="w-full min-w-0 max-w-xl">
      <FileUpload
        id="file-upload-selected"
        value={value}
        onValueChange={setValue}
        maxFiles={5}
        multiple
      >
        <FileUploadList showImagePreviews={false} />

        <div className="mt-[var(--hui-space-3)] flex flex-wrap items-center gap-[var(--hui-space-3)]">
          <FileUploadAddMore>Add more files</FileUploadAddMore>
          <FileUploadClear />
          <span
            aria-live="polite"
            className="text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-mini)] [letter-spacing:var(--hui-letter-spacing-mini)] [line-height:var(--hui-line-height-mini)]"
          >
            {value.length} of 5 files selected ·{" "}
            {formatFileSize(value.reduce((sum, item) => sum + item.file.size, 0))}
          </span>
        </div>
      </FileUpload>
    </div>
  )
}

```

### Multiple files and capacity [#multiple-files-and-capacity]

This example shows the selected count, a disabled Add more action at the cap, and Clear all.

### Upload lifecycle [#upload-lifecycle]

One determinate climb, one indeterminate ride, one finished row, and one scripted connection loss with a working Retry. Retry replays succeed because this demo, like your app, still holds the original File.

```tsx
"use client"

import * as React from "react"

import {
  FileUpload,
  FileUploadList,
  type FileUploadItemData,
} from "@/registry/default/product/file-upload/file-upload"
import {
  makeSampleFile,
  makeSampleJpegFile,
  makeSamplePngFile,
} from "./file-upload-example-utils"

const TICK_MS = 260

/** Steady climbs toward done, one unknown-length ride, one scripted drop. */
const PLANS: Record<string, { increment?: number }> = {
  report: { increment: 7 },
  invoice: { increment: 13 },
}

/**
 * Four rows caught mid-story: a determinate climb, an upload with no
 * computable percentage, a completed transfer, and a connection loss whose
 * Retry replays cleanly because the app still holds the original File.
 */
export function FileUploadStates() {
  const [items, setItems] = React.useState<FileUploadItemData[]>([
    {
      id: "expenses-png",
      file: makeSamplePngFile("team-expenses.png", { size: 640 * 1024 }),
      status: "success",
      progress: 100,
    },
    {
      id: "recording",
      file: makeSampleFile("all-hands-recording.wav", {
        type: "audio/wav",
        size: 48 * 1024 * 1024,
      }),
      status: "uploading",
      progress: null,
    },
    {
      id: "expenses-jpg",
      file: makeSampleJpegFile("team-expenses.jpg", {
        size: 640 * 1024,
      }),
      status: "success",
      progress: 100,
    },
    {
      id: "invoice",
      file: makeSampleFile("invoice-march.pdf", {
        type: "application/pdf",
        size: 1.4 * 1024 * 1024,
      }),
      status: "error",
      error: "Upload failed. The connection was lost mid-transfer.",
    },
  ])

  const attemptsRef = React.useRef(new Map<string, number>([["invoice", 1]]))
  const recordingTickRef = React.useRef(0)

  React.useEffect(() => {
    const timer = window.setInterval(() => {
      recordingTickRef.current += 1

      const recordingDone = recordingTickRef.current >= 11

      setItems((current) =>
        current.map((item) => {
          if (item.status !== "uploading") return item

          // Invoice sits still until its second attempt earns motion.
          if (item.id === "invoice" && (attemptsRef.current.get("invoice") ?? 0) < 2) {
            return item
          }

          if (item.id === "recording") {
            return recordingDone
              ? { ...item, status: "success" as const }
              : item
          }

          const plan = PLANS[item.id]

          if (!plan?.increment) return item

          const next = Math.min(
            100,
            (typeof item.progress === "number" ? item.progress : 0) +
              plan.increment
          )

          return next === 100
            ? { ...item, status: "success" as const, progress: 100 }
            : { ...item, progress: next }
        })
      )
    }, TICK_MS)

    return () => window.clearInterval(timer)
  }, [])

  function handleRetry(item: FileUploadItemData) {
    attemptsRef.current.set(item.id, (attemptsRef.current.get(item.id) ?? 0) + 1)

    setItems((current) =>
      current.map((entry) =>
        entry.id === item.id
          ? {
              ...entry,
              status: "uploading",
              progress: null,
              error: undefined,
            }
          : entry
      )
    )
  }

  return (
    <div className="w-full min-w-0 max-w-xl">
      <FileUpload value={items} onValueChange={setItems} multiple onRetry={handleRetry}>
        <FileUploadList showImagePreviews={false} />
      </FileUpload>

      <p className="mt-[var(--hui-space-2)] text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-mini)] [letter-spacing:var(--hui-letter-spacing-mini)] [line-height:var(--hui-line-height-mini)]">
        Sizes are stand-in metadata; no network requests leave this page.
      </p>
    </div>
  )
}

```

### Rejections [#rejections]

Deliberately tight limits, so ordinary browsing trips the rules. Rows explain themselves and wait for dismissal.

```tsx
"use client"

import * as React from "react"

import {
  FileUpload,
  FileUploadDescription,
  FileUploadDropzone,
  FileUploadIcon,
  FileUploadList,
  FileUploadTitle,
} from "@/registry/default/product/file-upload/file-upload"
import { Field, FieldDescription } from "@/components/honest-ui/ui/field"
import { makeSampleFile } from "./file-upload-example-utils"
import { FileUploadSampleButton } from "./file-upload-sample-button"

/**
 * Limits sit deliberately low so ordinary browsing triggers them: two files
 * max, one megabyte total headroom per file, PNG or JPG only. Every reject
 * lands as its own row with the reason spelled out where the file sits.
 */
export function FileUploadRejected() {
  const containerRef = React.useRef<HTMLDivElement>(null)

  return (
    <div ref={containerRef} className="w-full min-w-0 max-w-xl">
      <Field>
        <FileUpload
          accept={{ "image/png": [".png"], "image/jpeg": [".jpg", ".jpeg"] }}
          maxSize={1024 * 1024}
          maxFiles={2}
          multiple
        >
          <FileUploadDropzone>
            <FileUploadIcon />
            <FileUploadTitle>Drop a photo to test rejection</FileUploadTitle>
            <FileUploadDescription>
              PNG or JPG under 1 MB · 2 files max
            </FileUploadDescription>
          </FileUploadDropzone>
          <FileUploadList />
        </FileUpload>

        <FieldDescription>
          Try an oversized screenshot, a .mov, or three files at once. Rejected
          rows stay visible with their reason until you dismiss them.
        </FieldDescription>
      </Field>

      <div className="mt-[var(--hui-space-3)]">
        <FileUploadSampleButton
          containerRef={containerRef}
          label="Try two broken samples"
          files={() => [
            makeSampleFile("beach-photo.png", {
              type: "image/png",
              size: 2.4 * 1024 * 1024,
            }),
            makeSampleFile("product-demo.mov", {
              type: "video/quicktime",
              contents: ["not really a movie"],
            }),
          ]}
        />
        <span className="ms-2 text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-mini)] [letter-spacing:var(--hui-letter-spacing-mini)] [line-height:var(--hui-line-height-mini)]">
          One is too large, one is the wrong type.
        </span>
      </div>
    </div>
  )
}

```

### Drag states [#drag-states]

The examples show valid and over-capacity drag states. Count rules run during the drag. Type and size rules wait for the drop.

```tsx
"use client"

import * as React from "react"

import {
  FileUpload,
  FileUploadDescription,
  FileUploadDropzone,
  FileUploadIcon,
  FileUploadTitle,
} from "@/registry/default/product/file-upload/file-upload"

/**
 * Drag any files from your desktop over both zones. Browsers only reveal
 * the carried count during a drag, so the right zone turns red the moment
 * its one open slot is outnumbered; type and size still check on drop.
 */
export function FileUploadDragOver() {
  return (
    <div className="grid w-full min-w-0 gap-[var(--hui-space-4)] sm:grid-cols-2">
      <FileUpload multiple>
        <FileUploadDropzone>
          <FileUploadIcon />
          <FileUploadTitle>Drop to upload</FileUploadTitle>
          <FileUploadDescription>Any number of files</FileUploadDescription>
        </FileUploadDropzone>
      </FileUpload>

      <FileUpload maxFiles={1} multiple>
        <FileUploadDropzone>
          <FileUploadIcon />
          <FileUploadTitle>One slot only</FileUploadTitle>
          <FileUploadDescription>Dragging two or more turns this red</FileUploadDescription>
        </FileUploadDropzone>
      </FileUpload>

      <p className="text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-mini)] [letter-spacing:var(--hui-letter-spacing-mini)] [line-height:var(--hui-line-height-mini)] sm:col-span-2">
        Borders shift color on valid and invalid drags; nothing bounces.
      </p>
    </div>
  )
}

```

### Single file [#single-file]

Replace and the empty state take over from the dropzone, sized for avatars, certificates, and imports that take exactly one document.

```tsx
"use client"

import * as React from "react"

import {
  FileUpload,
  FileUploadDropzone,
  FileUploadIcon,
  FileUploadList,
  FileUploadTitle,
  FileUploadTrigger,
  type FileUploadItemData,
} from "@/registry/default/product/file-upload/file-upload"
import { Button } from "@/components/honest-ui/ui/button"
import { makeSamplePngFile } from "./file-upload-example-utils"
import { FileUploadSampleButton } from "./file-upload-sample-button"

/**
 * Single-file tasks should not keep a giant empty dropzone around. After a
 * pick the row takes over, Replace reopens the picker, and removing lands
 * you back on an honest empty state. Dropping again replaces, not stacks.
 */
export function FileUploadSingle() {
  const containerRef = React.useRef<HTMLDivElement>(null)
  const [value, setValue] = React.useState<FileUploadItemData[]>([])
  const hasFile = value.length > 0

  return (
    <div ref={containerRef} className="w-full min-w-0 max-w-xl">
      <FileUpload
        value={value}
        onValueChange={setValue}
        accept={{ "image/png": [".png"], "image/jpeg": [".jpg", ".jpeg"] }}
        maxSize={5 * 1024 * 1024}
      >
        {/* Stays mounted so rejected picks explain themselves while empty. */}
        <FileUploadList />

        {hasFile ? (
          <div className="mt-[var(--hui-space-3)]">
            <FileUploadTrigger render={<Button variant="outline" />}>
              Replace
            </FileUploadTrigger>
          </div>
        ) : (
          <FileUploadDropzone className="mt-[var(--hui-space-3)]">
            <FileUploadIcon />
            <FileUploadTitle>Drop a file or browse</FileUploadTitle>
          </FileUploadDropzone>
        )}
      </FileUpload>

      <div className="mt-[var(--hui-space-2)] flex items-center gap-[var(--hui-space-3)]">
        <p className="text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-mini)] [letter-spacing:var(--hui-letter-spacing-mini)] [line-height:var(--hui-line-height-mini)]">
          PNG or JPG up to 5 MB
        </p>
        <FileUploadSampleButton
          containerRef={containerRef}
          files={() => [
            makeSamplePngFile("portrait.png", { size: 820 * 1024 }),
          ]}
        />
      </div>
    </div>
  )
}

```

### Button only [#button-only]

Use this layout for filters and other compact controls. It shows `No file chosen` while empty.

```tsx
"use client"

import * as React from "react"

import {
  FileUpload,
  FileUploadSummary,
  FileUploadTrigger,
  type FileUploadItemData,
} from "@/registry/default/product/file-upload/file-upload"
import { Button } from "@/components/honest-ui/ui/button"
import { makeSampleFile, makeSampleSvgFile } from "./file-upload-example-utils"
import { FileUploadSampleButton } from "./file-upload-sample-button"

/**
 * Compact forms and importers skip the dropzone entirely. The summary line
 * mirrors the picker's own "No file chosen" convention and updates through
 * the same controlled state. Sample picks prove the wiring even where the
 * native chooser is unavailable.
 */
export function FileUploadButtonOnly() {
  const containerRef = React.useRef<HTMLDivElement>(null)
  const [value, setValue] = React.useState<FileUploadItemData[]>([])

  return (
    <div className="flex min-h-[128px] w-full items-center justify-center">
      <div ref={containerRef} className="flex flex-wrap items-center justify-center gap-[var(--hui-space-3)]">
        <FileUpload
          value={value}
          onValueChange={setValue}
          className="flex items-center justify-center gap-[var(--hui-space-3)]"
        >
          <FileUploadTrigger render={<Button variant="outline" />}>
            {value.length === 0 ? "Choose file" : "Choose another file"}
          </FileUploadTrigger>
          <FileUploadSummary className="max-w-72" />
        </FileUpload>

        <FileUploadSampleButton
          containerRef={containerRef}
          files={() => [
            makeSampleSvgFile("field-notes", 205, 155),
            makeSampleFile("budget-2026.csv", {
              type: "text/csv",
              contents: ["month,spend", "aug,412"],
              size: 64 * 1024,
            }),
          ]}
          label="Try samples"
        />
      </div>
    </div>
  )
}

```

### Disabled, inside Field [#disabled-inside-field]

The enabled example makes the disabled border, text, and cursor changes easy to compare.

```tsx
"use client"

import * as React from "react"

import {
  FileUpload,
  FileUploadDescription,
  FileUploadDropzone,
  FileUploadIcon,
  FileUploadTitle,
} from "@/registry/default/product/file-upload/file-upload"
import { Field, FieldLabel } from "@/components/honest-ui/ui/field"

/**
 * Disabled matches every other HonestUI control: muted border and text, no
 * picker, no drops, nothing focusable. Composed inside Field with the label
 * pointing at the dropzone id so assistive tech names it directly.
 */
export function FileUploadDisabled() {
  return (
    <div className="grid w-full min-w-0 gap-[var(--hui-space-5)] sm:grid-cols-2">
      <Field>
        <FieldLabel htmlFor="file-upload-enabled-zone">Attachments</FieldLabel>

        <FileUpload>
          <FileUploadDropzone id="file-upload-enabled-zone">
            <FileUploadIcon />
            <FileUploadTitle>Enabled sibling for contrast</FileUploadTitle>
            <FileUploadDescription>PDF up to 10 MB</FileUploadDescription>
          </FileUploadDropzone>
        </FileUpload>
      </Field>

      <Field data-disabled>
        <FieldLabel htmlFor="file-upload-disabled-zone">Locked attachments</FieldLabel>

        <FileUpload disabled>
          <FileUploadDropzone
            id="file-upload-disabled-zone"
            aria-describedby="lock-note"
          >
            <FileUploadIcon />
            <FileUploadTitle>File upload is disabled</FileUploadTitle>
            <FileUploadDescription>Billing must be restored first</FileUploadDescription>
          </FileUploadDropzone>
        </FileUpload>
        <span id="lock-note" className="sr-only">
          Attachments unlock after billing is restored.
        </span>
      </Field>
    </div>
  )
}

```

### Controlled ownership [#controlled-ownership]

Reset writes through the same callback everything else uses, and the commit counter makes round-trips visible.

```tsx
"use client"

import * as React from "react"

import {
  FileUpload,
  FileUploadDropzone,
  FileUploadIcon,
  FileUploadList,
  FileUploadTitle,
  type FileUploadItemData,
} from "@/registry/default/product/file-upload/file-upload"
import { Button } from "@/components/honest-ui/ui/button"
import { makeSampleFile } from "./file-upload-example-utils"
import { FileUploadSampleButton } from "./file-upload-sample-button"

function freshSample() {
  return makeSampleFile("kickoff-deck.pdf", {
    type: "application/pdf",
    size: 5.6 * 1024 * 1024,
  })
}

/**
 * State lives above the component, and the readout proves it: every add,
 * remove, and clear flows through the one onValueChange callback the parent
 * owns. Both sample buttons write through that same narrow door.
 */
export function FileUploadControlled() {
  const containerRef = React.useRef<HTMLDivElement>(null)
  const [value, setValue] = React.useState<FileUploadItemData[]>([])
  const [commitCount, setCommitCount] = React.useState(0)

  function commit(items: FileUploadItemData[]) {
    setValue(items)
    setCommitCount((count) => count + 1)
  }

  return (
    <div ref={containerRef} className="w-full min-w-0 max-w-xl">
      <FileUpload value={value} onValueChange={commit} multiple maxFiles={4}>
        <FileUploadDropzone>
          <FileUploadIcon />
          <FileUploadTitle>Drop files here or click to browse</FileUploadTitle>
        </FileUploadDropzone>
        <FileUploadList />
      </FileUpload>

      <div className="mt-[var(--hui-space-3)] flex flex-wrap items-center gap-[var(--hui-space-3)]">
        <Button
          variant="outline"
          onClick={() => {
            setValue([])
            setCommitCount((count) => count + 1)
          }}
        >
          Clear through the parent
        </Button>
        <FileUploadSampleButton
          containerRef={containerRef}
          files={() => [freshSample(), freshSample(), freshSample()]}
          label="Commit a sample"
        />
        <span
          aria-live="polite"
          className="text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-mini)] [letter-spacing:var(--hui-letter-spacing-mini)] [line-height:var(--hui-line-height-mini)]"
        >
          Commits seen by the parent: {commitCount}
        </span>
      </div>
    </div>
  )
}

```

### Custom validation [#custom-validation]

Return a sentence from `validateFile` to reject a file with that reason.

```tsx
"use client"

import * as React from "react"

import {
  FileUpload,
  FileUploadDescription,
  FileUploadDropzone,
  FileUploadIcon,
  FileUploadList,
  FileUploadTitle,
} from "@/registry/default/product/file-upload/file-upload"
import { makeSampleFile } from "./file-upload-example-utils"
import { FileUploadSampleButton } from "./file-upload-sample-button"

/**
 * validateFile adds rules HonestUI cannot guess. This one bans spaces in
 * names because an import pipeline chokes on them; the returned sentence
 * renders verbatim on the rejected row and reaches screen readers once.
 */
export function FileUploadValidation() {
  const containerRef = React.useRef<HTMLDivElement>(null)

  return (
    <div ref={containerRef} className="w-full min-w-0 max-w-xl">
      <FileUpload
        multiple
        validateFile={(file) => {
          if (file.name.includes(" ")) {
            return "File names cannot contain spaces."
          }

          return null
        }}
      >
        <FileUploadDropzone>
          <FileUploadIcon />
          <FileUploadTitle>
            Drop a file with no spaces in its name
          </FileUploadTitle>
          <FileUploadDescription>
            Custom rule: use dashes instead of spaces
          </FileUploadDescription>
        </FileUploadDropzone>
        <FileUploadList />
      </FileUpload>

      <div className="mt-[var(--hui-space-3)]">
        <FileUploadSampleButton
          containerRef={containerRef}
          files={() => [
            makeSampleFile("my resume draft.txt", {
              type: "text/plain",
              contents: ["name: someone\nrole: something"],
            }),
            makeSampleFile("clean-name.csv", {
              type: "text/csv",
              contents: ["id,name", "1,connor"],
            }),
          ]}
        />
      </div>
    </div>
  )
}

```

### Image previews [#image-previews]

This example creates blob thumbnails from an SVG and a JPG, then updates their progress and status.

```tsx
"use client"

import * as React from "react"

import {
  FileUpload,
  FileUploadDescription,
  FileUploadDropzone,
  FileUploadIcon,
  FileUploadList,
  FileUploadTitle,
  type FileUploadItemData,
} from "@/registry/default/product/file-upload/file-upload"
import {
  makeSampleJpegFile,
  makeStockSvgFile,
} from "./file-upload-example-utils"

/**
 * Image pickers live everywhere: avatars, receipts, gallery posts. Two
 * seeded picks ride a short simulated transfer so thumbnails, thin progress
 * bars, and the quiet Uploaded finish all appear without a click.
 */
export function FileUploadImages() {
  const [value, setValue] = React.useState<FileUploadItemData[]>([])
  const seededRef = React.useRef(false)

  React.useEffect(() => {
    if (seededRef.current) return

    seededRef.current = true

    const samples = [
      makeStockSvgFile("sunset-watch.svg", { size: 184 * 1024 }),
      makeSampleJpegFile("harbor-boats.jpg", { size: 540 * 1024 }),
    ]
    const startRates = [34, 16]

    setValue(
      samples.map((file, index) => ({
        id: `sample-image-${index}`,
        file,
        status: "uploading" as const,
        progress: startRates[index],
      }))
    )

    let ticks = 0
    const timer = window.setInterval(() => {
      ticks += 1

      setValue((current) =>
        current.map((item) => {
          if (item.status !== "uploading") return item

          // Second image moves slower so the two bars read differently.
          const step = item.id.endsWith("1") ? 8 : 12
          const next = Math.min(100, (item.progress ?? 0) + step)

          return next >= 100
            ? { ...item, status: "success" as const, progress: 100 }
            : { ...item, progress: next }
        })
      )

      if (ticks >= 10) window.clearInterval(timer)
    }, 300)

    return () => window.clearInterval(timer)
  }, [])

  return (
    <div className="w-full min-w-0 max-w-xl">
      <FileUpload
        value={value}
        onValueChange={setValue}
        accept={{
          "image/png": [".png"],
          "image/jpeg": [".jpg", ".jpeg"],
          "image/svg+xml": [".svg"],
        }}
        maxSize={8 * 1024 * 1024}
        maxFiles={4}
        multiple
      >
        <FileUploadDropzone>
          <FileUploadIcon />
          <FileUploadTitle>Drop images or browse</FileUploadTitle>
          <FileUploadDescription>
            PNG, JPG, or SVG up to 8 MB · four at a time
          </FileUploadDescription>
        </FileUploadDropzone>
        <FileUploadList />
      </FileUpload>
    </div>
  )
}

```

### Paste support [#paste-support]

Focus the zone, press paste, and clipboard screenshots travel the identical validation path.

```tsx
"use client"

import * as React from "react"

import {
  FileUpload,
  FileUploadDescription,
  FileUploadDropzone,
  FileUploadIcon,
  FileUploadList,
  FileUploadTitle,
} from "@/registry/default/product/file-upload/file-upload"
import { formatFileSize } from "@/registry/default/product/file-upload/file-upload-utils"

/**
 * allowPaste listens only while focus sits inside this uploader, so a copy
 * hotkey meant for a nearby input never gets hijacked. Paste a screenshot
 * after focusing the zone and the usual rules apply unchanged.
 */
export function FileUploadPaste() {
  const [note, setNote] = React.useState("Nothing picked yet.")

  return (
    <div className="w-full min-w-0 max-w-xl">
      <FileUpload
        allowPaste
        accept={{ "image/png": [".png"], "image/jpeg": [".jpg", ".jpeg"] }}
        maxSize={10 * 1024 * 1024}
        onAccept={(items) => {
          setNote(
            items.length === 1
              ? `Picked up ${items[0].file.name} · ${formatFileSize(items[0].file.size)}`
              : `Picked up ${items.length} files`
          )
        }}
      >
        <FileUploadDropzone>
          <FileUploadIcon />
          <FileUploadTitle>Focus here and paste a screenshot</FileUploadTitle>
          <FileUploadDescription>
            Clipboard files follow the same PNG or JPG rules, up to 10 MB
          </FileUploadDescription>
        </FileUploadDropzone>
        <FileUploadList />
      </FileUpload>

      <p
        aria-live="polite"
        className="mt-[var(--hui-space-2)] text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-mini)] [letter-spacing:var(--hui-letter-spacing-mini)] [line-height:var(--hui-line-height-mini)]"
      >
        {note}
      </p>
    </div>
  )
}

```

### Less common inputs [#less-common-inputs]

Directory picking and camera capture together, both opt-in props with their caveats stated next to the controls that enable them.

```tsx
"use client"

import * as React from "react"

import {
  FileUpload,
  FileUploadDescription,
  FileUploadDropzone,
  FileUploadIcon,
  FileUploadList,

  FileUploadTitle,
} from "@/registry/default/product/file-upload/file-upload"
import { Switch } from "@/components/honest-ui/ui/switch"

/**
 * Two less common entry doors in one place because both are opt-in props:
 * folder selection through the directory picker, and camera capture on
 * phones. Desktop capture buttons simply open the normal picker.
 */
export function FileUploadSources() {
  const [pickFolders, setPickFolders] = React.useState(false)
  const [direction, setDirection] = React.useState<"user" | "environment">("environment")

  return (
    <div className="flex w-full min-w-0 max-w-2xl flex-col gap-[var(--hui-space-5)]">
      <FileUpload
        directory={pickFolders}
        accept={{ "image/*": [] }}
        multiple
      >
        <FileUploadDropzone>
          <FileUploadIcon />
          <FileUploadTitle>Drop images or browse</FileUploadTitle>
          <FileUploadDescription>
            {pickFolders
              ? "Whole folders count; browser support varies"
              : "Switch below to pick folders instead of files"}
          </FileUploadDescription>
        </FileUploadDropzone>
        <FileUploadList />
      </FileUpload>

      <label className="flex items-center gap-[var(--hui-space-2)] text-[var(--hui-color-foreground-base-primary)] [font-size:var(--hui-font-size-small)] [line-height:var(--hui-line-height-small)]">
        <Switch
          checked={pickFolders}
          onCheckedChange={(checked) => setPickFolders(Boolean(checked))}
          aria-label="Pick folders instead of files"
        />
        Pick folders instead of files
      </label>

      <FileUpload accept={{ "image/*": [] }} capture={direction}>
        <FileUploadDropzone>
          <FileUploadIcon />
          <FileUploadTitle>Capture a photo</FileUploadTitle>
          <FileUploadDescription>
            Opens the camera on phones with facing {direction === "environment" ? "back" : "front"}
          </FileUploadDescription>
        </FileUploadDropzone>
        <FileUploadList />

        <div className="mt-[var(--hui-space-3)] flex items-center gap-[var(--hui-space-2)]">
          <button
            type="button"
            onClick={() => setDirection("environment")}
            className={
              direction === "environment"
                ? "font-medium underline underline-offset-4"
                : "text-[var(--hui-color-foreground-base-secondary)] underline-offset-4 hover:underline"
            }
          >
            Back camera
          </button>
          <span aria-hidden="true">·</span>
          <button
            type="button"
            onClick={() => setDirection("user")}
            className={
              direction === "user"
                ? "font-medium underline underline-offset-4"
                : "text-[var(--hui-color-foreground-base-secondary)] underline-offset-4 hover:underline"
            }
          >
            Front camera
          </button>
        </div>
      </FileUpload>
    </div>
  )
}

```

## API reference [#api-reference]

### FileUpload [#fileupload]

Root provider and layout wrapper. Accepts native div props beyond those listed.

| Prop                  | Type                                 | Default | Description                                                     |
| --------------------- | ------------------------------------ | ------- | --------------------------------------------------------------- |
| `value`               | `FileUploadItemData[]`               | None    | Controlled selection owned by your application.                 |
| `defaultValue`        | `FileUploadItemData[]`               | `[]`    | Initial items when uncontrolled.                                |
| `onValueChange`       | `(items) => void`                    | None    | Every accepted mutation, including clears.                      |
| `accept`              | `Record<string, string[]> \| string` | None    | MIME-to-extension map filtering the picker and rules.           |
| `multiple`            | boolean                              | `false` | Allows stacking selections instead of replacing.                |
| `maxFiles`            | number                               | None    | Capacity ceiling driving batch rejection and trigger disabling. |
| `maxSize` / `minSize` | number                               | None    | Per-file byte bounds quoted back in reasons.                    |
| `disabled`            | boolean                              | `false` | Blocks clicks, drops, pastes, and focus entry.                  |
| `onAccept`            | `(items) => void`                    | None    | Fires after validation passes.                                  |
| `onReject`            | `(rejections) => void`               | None    | One call per selection carrying every rejection.                |
| `onRemove`            | `(item) => void`                     | None    | User-initiated removals only.                                   |
| `onRetry`             | `(item) => void`                     | None    | Reports retry intent; you restart the request.                  |
| `validateFile`        | `(file) => string \| null`           | None    | Custom rule returning a display-ready reason or null.           |
| `allowPaste`          | boolean                              | `false` | Reads clipboard files while focus sits inside this uploader.    |
| `directory`           | boolean                              | `false` | Switches the picker to folder selection where supported.        |
| `capture`             | `"user" \| "environment"`            | None    | Requests a phone camera facing direction.                       |

### Parts [#parts]

| Part                     | Renders             | Notes                                                                                                                                                               |
| ------------------------ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FileUploadDropzone`     | button              | Accepts native button props, handles picker and drop actions, and prints selection errors beneath itself. Use `wrapperClassName` to style its outer layout wrapper. |
| `FileUploadIcon`         | svg, 24px           | Tint tracks idle, valid, and invalid drag states.                                                                                                                   |
| `FileUploadTitle`        | span                | Reads primary instructions; swap wording during drags for non-color feedback.                                                                                       |
| `FileUploadDescription`  | span                | Muted requirements line that joins the accessible name.                                                                                                             |
| `FileUploadTrigger`      | configurable button | Merges with `render={<Button />}` and disables at the file limit.                                                                                                   |
| `FileUploadAddMore`      | outline Button      | Plus-marked convenience wrapper around the trigger.                                                                                                                 |
| `FileUploadClear`        | link Button         | Renders nothing at zero or one file.                                                                                                                                |
| `FileUploadList`         | ul                  | Contains item rows plus rejection rows; `showImagePreviews={false}` uses file-type tiles.                                                                           |
| `FileUploadItem`         | li                  | Requires an `item` prop; sets row context for its children.                                                                                                         |
| `FileUploadItemPreview`  | img or tile         | Uses image bytes or `src`; `showImagePreview={false}` forces a file-type tile.                                                                                      |
| `FileUploadItemContent`  | div                 | Flex row holding name, metadata, status; wraps progress and reason below.                                                                                           |
| `FileUploadItemActions`  | div                 | Holds Retry and Remove in the row's action column.                                                                                                                  |
| `FileUploadItemName`     | span                | Truncates with title fallback; strongest weight in the row.                                                                                                         |
| `FileUploadItemMetadata` | span                | Human size joined with uppercase extension by a middle dot.                                                                                                         |
| `FileUploadItemStatus`   | span                | Waiting, uploading, uploaded, or failed text with a status icon.                                                                                                    |
| `FileUploadItemProgress` | Progress track      | Thin determinate or indeterminate bar labeled `Uploading {name}`.                                                                                                   |
| `FileUploadItemReason`   | span                | Shows `item.error` verbatim under the name.                                                                                                                         |
| `FileUploadItemRetry`    | link Button         | Renders for error rows when `onRetry` is set. Your handler owns the request.                                                                                        |
| `FileUploadItemRemove`   | ghost icon Button   | Becomes plain-text Remove on failed rows; focus target follows removals.                                                                                            |
| `FileUploadSummary`      | span                | Chosen-file echo for trigger-only layouts.                                                                                                                          |

### Types [#types]

| Export                | Shape                                                                                              |
| --------------------- | -------------------------------------------------------------------------------------------------- |
| `FileUploadItemData`  | `{ id: string; file: File; status?: FileUploadStatus; progress?: number \| null; error?: string }` |
| `FileUploadStatus`    | `"pending" \| "uploading" \| "success" \| "error"`                                                 |
| `FileUploadRejection` | `{ id: string; file: File; reason: string }`                                                       |
| `FileUploadAccept`    | `Record<string, string[]> \| string`                                                               |
| `FileUploadDragState` | `"idle" \| "valid" \| "invalid"`                                                                   |

Rows use HonestUI spacing, color, radius, and focus tokens. Test custom token values in every theme your application supports.
