# React File Upload: Drag and Drop, Paste, Camera, Progress

> Build a React file upload that takes drag and drop, paste and the phone camera, checks type and size, and shows a preview and progress bar for every file.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 16, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 11 min
- Canonical: https://wingo-ui.com/blog/react-file-upload

## TL;DR

A good React file upload accepts four inputs (drag and drop, the file picker, paste and the phone camera), validates every file before it uploads and lists rejected files with a reason instead of dropping them. Track progress with XMLHttpRequest, because its upload object fires progress events and fetch has none, and abort through an AbortSignal so cancel and remove really stop the request. The Wingo UI File Upload does all of this with one upload function prop, and the server must still check type and size again.

A file input looks done the moment the picker opens. Then the real requests arrive: drop files from the desktop, paste a screenshot, take a photo on a phone, see each file's progress, cancel the wrong one. A React file upload has to handle all four ways in, check every file before it leaves the browser, and say clearly why a file was refused. This guide covers what that takes, how progress and cancel work under the hood, and a complete example with a Next.js route handler, including the body size limits that break uploads in production. The examples use the [File Upload component](https://wingo-ui.com/components/file-upload) from Wingo UI, the library we build, so we are not neutral; outside facts link to their sources.

## What should a React file upload handle?

Four inputs, three checks and a status per file. Here is the list we built against, and a fair test for any library or hand-written version:

- **Drag and drop.** A file dragged anywhere over the page should light up the dropzone, and a drop that misses it should do nothing. Without that, the browser opens the file and the person loses the form.
- **Browse.** A click, Enter or Space on the dropzone opens the native picker. Drag and drop is the extra. The picker is the baseline that works everywhere.
- **Paste.** Ctrl+V or Cmd+V with focus inside adds copied files and screenshots.
- **Camera.** On a phone, a "Take photo" button that opens the camera directly.
- **Checks.** Accepted types, a size limit and a file count, plus your own rules, with a visible reason for every rejected file.
- **Status.** A preview for images, a progress bar per file, cancel, retry and remove.

Install the component and give it an accept list:

```bash
npx wingo-ui@latest add file-upload
```

```tsx
import { FileUpload } from "@/components/ui/file-upload";

export function Documents() {
  return (
    <FileUpload
      label="Property documents"
      description="PDFs or images, up to 10 MB each"
      accept="image/*,.pdf"
      maxFiles={8}
    />
  );
}
```

Without an `upload` function the files stay idle in the list, which is what you want for a form that submits them natively (more on that below).

> Live demo (File Upload): Drag a PDF or a photo from your desktop over the page, then focus the dropzone and paste a screenshot: each file gets a row with its size and a progress bar. Try it at [File Upload](https://wingo-ui.com/components/file-upload) and install it with `npx wingo-ui@latest add file-upload`.

## How does drag and drop work in a React file upload?

The browser fires `dragenter`, `dragover`, `dragleave` and `drop`, and the dropped files arrive in `event.dataTransfer`. Three details trip up hand-rolled React file upload drag and drop code. First, `dragover` must call `preventDefault()`, or the drop event never fires. Second, a drop outside your zone makes the browser open the file and leave the page. Third, `dragenter` and `dragleave` fire again for every child element the pointer crosses, so a plain boolean flickers; count enters and leaves and clear the highlight when the count is back to zero.

Our component handles the first two on the window: a drag that carries files lights up every dropzone on the page with "Drop to upload", and a drop that misses them is swallowed. It checks `dataTransfer.types` for `"Files"` first, so dragging text or a link around the page does nothing. It also skips folders. A dropped folder shows up in `dataTransfer.items` as an entry whose `webkitGetAsEntry()` reports `isDirectory`, and reading it with `getAsFile()` does not give you its contents. The component leaves it out instead of adding a broken row.

## How do you show a React file upload progress bar?

Use `XMLHttpRequest`. Its `upload` object fires `progress` events with `loaded` and `total` bytes. `fetch` has no upload progress event, so a fetch upload can only show a spinner. [MDN's XMLHttpRequest.upload page](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/upload) (checked October 2026) adds two pitfalls. Listeners must be registered before `send()` or no upload events fire, and browsers often need them before `open()`, so set them first. An upload listener also turns a cross-origin request into a non-simple one, which costs a CORS preflight.

The component takes one function, `upload(file, { signal, onProgress })`. You call `onProgress` with a fraction from 0 to 1, and you wire `signal` to the request so that cancel, remove and unmount abort it. Save this as `upload-file.ts`; the later examples import it:

```ts
import type { UploadHandler } from "@/components/ui/file-upload";

export const uploadFile: UploadHandler = (file, { signal, onProgress }) =>
  new Promise<{ url: string }>((resolve, reject) => {
    const request = new XMLHttpRequest();
    // listeners before open(): some browsers drop upload events otherwise
    request.upload.onprogress = (event) => {
      if (event.lengthComputable) onProgress(event.loaded / event.total);
    };
    request.onload = () => {
      // responseType "json" gives null instead of throwing on a bad body
      if (request.status < 300 && request.response) resolve(request.response);
      else reject(new Error(request.response?.error ?? "The upload failed"));
    };
    request.onerror = () => reject(new Error("No connection"));
    request.onabort = () => reject(new DOMException("Canceled", "AbortError"));
    signal.addEventListener("abort", () => request.abort(), { once: true });
    request.open("POST", "/api/upload");
    request.responseType = "json";
    const body = new FormData();
    body.append("file", file);
    request.send(body);
  });
```

The `onload` handler matters more than it looks. Calling `JSON.parse` there throws inside an event handler when the server answers with an HTML error page, the promise never settles and the row spins forever. With `responseType = "json"` a bad body is `null`, and the server's own `error` message reaches the row.

Progress updates are throttled to one render per 100ms per file, so a fast connection does not re-render the list hundreds of times a second. At most three files upload at once by default (`maxConcurrent`), and the rest wait their turn. Whatever the promise resolves with lands in the file's `response`. A rejection keeps the file in the list with its error message and a retry button. Set `autoUpload={false}` when people should review the list first: each row gets an upload button and the list an "Upload all" button.

## How do you handle paste and the phone camera?

For a React file upload paste handler, read `event.clipboardData.files`. That list holds copied files and screenshots taken to the clipboard. The catch is where to listen. An `onPaste` on the dropzone misses a paste made while focus sits on a row's remove button, so the component listens on the document and acts only when `document.activeElement` is inside its root. It calls `preventDefault()` only when the clipboard holds files, so pasting text into an input elsewhere on the page still works. `paste` is on by default; turn it off with `paste={false}`.

The camera is a second, hidden input with `capture="environment"` and `accept="image/*"`. [MDN's capture attribute page](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/capture) says `environment` asks for the outward-facing camera and `user` for the front one, and that a desktop browser usually shows the normal file picker instead. That is why `camera` adds its "Take photo" button on touch screens only:

```tsx
<FileUpload variant="grid" accept="image/*" maxFiles={12} camera upload={uploadFile} />
```

The grid layout is built for listing photos. The first tile is the cover, and you reorder by dragging, by a 250ms press on touch so the page still scrolls, or with Space and the arrow keys. Our post on [dnd-kit drag on touch screens](https://wingo-ui.com/blog/dnd-kit-mobile-touch-drag) explains why the press delay matters.

> Live demo (File Upload): Add a few photos, then drag a tile to the first spot to make it the cover; on a phone, press and hold a tile before you drag. Try it at [File Upload](https://wingo-ui.com/components/file-upload) and install it with `npx wingo-ui@latest add file-upload`.

## How do you validate files before they upload?

Use `accept`, `maxSize` and `maxFiles` for the common rules and `validate` for your own. `maxSize` defaults to 10 MB, so set it to `0` if you really want no limit. Every refused file is listed under the dropzone with a message ("You can add up to 6 files") and handed to `onReject` with a reason code (`"type"`, `"size"`, `"count"` or `"custom"`), so nothing disappears without an explanation. The checks run in that order per file: type, then size, then your `validate`, then the count, so a file that fails two rules gets the first message only. `accept` matches extensions case-insensitively and MIME wildcards like `image/*`; `acceptsFile(file, accept)` runs the same check outside the component, and `formatFileSize(bytes, locale)` formats sizes for your messages.

Treat all of it as feedback. `accept` only filters the picker, and anyone can post any file to your endpoint, so the server checks again. Also note that `file.type` comes from the file extension on the client and from the multipart header on the server. Both are claims the sender makes; if the type matters for security, read the first bytes of the file on the server.

## How do you upload files to a Next.js route handler?

Post each file to a route handler, check it again there, and store it under a name you generate. The client: documents, a duplicate check, a 10 MB limit and a callback for rejected files.

```tsx
"use client";

import { useState } from "react";
import { FileUpload, formatFileSize, type UploadFile } from "@/components/ui/file-upload";
import { uploadFile } from "./upload-file";

export function ClaimDocuments() {
  const [files, setFiles] = useState<UploadFile[]>([]);

  return (
    <FileUpload
      label="Claim documents"
      description="PDFs or photos, up to 10 MB each"
      accept="image/*,.pdf"
      maxSize={10 * 1024 * 1024}
      maxFiles={6}
      camera
      value={files}
      onValueChange={setFiles}
      upload={uploadFile}
      validate={(file) =>
        files.some((f) => f.name === file.name && f.size === file.size)
          ? `${file.name} (${formatFileSize(file.size, "en-US")}) is already in the list`
          : null
      }
      onReject={(rejections) => console.warn(rejections.map((r) => r.message))}
      locale="en-US"
    />
  );
}
```

The server: a route handler in `app/api/upload/route.ts` that repeats the checks before it stores anything.

```ts
import { saveToStorage } from "@/lib/storage"; // your own helper for S3, R2 or disk

const MAX_SIZE = 10 * 1024 * 1024;
// allowed types and the extension each one is stored with
const EXTENSIONS: Record<string, string> = {
  "application/pdf": "pdf",
  "image/jpeg": "jpg",
  "image/png": "png",
  "image/webp": "webp",
  "image/heic": "heic",
};

export async function POST(request: Request) {
  const form = await request.formData();
  const file = form.get("file");
  if (!(file instanceof File)) return Response.json({ error: "No file" }, { status: 400 });
  if (file.size > MAX_SIZE) return Response.json({ error: "File too large" }, { status: 413 });
  const extension = EXTENSIONS[file.type];
  if (!extension) return Response.json({ error: "File type not allowed" }, { status: 415 });

  const bytes = new Uint8Array(await file.arrayBuffer());
  const key = `${crypto.randomUUID()}.${extension}`;
  await saveToStorage(key, bytes);
  return Response.json({ url: `/uploads/${key}` });
}
```

The key never uses `file.name`. That name is whatever the client sent, so `../../app.config.js` must never reach a disk path; the extension comes from the allowed type instead. The `{ error }` body is what the upload function above shows on the failed row.

A route handler is the right place for the upload endpoint. The [Next.js Server Actions docs](https://nextjs.org/docs/app/guides/server-actions) (checked October 2026) cap action requests at 1MB by default (`serverActions.bodySizeLimit` raises it), and an action gives you no upload progress anyway.

### Why do uploads over 4.5 MB fail on Vercel?

Hosting adds its own limit. [Vercel's function limits page](https://vercel.com/docs/functions/limitations) (checked October 2026) caps the request body of a Vercel Function at 4.5 MB and answers bigger bodies with a 413 `FUNCTION_PAYLOAD_TOO_LARGE`, so the 10 MB route above works locally and fails in production there. The fix is to keep the file away from the function: the route returns a presigned URL from your storage (S3 and R2 both offer them), and the upload function sends the file straight to it. Progress and cancel keep working, because it is still an `XMLHttpRequest`:

```ts
import type { UploadHandler } from "@/components/ui/file-upload";

export const uploadDirect: UploadHandler = async (file, { signal, onProgress }) => {
  // your route: checks type and size, returns a presigned PUT url and the public url
  const response = await fetch("/api/upload-url", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ name: file.name, type: file.type, size: file.size }),
    signal,
  });
  if (!response.ok) throw new Error("The upload was refused");
  const { uploadUrl, url } = (await response.json()) as { uploadUrl: string; url: string };

  await new Promise<void>((resolve, reject) => {
    const request = new XMLHttpRequest();
    request.upload.onprogress = (event) => {
      if (event.lengthComputable) onProgress(event.loaded / event.total);
    };
    request.onload = () => (request.status < 300 ? resolve() : reject(new Error("The upload failed")));
    request.onerror = () => reject(new Error("No connection"));
    request.onabort = () => reject(new DOMException("Canceled", "AbortError"));
    signal.addEventListener("abort", () => request.abort(), { once: true });
    request.open("PUT", uploadUrl);
    request.setRequestHeader("Content-Type", file.type);
    request.send(file);
  });
  return { url };
};
```

The bucket needs a CORS rule that allows `PUT` and the `Content-Type` header from your origin. The upload listener makes the request non-simple, so the browser sends a preflight first, and a missing rule shows up as the "No connection" error from `onerror`, with the real cause only in the browser console. If your presigned URL signs a content type, send exactly that one, or the storage rejects the signature.

### Can you submit the files with a plain form instead?

Give the component a `name` and leave out `upload`. It keeps a hidden file input whose files follow the list through `DataTransfer`, so a native form submit or `new FormData(form)` sends exactly the files on screen, and `required` blocks an empty submit. Our [React form components guide](https://wingo-ui.com/blog/react-form-components-guide) covers the field anatomy around it: label, description and the animated error.

## How do you show files that are already uploaded?

Pass them as `defaultValue` or `value` with a `url` and `status: "done"`, and they show as finished rows. When the files live on a separate screen, such as a client folder or email attachments, the [File List component](https://wingo-ui.com/components/file-list) is the better fit: rows, preview tiles or chips, download and delete with a confirm dialog, selection, and progress for files still uploading. The `UploadFile[]` that File Upload reports fits its `files` prop as it is.

## How do you crop a photo before it uploads?

Put the [Image Cropper](https://wingo-ui.com/components/image-cropper) between the pick and the upload. It takes a `File` as `src`, offers aspect presets, pinch or wheel zoom and rotation, and hands you an API through `setApi` whose `toBlob` exports a JPEG, PNG or WebP:

```tsx
"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";
import { ImageCropper, type ImageCropperApi } from "@/components/ui/image-cropper";

export function CoverPhoto({ photo, onSave }: { photo: File; onSave: (file: File) => void }) {
  const [api, setApi] = useState<ImageCropperApi | null>(null);

  const save = async () => {
    if (!api) return;
    const blob = await api.toBlob({ type: "image/jpeg", quality: 0.9, maxSize: 1600 });
    onSave(new File([blob], "cover.jpg", { type: "image/jpeg" }));
  };

  return (
    <div className="flex max-w-xl flex-col gap-4">
      <ImageCropper src={photo} defaultAspect={4 / 3} setApi={setApi} />
      <Button onClick={save}>Save crop</Button>
    </div>
  );
}
```

`maxSize` caps the longest side and never upscales, so a 12 megapixel phone photo does not travel at full size. The cropper exports through a canvas, so the result carries no EXIF data, which also strips the GPS location a phone writes into photos. For a profile photo, [Avatar Upload](https://wingo-ui.com/components/avatar-upload) already combines the picker, a round crop and the upload in one component. The [React image cropper guide](https://wingo-ui.com/blog/react-image-cropper) covers zoom, rotation, the export size and that avatar flow in more detail.

> Live demo (Image Cropper): Drag the photo under the frame, pinch or scroll to zoom, then switch the ratio to 1:1 and export the crop. Try it at [Image Cropper](https://wingo-ui.com/components/image-cropper) and install it with `npx wingo-ui@latest add image-cropper`.

## Is the file upload accessible by keyboard and on phones?

Yes. The dropzone is a real focusable control: Tab reaches it, Enter or Space opens the picker, and clicking the label focuses it without opening the picker. A polite live region announces uploaded, failed, rejected and removed files, and every reorder in the grid. On a phone the dropzone reads "Tap to choose files", remove buttons on photos are always visible instead of appearing on hover, and every action has a 44px target, the size our [touch target guide](https://wingo-ui.com/blog/minimum-touch-target-size-react-tailwind) explains.

## Should you use a shadcn file upload instead?

As of October 2026, shadcn/ui has no dropzone of its own. Its [Attachment component](https://ui.shadcn.com/docs/components/attachment) displays a file with its upload state and actions, but you build the picker, the drop handling and the progress math yourself. So a shadcn file upload usually means a community registry item. [Dice UI's File Upload](https://diceui.com/docs/components/file-upload) is a composable set of parts (dropzone, trigger, list, item preview, item progress) with validation, installed with `npx shadcn@latest add @diceui/file-upload`. If you want to assemble those parts yourself, it is a good choice. Wingo UI's File Upload is one element configured by props, with four layouts (list, grid, avatar, compact), paste, the camera button, grid reordering, an upload queue and a native form fallback built in.

## Next step

Install it with `npx wingo-ui@latest add file-upload file-list image-cropper`. All three are part of Wingo UI Pro ([pricing](https://wingo-ui.com/pricing)); the [File Upload docs](https://wingo-ui.com/components/file-upload) have the playground and every prop. More component guides live in the [component guides category](https://wingo-ui.com/blog/category/components).

## Components in this post

- [File Upload](https://wingo-ui.com/components/file-upload) (Pro): A React file upload with drag and drop, paste and camera capture: validation, previews, per-file progress, and list, grid or avatar layouts. Install: `npx wingo-ui@latest add file-upload`
- [File List](https://wingo-ui.com/components/file-list) (Pro): A React file list for attachments and documents: rows, preview tiles or chips with type icons, thumbnails, upload progress and file actions. Install: `npx wingo-ui@latest add file-list`
- [Image Cropper](https://wingo-ui.com/components/image-cropper) (Pro): A React image cropper for photos before upload: aspect presets, pinch or wheel zoom, rotation, a thirds grid and a JPEG, PNG or WebP export. Install: `npx wingo-ui@latest add image-cropper`

## FAQ

### How do I show upload progress in React?

Send the file with XMLHttpRequest and listen to request.upload.onprogress, which reports loaded and total bytes. fetch has no upload progress event, so a fetch-based upload can only show a spinner. Register the listener before open(), since MDN warns that browsers often skip upload events for listeners added later.

### How do I paste images into a React file upload?

Listen to the paste event and read event.clipboardData.files, which holds copied files and screenshots. Attach the listener to the document and act only when document.activeElement is inside your component, so a paste works whether focus sits on the dropzone or on a row's button.

### How do I open the camera from a file input on mobile?

Add capture="environment" for the back camera or capture="user" for the front one, together with accept="image/*". MDN notes that desktop browsers usually show the normal file picker instead, so offer the camera as an extra button on touch screens.

### Is the accept attribute enough to validate uploads?

No. accept only filters the picker; a dropped or pasted file skips it, and anyone can send any file to your endpoint. Check type and size in the component for fast feedback and again on the server before you store the file.

### Is there a shadcn file upload component?

As of October 2026, shadcn/ui has an Attachment component that displays files and their upload state, but no dropzone or file picker. Community registries such as Dice UI fill that gap, and Wingo UI's File Upload installs the same way, as source code, with npx wingo-ui@latest add file-upload.

### Why do large uploads fail on Vercel?

Vercel Functions accept request bodies up to 4.5 MB and answer anything larger with a 413 error. For bigger files, have your route return a presigned storage URL and send the file straight to storage from the browser.

---

Source: https://wingo-ui.com/blog/react-file-upload
