# File Upload

> A dropzone that wakes when files cross the page and lifts as they reach it. Rows rise in with per-file progress on a spring, and refused files are explained inline.

- Tier: Free (MIT)
- Interaction: Drag
- Page: https://eased.dev/components/file-upload
- Installs: motion
- Registry: https://eased.dev/r/file-upload.json

## Install

```bash
npx shadcn add https://eased.dev/r/file-upload.json
```

A dropzone and its file list: click, drop or paste to add files, with accept, size and count checks, per-file progress from your upload function, retry and remove.

## When to use

- Forms that collect documents: a lease, an expense claim, an onboarding pack.
- Uploads where people need to see each file's progress and what went wrong with it.
- Pages where files may be pasted from the clipboard as well as dropped.

## When not to use

- Attachments in a chat composer: use [Attachment](/components/attachment), which shows thumbnails inline.
- A single avatar or logo picker: use a button that opens the file picker and a preview.

## Usage

```tsx lease-upload.tsx
import { FileUpload, type FileUploadFn } from "@/components/eased/file-upload/file-upload";

const upload: FileUploadFn = (file, { onProgress, signal }) =>
  new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("PUT", `/api/uploads/${encodeURIComponent(file.name)}`);
    xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded / e.total);
    xhr.onload = () => (xhr.status < 300 ? resolve(undefined) : reject(new Error("Upload failed")));
    xhr.onerror = () => reject(new Error("Connection lost"));
    signal.addEventListener("abort", () => xhr.abort());
    xhr.send(file);
  });

export function LeaseUpload() {
  return <FileUpload upload={upload} accept="application/pdf,image/*" maxSize={5_000_000} />;
}
```

## Examples

### Adding files from your own code

Create the controller with `useFileUpload` and pass it in; `add` takes any list of `File`s.

```tsx
const files = useFileUpload({ upload, accept: "application/pdf" });
<FileUpload controller={files} />
<Button onClick={() => files.add(picked)}>Import from Drive</Button>
```

## API reference

### FileUpload

| Prop | Type | Default | Description |
|---|---|---|---|
| `upload` | `(file, { onProgress, signal, item }) => Promise<unknown>` | | Uploads one file. Report 0–1 progress, resolve when stored, reject with an `Error` whose message the row shows. Without it, files are listed as done. |
| `accept` | `string` | | `<input accept>` syntax. Other files are refused with a message under the zone. |
| `maxSize` | `number` | | Bytes. Larger files are listed with an error so the reason is visible. |
| `maxFiles` | `number` | | Files past this count are refused. |
| `value` / `defaultValue` | `FileUploadItem[]` | | The list, controlled or uncontrolled. |
| `onValueChange` | `(items) => void` | | |
| `onReject` | `(rejected) => void` | | Files refused for type or count. |
| `controller` | `FileUploadController` | | From `useFileUpload`; its options win. |
| `multiple` | `boolean` | `true` | |
| `disabled` | `boolean` | `false` | |
| `title` | `ReactNode` | `"Drop files here"` | Followed by "or browse". |
| `hint` | `ReactNode` | accepted types and size | |
| `listLabel` | `string` | `"Files"` | Names the list. |
| `children` | `ReactNode` | | Rendered after the list. |

`FileUploadItem` is `{ id, name, size, type, status: "uploading" \| "done" \| "error", progress?, error?, retryable?, file? }`. `useFileUpload` returns `{ items, add, remove, retry, clear, rejection }`. `formatBytes`, `matchesAccept` and `describeAccept` are exported too.

## Keyboard

| Key | Action |
|---|---|
| `Tab` | Moves to the dropzone, then each row's Retry and Remove buttons. |
| `Enter` / `Space` | On the dropzone, opens the file picker. |
| `⌘ V` / `Ctrl V` | With focus inside, adds files from the clipboard. |

## Accessibility

- The dropzone is a button described by its hint and by the refusal message.
- Refusals are announced from a live region that stays mounted.
- Each row is a group named with its file, status and progress; buttons say "Cancel", "Retry" or "Remove" with the file name.
- Removing a row with the keyboard moves focus to the next row's Remove button, or back to the dropzone.

## Motion

A file dragged anywhere on the page wakes the zone; over it the icon lifts 2px on the snappy spring, the title swaps to "Drop 3 files" and a soft light follows the pointer. A drag of the wrong type turns the zone danger before the drop. Rows rise 8px from a 2px blur while the list's height springs (smooth, no overshoot); progress bars scale on the smooth spring; a finished row swaps its bar for the size and a check that draws once. Removed rows fade out in 100ms and the others close the gap. Reduced motion keeps opacity changes only.

## Notes for AI

Pick File Upload for any form that collects documents. Pass `upload` and call your storage API inside it; the component handles the list, progress, aborting on remove and retry. Use `accept` and `maxSize` rather than validating yourself.

Don't wrap it in another drop target or add your own paste listener, and don't render a separate progress bar per file: rows already have one. It does not upload anything without your `upload` function.

## Related

- [Attachment](/components/attachment): thumbnails and chips for a chat composer.
- [Progress](/components/progress): a single labelled progress bar.

## Example

`components/eased/file-upload/file-upload-demo.tsx`

```tsx
"use client";

import { useRef } from "react";
import { scrollFadeMask, useScrollEdges } from "../_primitives/scroll-fade/scroll-fade";
import { Button } from "../button/button";
import { DemoFrame } from "../lib/demo-frame";
import { FileUpload, useFileUpload, type FileUploadFn, type FileUploadItem } from "./file-upload";

const MB = 1_000_000;

const SEED: FileUploadItem[] = [
  { id: "seed-brand", name: "brand-guidelines-2026.pdf", size: 3.1 * MB, type: "application/pdf", status: "done" },
  { id: "seed-id", name: "photo-id-maya-okafor.jpg", size: 640_000, type: "image/jpeg", status: "done" },
];

// Fails once, the first time, so Retry has something to do.
const failedOnce = new Set<string>();

/** A pretend upload: progress in uneven steps, faster for small files; one file drops its connection once. */
const simulateUpload: FileUploadFn = (file, { onProgress, signal }) =>
  new Promise((resolve, reject) => {
    let done = 0;
    const rate = 0.9 * MB + Math.random() * 0.6 * MB; // bytes per second
    const flaky = file.name.includes("lease") && !failedOnce.has(file.name);
    const timer = window.setInterval(() => {
      done += (rate / 8) * (0.5 + Math.random());
      const fraction = Math.min(1, done / Math.max(1, file.size));
      if (flaky && fraction > 0.58) {
        window.clearInterval(timer);
        failedOnce.add(file.name);
        reject(new Error("Connection lost at 58%"));
        return;
      }
      onProgress(fraction);
      if (fraction >= 1) {
        window.clearInterval(timer);
        resolve(undefined);
      }
    }, 125);
    signal.addEventListener("abort", () => window.clearInterval(timer));
  });

const sample = (name: string, size: number, type: string) => new File([new ArrayBuffer(size)], name, { type });

/** Upload documents for a lease: browse, drop or paste, or try the samples. */
export function FileUploadDemo() {
  const upload = useFileUpload({ upload: simulateUpload, accept: "application/pdf,image/*", maxSize: 5 * MB, maxFiles: 8, defaultValue: SEED });
  const scroller = useRef<HTMLDivElement>(null);
  useScrollEdges(scroller);
  const done = upload.items.filter((f) => f.status === "done").length;

  return (
    <DemoFrame
      title="Lease documents"
      meta={`${done} of ${upload.items.length}`}
      actions={
        <Button
          variant="ghost"
          size="sm"
          edge="end"
          disabled={upload.items.some((f) => f.name === "signed-lease-agreement.pdf")}
          onClick={() =>
            upload.add([
              sample("signed-lease-agreement.pdf", 1.8 * MB, "application/pdf"),
              sample("unit-4b-floorplan.png", 2.4 * MB, "image/png"),
              sample("move-in-inspection.jpg", 0.9 * MB, "image/jpeg"),
              sample("drone-survey-raw.png", 6.2 * MB, "image/png"),
            ])
          }
        >
          Try samples
        </Button>
      }
    >
      <div ref={scroller} style={scrollFadeMask("y", 20)} className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4">
        <FileUpload controller={upload} listLabel="Lease documents" />
      </div>
    </DemoFrame>
  );
}
```
