Drop files here or click to browsePDF, CSV, MD, PNG
- q3-report.pdf1.2 MB
- customers.csv80 KB
import * as React from "react";
import { FileDrop, FileItem, FileList, type DroppedFile } from "@/components/pandacoderz-ui/file-drop";
export default function FileDropDemo() {
const [files, setFiles] = React.useState<DroppedFile[]>([
{ id: "a", name: "q3-report.pdf", size: 1_240_000, type: "application/pdf" },
{ id: "b", name: "customers.csv", size: 82_000, type: "text/csv" },
]);
const addFiles = (incoming: File[]) => {
const next = incoming.map((f) => ({ id: `${f.name}-${Date.now()}`, name: f.name, size: f.size, type: f.type, progress: 0 }));
setFiles((prev) => [...prev, ...next]);
// Fake an upload so the progress bar has something to show.
next.forEach((f) => {
let p = 0;
const id = setInterval(() => {
p += 12 + Math.random() * 20;
setFiles((prev) => prev.map((x) => (x.id === f.id ? { ...x, progress: Math.min(100, p) } : x)));
if (p >= 100) clearInterval(id);
}, 180);
});
};
return (
<div className="flex w-full max-w-md flex-col gap-3">
<FileDrop onFiles={addFiles} accept=".pdf,.csv,.md,.png" />
<FileList>
{files.map((f) => (
<FileItem key={f.id} file={f} onRemove={(id) => setFiles((prev) => prev.filter((x) => x.id !== id))} />
))}
</FileList>
</div>
);
}Installation
npx shadcn@latest add https://ui.spencerwueste.com/r/file-drop.jsonpnpm dlx shadcn@latest add https://ui.spencerwueste.com/r/file-drop.jsonyarn dlx shadcn@latest add https://ui.spencerwueste.com/r/file-drop.jsonbunx --bun shadcn@latest add https://ui.spencerwueste.com/r/file-drop.jsonInstall the dependencies:
npm install @heroicons-animated/react motionCopy the source into your project:
"use client";
import * as React from "react";
import {
ArrowUpTrayIcon,
DocumentIcon,
DocumentTextIcon,
PhotoIcon,
TableCellsIcon,
XMarkIcon,
} from "@heroicons-animated/react";
import { cn } from "@/lib/utils";
export type DroppedFile = {
id: string;
name: string;
size: number;
type: string;
/** 0–100 while uploading; omit when done. */
progress?: number;
error?: string;
};
export function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB"];
let v = bytes / 1024;
let u = 0;
while (v >= 1024 && u < units.length - 1) {
v /= 1024;
u++;
}
return `${v.toFixed(v >= 10 ? 0 : 1)} ${units[u]}`;
}
function FileTypeIcon({ type, name, size = 16, className }: { type: string; name?: string; size?: number; className?: string }) {
const ext = name?.split(".").pop()?.toLowerCase() ?? "";
if (type.startsWith("image/")) return <PhotoIcon size={size} className={className} />;
if (type.includes("csv") || type.includes("sheet") || ["csv", "xlsx"].includes(ext)) return <TableCellsIcon size={size} className={className} />;
if (type.includes("pdf") || type.startsWith("text/") || ["pdf", "md", "txt", "docx"].includes(ext)) return <DocumentTextIcon size={size} className={className} />;
return <DocumentIcon size={size} className={className} />;
}
type FileDropProps = Omit<React.ComponentProps<"div">, "onDrop"> & {
onFiles: (files: File[]) => void;
accept?: string;
multiple?: boolean;
disabled?: boolean;
/** Compact single-line variant. */
compact?: boolean;
};
function FileDrop({ onFiles, accept, multiple = true, disabled, compact, className, children, ...props }: FileDropProps) {
const [dragging, setDragging] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const handleFiles = (list: FileList | null) => {
if (!list) return;
const files = Array.from(list);
onFiles(multiple ? files : files.slice(0, 1));
};
return (
<div
data-slot="file-drop"
data-dragging={dragging || undefined}
role="button"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled}
onClick={() => !disabled && inputRef.current?.click()}
onKeyDown={(e) => {
if (disabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
inputRef.current?.click();
}
}}
onDragOver={(e) => {
if (disabled) return;
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
if (disabled) return;
e.preventDefault();
setDragging(false);
handleFiles(e.dataTransfer.files);
}}
className={cn(
"flex w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border border-dashed bg-surface text-center transition-colors outline-none hover:border-brand/50 hover:bg-brand-soft/30 focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[dragging]:border-brand data-[dragging]:bg-brand-soft/50",
compact ? "flex-row px-4 py-3" : "px-6 py-10",
disabled && "pointer-events-none opacity-50",
className,
)}
{...props}
>
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
className="sr-only"
tabIndex={-1}
onChange={(e) => {
handleFiles(e.target.files);
e.target.value = "";
}}
/>
{children ?? (
<>
<span className={cn("flex items-center justify-center rounded-full bg-brand-soft text-brand", compact ? "size-8" : "size-10")}>
<ArrowUpTrayIcon size={compact ? 14 : 18} className="flex" />
</span>
<div className={cn("flex flex-col", compact ? "items-start" : "items-center gap-0.5")}>
<span className="text-sm font-medium">Drop files here or click to browse</span>
<span className="text-xs text-muted-foreground">{accept ? accept.replace(/\./g, "").toUpperCase().replace(/,/g, ", ") : "Any file type"}</span>
</div>
</>
)}
</div>
);
}
function FileList({ className, ...props }: React.ComponentProps<"ul">) {
return <ul data-slot="file-list" className={cn("flex flex-col gap-2", className)} {...props} />;
}
type FileItemProps = Omit<React.ComponentProps<"li">, "children"> & {
file: DroppedFile;
onRemove?: (id: string) => void;
selected?: boolean;
};
function FileItem({ file, onRemove, selected, className, ...props }: FileItemProps) {
const uploading = file.progress !== undefined && file.progress < 100;
return (
<li
data-slot="file-item"
data-selected={selected || undefined}
className={cn(
"flex items-center gap-3 rounded-xl border bg-card px-3 py-2 text-sm transition-colors data-[selected]:border-brand/50 data-[selected]:bg-brand-soft/30",
file.error && "border-red-500/40",
className,
)}
{...props}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<FileTypeIcon type={file.type} name={file.name} size={16} className="flex" />
</span>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center justify-between gap-2">
<span className="truncate font-medium">{file.name}</span>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{file.error ?? (uploading ? `${Math.round(file.progress ?? 0)}%` : formatBytes(file.size))}
</span>
</div>
{uploading ? (
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-brand transition-[width] duration-300" style={{ width: `${file.progress}%` }} />
</div>
) : null}
</div>
{onRemove ? (
<button
type="button"
aria-label={`Remove ${file.name}`}
onClick={(e) => {
e.stopPropagation();
onRemove(file.id);
}}
className="flex size-6 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground"
>
<XMarkIcon size={14} className="flex" />
</button>
) : null}
</li>
);
}
export { FileDrop, FileList, FileItem, FileTypeIcon };Usage
import { FileDrop, FileItem, FileList, type DroppedFile } from "@/components/pandacoderz-ui/file-drop";<FileDrop onFiles={upload} accept=".pdf,.md" />
<FileList>
{files.map((f) => <FileItem key={f.id} file={f} onRemove={remove} />)}
</FileList>FileDrop hands you raw File objects. Map them into DroppedFile records and update progress as your upload proceeds; the item shows a bar until it reaches 100.
API Reference
FileDrop
| Prop | Type | Description |
|---|---|---|
onFiles |
(files: File[]) => void |
Called on drop or pick. |
accept |
string |
Native accept string; also shown as hint text. |
multiple |
boolean |
Default true. |
compact |
boolean |
Single-line variant. |
FileItem
| Prop | Type | Description |
|---|---|---|
file |
DroppedFile |
{ id, name, size, type, progress?, error? }. |
onRemove |
(id) => void |
Shows a remove button. |
selected |
boolean |
Highlighted state. |