Skip to content
goodtabs UI
BrowseBrowse components

File drop

A drop zone with a browse button and a list of the chosen files.

npx shadcn@latest add https://ui.goodtabs.co/r/file-drop.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/file-drop

Drop files here or

Several files, 5 MB each. Bigger ones stay in the list marked too large.

Images only, single

Drop files here or

One image, replaced on the next drop.

Controlled with files and onFiles. One image, replaced on the next drop.

Compact

AttachmentsPDF or CSV, up to 10 MB each.

Drop files here or

The sm size is one line, for a settings row or a toolbar.

Usage

import { FileDrop } from "@/components/ui/file-drop"

<FileDrop multiple accept="image/*" maxSize={5 * 1024 * 1024} onFiles={setFiles} />

Props

PropTypeDefaultDescription
acceptstringNative accept syntax, for example "image/*" or ".pdf,.csv". Applied to drops too.
multiplebooleanfalseAllow more than one file.
maxSizenumberLargest file in bytes. Bigger files stay in the list, marked too large.
onFiles(files: File[]) => voidFires with the full list after every drop, pick or remove.
filesFile[]Controlled list. Leave out to let the component keep its own.
size"sm" | "default""default"sm is one line with the icon and text side by side.

Source

file-drop/file-drop.tsx
"use client";

import * as React from "react";
import { FileIcon, UploadIcon, XIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

type FileDropProps = Omit<React.ComponentProps<"div">, "onDrop"> & {
  /** Same syntax as the native accept attribute, for example "image/*" or ".pdf,.docx". */
  accept?: string;
  /** Allow more than one file. The list grows with each drop. */
  multiple?: boolean;
  /** Largest file in bytes. Bigger files stay in the list, marked "Too large". */
  maxSize?: number;
  /** Fires with the full list after every drop, pick or remove. */
  onFiles?: (files: File[]) => void;
  /** Controlled list. Leave out and the component keeps its own. */
  files?: File[];
  /** sm is one line, for toolbars and settings rows. */
  size?: "sm" | "default";
};

function formatSize(bytes: number) {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
  if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}

/** Mirrors what the native file dialog does with accept, for files that arrive by drag. */
function matchesAccept(file: File, accept?: string) {
  if (!accept) return true;
  const name = file.name.toLowerCase();
  const type = file.type.toLowerCase();
  return accept
    .split(",")
    .map((rule) => rule.trim().toLowerCase())
    .filter(Boolean)
    .some((rule) => {
      if (rule.startsWith(".")) return name.endsWith(rule);
      if (rule.endsWith("/*")) return type.startsWith(rule.slice(0, -1));
      return type === rule;
    });
}

const fileKey = (file: File) => `${file.name}:${file.size}:${file.lastModified}`;

/**
 * A drop zone with a browse button and a list of the files chosen so far.
 * Works controlled through files and onFiles, or keeps its own list.
 */
export function FileDrop({
  accept,
  multiple = false,
  maxSize,
  onFiles,
  files,
  size = "default",
  className,
  ...props
}: FileDropProps) {
  const [inner, setInner] = React.useState<File[]>([]);
  const [dragging, setDragging] = React.useState(false);
  const inputRef = React.useRef<HTMLInputElement>(null);
  const depth = React.useRef(0);
  const controlled = files !== undefined;
  const list = controlled ? files : inner;

  const update = (next: File[]) => {
    if (!controlled) setInner(next);
    onFiles?.(next);
  };

  const add = (incoming: FileList | File[]) => {
    const picked = Array.from(incoming).filter((f) => matchesAccept(f, accept));
    if (picked.length === 0) return;
    if (!multiple) return update([picked[0]]);
    const seen = new Set(list.map(fileKey));
    update([...list, ...picked.filter((f) => !seen.has(fileKey(f)))]);
  };

  const remove = (index: number) => {
    update(list.filter((_, i) => i !== index));
    inputRef.current?.focus();
  };

  const small = size === "sm";

  return (
    <div className={cn("flex w-full flex-col gap-2", className)} {...props}>
      <div
        data-slot="file-drop-zone"
        data-dragging={dragging || undefined}
        onDragEnter={(e) => {
          e.preventDefault();
          depth.current += 1;
          setDragging(true);
        }}
        onDragOver={(e) => {
          e.preventDefault();
          e.dataTransfer.dropEffect = "copy";
        }}
        onDragLeave={() => {
          depth.current = Math.max(0, depth.current - 1);
          if (depth.current === 0) setDragging(false);
        }}
        onDrop={(e) => {
          e.preventDefault();
          depth.current = 0;
          setDragging(false);
          add(e.dataTransfer.files);
        }}
        className={cn(
          "group/zone flex items-center justify-center rounded-lg border border-dashed text-center text-sm text-muted-foreground transition-colors duration-100 ease-out data-dragging:bg-muted data-dragging:text-foreground/70",
          small ? "h-10 flex-row gap-2 px-3" : "flex-col gap-2 px-4 py-6",
        )}
      >
        <UploadIcon className={cn("shrink-0", small ? "size-4" : "size-5")} aria-hidden="true" />
        <p className="m-0">
          Drop files here or{" "}
          <Button
            type="button"
            variant="link"
            size="sm"
            onClick={() => inputRef.current?.click()}
            className="h-auto p-0 text-sm underline underline-offset-4 group-data-dragging/zone:text-foreground"
          >
            browse
          </Button>
        </p>
        <input
          ref={inputRef}
          type="file"
          accept={accept}
          multiple={multiple}
          tabIndex={-1}
          aria-hidden="true"
          className="sr-only"
          onChange={(e) => {
            if (e.target.files) add(e.target.files);
            e.target.value = "";
          }}
        />
      </div>
      {list.length > 0 && (
        <ul className="m-0 flex list-none flex-col gap-1 p-0">
          {list.map((file, i) => {
            const tooLarge = maxSize !== undefined && file.size > maxSize;
            return (
              <li
                key={fileKey(file)}
                data-too-large={tooLarge || undefined}
                className="flex h-8 items-center gap-2 rounded-md border pr-1 pl-2.5 text-sm"
              >
                <FileIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
                <span className="min-w-0 flex-1 truncate">{file.name}</span>
                <span className="shrink-0 font-mono text-xs text-muted-foreground">{formatSize(file.size)}</span>
                {tooLarge && <span className="shrink-0 font-mono text-xs text-foreground">Too large</span>}
                <Button
                  type="button"
                  variant="ghost"
                  size="icon-xs"
                  aria-label={`Remove ${file.name}`}
                  onClick={() => remove(i)}
                >
                  <XIcon aria-hidden="true" />
                </Button>
              </li>
            );
          })}
        </ul>
      )}
      <span className="sr-only" aria-live="polite">
        {list.length === 0 ? "" : `${list.length} ${list.length === 1 ? "file" : "files"} chosen`}
      </span>
    </div>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/file-drop/prompt.md, or the Copy AI prompt button above.

When to use

  • Uploading attachments, avatars, imports or any file people already have on disk
  • Forms where the chosen files should be visible and removable before submit
  • Settings rows that take one file, in the sm size

When not to use

  • A bare native input inside a dense form, where a plain Input type="file" is enough
  • Pasting or recording media, which needs a different capture flow
  • Showing upload progress: pair it with your own progress list after submit

Agent notes

  • It takes a function (onFiles), so a demo or page that passes one must be a client component
  • Files over maxSize are kept in the list and marked, not dropped, so filter by file.size before upload
  • Without multiple the list holds one file and a new drop replaces it; with multiple, drops append and exact duplicates are skipped
  • The native input is visually hidden and opened by the browse button; the zone itself is not focusable