# File Dropzone

A drag-and-drop file input with previews, validation, and per-file progress.

## Installation

```bash
npx shadcn@latest add https://mwui.vercel.app/r/file-dropzone.json
```

[Registry JSON](https://mwui.vercel.app/r/file-dropzone.json)

## Preview

```tsx
import { FileDropzone } from "@/components/ui/file-dropzone";

export function Preview() {
  return (
    <div className="w-full max-w-md">
      <FileDropzone
        accept="image/*,.pdf"
        maxSize={2 * 1024 * 1024}
        maxFiles={4}
        label="Drop attachments here"
        onUpload={(_item, { onProgress, signal }) =>
          new Promise<void>((resolve, reject) => {
            let step = 0;

            const timer = setInterval(() => {
              step += 1;
              onProgress(step / 12);

              if (signal.aborted) {
                clearInterval(timer);
                resolve();
                return;
              }

              if (step >= 12) {
                clearInterval(timer);

                if (Math.random() < 0.25) {
                  reject(new Error("The server rejected this file."));
                  return;
                }

                resolve();
              }
            }, 120);
          })
        }
      />
    </div>
  );
}
```


## Source

### ui/file-dropzone.tsx

```tsx
"use client";

import { IconAlertCircle, IconFile, IconRefresh, IconUpload, IconX } from "@tabler/icons-react";
import * as React from "react";

import { Button } from "@/components/ui/button";
import {
  formatBytes,
  useFileUpload,
  type UploadFile,
  type UseFileUploadOptions,
} from "@/hooks/use-file-upload";
import { cn } from "@/lib/utils";

type FileDropzoneProps = Omit<React.ComponentProps<"div">, "onChange"> & {
  accept?: string;
  maxSize?: number;
  maxFiles?: number;
  multiple?: boolean;
  disabled?: boolean;
  label?: string;
  description?: string;
  onUpload?: UseFileUploadOptions["onUpload"];
  onFilesChange?: (files: UploadFile[]) => void;
};

function FileDropzone({
  accept,
  maxSize,
  maxFiles,
  multiple = true,
  disabled = false,
  label = "Drop files here",
  description,
  onUpload,
  onFilesChange,
  className,
  ...props
}: FileDropzoneProps) {
  const upload = useFileUpload({
    accept,
    maxSize,
    maxFiles,
    multiple,
    disabled,
    onUpload,
    autoUpload: onUpload !== undefined,
    onFilesChange,
  });

  const hint =
    description ??
    [
      accept ? describeAccept(accept) : null,
      maxSize ? `up to ${formatBytes(maxSize)}` : null,
      maxFiles ? `${maxFiles} files max` : null,
    ]
      .filter(Boolean)
      .join(" · ");

  return (
    <div
      data-slot="file-dropzone"
      className={cn("flex w-full flex-col gap-2", className)}
      {...props}
    >
      <div
        {...upload.getDropzoneProps()}
        data-dragging={upload.isDragging || undefined}
        data-disabled={disabled || undefined}
        className={cn(
          "flex flex-col items-center justify-center gap-2 rounded-xl border border-dashed border-border bg-background px-6 py-8 text-center transition-colors",
          "data-dragging:border-ring data-dragging:bg-muted/50",
          "data-disabled:pointer-events-none data-disabled:opacity-50",
        )}
      >
        <div className="flex size-9 items-center justify-center rounded-full bg-muted text-muted-foreground">
          <IconUpload className="size-4" />
        </div>

        <div className="flex flex-col gap-0.5">
          <p className="text-sm font-medium">{label}</p>
          {hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
        </div>

        <input {...upload.getInputProps()} />

        <Button type="button" size="sm" variant="outline" onClick={upload.openFileDialog}>
          Browse files
        </Button>
      </div>

      {upload.rejections.length > 0 ? (
        <ul className="flex flex-col gap-1">
          {upload.rejections.map((rejection) => (
            <li
              key={`${rejection.file.name}-${rejection.reason}`}
              className="flex items-center gap-1.5 text-xs text-destructive"
            >
              <IconAlertCircle className="size-3.5 shrink-0" />
              {rejection.message}
            </li>
          ))}
        </ul>
      ) : null}

      {upload.files.length > 0 ? (
        <ul className="flex flex-col gap-2">
          {upload.files.map((item) => (
            <li
              key={item.id}
              data-status={item.status}
              className="flex items-center gap-3 rounded-lg border border-border p-2 data-[status=error]:border-destructive/40"
            >
              <FilePreview file={item} />

              <div className="flex min-w-0 flex-1 flex-col gap-1">
                <div className="flex items-baseline justify-between gap-2">
                  <span className="truncate text-sm">{item.file.name}</span>
                  <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
                    {formatBytes(item.file.size)}
                  </span>
                </div>

                {item.status === "error" ? (
                  <span className="text-xs text-destructive">{item.error}</span>
                ) : onUpload ? (
                  <div
                    role="progressbar"
                    aria-label={`Uploading ${item.file.name}`}
                    aria-valuenow={Math.round(item.progress * 100)}
                    className="h-1 overflow-hidden rounded-full bg-muted"
                  >
                    <div
                      className="h-full rounded-full bg-primary transition-[width] duration-200 ease-out"
                      style={{ width: `${item.progress * 100}%` }}
                    />
                  </div>
                ) : null}
              </div>

              {item.status === "error" ? (
                <Button
                  type="button"
                  size="icon-xs"
                  variant="ghost"
                  aria-label={`Retry ${item.file.name}`}
                  onClick={() => upload.retryFile(item.id)}
                >
                  <IconRefresh />
                </Button>
              ) : null}

              <Button
                type="button"
                size="icon-xs"
                variant="ghost"
                aria-label={`Remove ${item.file.name}`}
                onClick={() => upload.removeFile(item.id)}
              >
                <IconX />
              </Button>
            </li>
          ))}
        </ul>
      ) : null}
    </div>
  );
}

function FilePreview({ file }: { file: UploadFile }) {
  if (file.previewUrl) {
    return (
      <img
        src={file.previewUrl}
        alt=""
        className="size-9 shrink-0 rounded-md border border-border object-cover"
      />
    );
  }

  return (
    <div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
      <IconFile className="size-4" />
    </div>
  );
}

function describeAccept(accept: string): string {
  const parts = accept
    .split(",")
    .map((part) => part.trim())
    .filter(Boolean)
    .map((part) => (part.endsWith("/*") ? `${part.slice(0, -2)}s` : part.replace(/^\./u, "")));

  return parts.join(", ").toUpperCase();
}

export { FileDropzone, type FileDropzoneProps };
```



## Usage

A drop target, a real file input, image thumbnails, size and type validation, and a progress row per
file. All of the state lives in [useFileUpload](/utilities/use-file-upload), so the component is
markup — swap it for your own layout without rewriting the logic.

```tsx
import { FileDropzone } from "@/components/ui/file-dropzone";

export function Attachments() {
  return (
    <FileDropzone
      accept="image/*,.pdf"
      maxSize={5 * 1024 * 1024}
      maxFiles={5}
      onFilesChange={setAttachments}
    />
  );
}
```

Without `onUpload` the dropzone is a collector: it validates and lists files and hands them to
`onFilesChange`, and you submit them with the rest of your form.

## Uploading

Pass `onUpload` and each accepted file uploads immediately, with a progress bar, an error state, and
a retry button.

```tsx
<FileDropzone
  accept="image/*"
  onUpload={async (item, { onProgress, signal }) => {
    const body = new FormData();
    body.append("file", item.file);

    await fetch("/api/upload", { method: "POST", body, signal });
    onProgress(1);
  }}
/>
```

`signal` aborts if the file is removed mid-flight, so cancelling actually cancels. Real byte-level
progress needs `XMLHttpRequest` — `fetch` cannot report it — and `onProgress` takes a fraction from
`0` to `1`.

## Validation

`accept` is enforced on drop as well as in the picker. The browser applies the `accept` attribute
only to the file dialog, so a dragged `.exe` would otherwise sail through.

Rejected files never enter the list; they render above it with a specific reason — wrong type, too
large, over the file count, or already added.

## Props

| Prop            | Description                                                              |
| --------------- | ------------------------------------------------------------------------ |
| `accept`        | Same syntax as the `accept` attribute: `"image/*,.pdf"`.                  |
| `maxSize`       | Per-file limit in bytes.                                                  |
| `maxFiles`      | Maximum number of accepted files.                                         |
| `multiple`      | When `false`, a new selection replaces the current file.                   |
| `onUpload`      | Upload transport. Enables progress, errors, and retry.                    |
| `onFilesChange` | Called with the full list after every change.                             |
| `label`         | Headline inside the drop target.                                          |
| `description`   | Overrides the hint line, which is otherwise derived from the constraints. |
| `disabled`      | Ignore selection and drops.                                               |

## Behavior

- Drag state uses enter/leave depth counting, so dragging across a child element does not flicker the
  highlight.
- Image previews are object URLs, revoked when the file is removed or the component unmounts.
- The file input stays in the DOM and is visually hidden rather than replaced by a button, so
  keyboard and assistive-technology users get the native picker.

