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
| Prop | Type | Default | Description |
|---|---|---|---|
| accept | string | Native accept syntax, for example "image/*" or ".pdf,.csv". Applied to drops too. | |
| multiple | boolean | false | Allow more than one file. |
| maxSize | number | Largest file in bytes. Bigger files stay in the list, marked too large. | |
| onFiles | (files: File[]) => void | Fires with the full list after every drop, pick or remove. | |
| files | File[] | 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