All componentsform
File Upload
Compound API: FileUpload, FileUploadList, FileUploadItem. Progress below is simulated client-side; the component only reports accepted/rejected files.
Multiple files
Single file
Restricted (images only, max 500 KB)
Drop or pick any non-image file, or an image over 500 KB, to see the rejection state.
Disabled
Installation
$ npx shadcn@latest add https://deepmoon.dev/r/file-upload.jsonnpm dependencies: lucide-react
Props
FileUploadProps
| Prop | Type | Required |
|---|---|---|
| accept | string | No |
| multiple | boolean | No |
| maxSize | number | No |
| disabled | boolean | No |
| onFilesAdded | (accepted: File[], rejected: FileRejection[]) => void | Yes |
FileUploadItemProps
| Prop | Type | Required |
|---|---|---|
| item | FileUploadItemData | Yes |
| onRemove | (id: string) => void | No |
Code
file-upload.tsx
"use client";
import * as React from "react";
import { AlertCircle, File as FileIcon, ImageIcon, Upload, X } from "lucide-react";
import { cn } from "@/lib/utils";
export type FileUploadItemStatus = "pending" | "uploading" | "success" | "error";
export interface FileUploadItemData {
id: string;
file: File;
status: FileUploadItemStatus;
/** 0-100, meaningful when status is "uploading". */
progress?: number;
error?: string;
}
export interface FileRejection {
file: File;
reason: "type" | "size";
message: string;
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / 1024 ** exponent;
return `${exponent === 0 ? value : value.toFixed(1)} ${units[exponent]}`;
}
function matchesAccept(file: File, accept?: string): boolean {
if (!accept) return true;
const name = file.name.toLowerCase();
const type = file.type.toLowerCase();
return accept
.split(",")
.map((pattern) => pattern.trim().toLowerCase())
.filter(Boolean)
.some((pattern) => {
if (pattern.startsWith(".")) return name.endsWith(pattern);
if (pattern.endsWith("/*")) return type.startsWith(pattern.slice(0, -1));
return type === pattern;
});
}
export interface FileUploadProps extends Omit<React.ComponentPropsWithoutRef<"div">, "onDrop"> {
accept?: string;
multiple?: boolean;
/** Max size per file, in bytes. */
maxSize?: number;
disabled?: boolean;
onFilesAdded: (accepted: File[], rejected: FileRejection[]) => void;
}
/**
* Dropzone + real, keyboard-operable file input. The visible prompt is a
* <label> associated with a genuinely present (sr-only, not display:none)
* <input type="file">, so Tab reaches it, Enter/Space opens the native
* picker, and clicking anywhere in the zone works too — none of this
* depends on drag-and-drop. Drag state is exposed via data-dragging for
* styling and announced through an aria-live region for screen reader
* users driving (or scripting) a drag interaction.
*/
export function FileUpload({
accept,
multiple = true,
maxSize,
disabled,
onFilesAdded,
className,
children,
id: idProp,
...props
}: FileUploadProps) {
const generatedId = React.useId();
const inputId = idProp ?? generatedId;
const [isDragging, setIsDragging] = React.useState(false);
const dragDepth = React.useRef(0);
const processFiles = (fileList: FileList | null) => {
if (!fileList || disabled) return;
const files = Array.from(fileList);
const candidates = multiple ? files : files.slice(0, 1);
const accepted: File[] = [];
const rejected: FileRejection[] = [];
for (const file of candidates) {
if (!matchesAccept(file, accept)) {
rejected.push({ file, reason: "type", message: `"${file.name}" isn't an accepted file type.` });
continue;
}
if (maxSize && file.size > maxSize) {
rejected.push({
file,
reason: "size",
message: `"${file.name}" is larger than ${formatBytes(maxSize)}.`,
});
continue;
}
accepted.push(file);
}
onFilesAdded(accepted, rejected);
};
return (
<div className={cn("flex flex-col gap-3", className)} {...props}>
<label
htmlFor={inputId}
data-dragging={isDragging || undefined}
data-disabled={disabled || undefined}
onDragEnter={(event) => {
event.preventDefault();
if (disabled) return;
dragDepth.current += 1;
setIsDragging(true);
}}
onDragLeave={(event) => {
event.preventDefault();
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setIsDragging(false);
}}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
dragDepth.current = 0;
setIsDragging(false);
if (!disabled) processFiles(event.dataTransfer.files);
}}
className={cn(
"flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border bg-muted/40 p-8 text-center transition-colors",
"has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-background",
!disabled && "cursor-pointer hover:border-primary/50 hover:bg-muted/70",
isDragging && "border-primary bg-primary/5",
disabled && "cursor-not-allowed opacity-50",
)}
>
{children ?? (
<>
<Upload className="size-8 text-muted-foreground" aria-hidden="true" />
<p className="text-sm font-medium text-foreground">Drag files here or click to browse</p>
{accept ? <p className="text-xs text-muted-foreground">Accepted: {accept}</p> : null}
</>
)}
<input
id={inputId}
type="file"
className="sr-only"
accept={accept}
multiple={multiple}
disabled={disabled}
onChange={(event) => {
processFiles(event.target.files);
event.target.value = "";
}}
/>
</label>
<span aria-live="polite" className="sr-only">
{isDragging ? "Drop files here to upload." : ""}
</span>
</div>
);
}
export interface FileUploadListProps extends React.ComponentPropsWithoutRef<"ul"> {}
export function FileUploadList({ className, ...props }: FileUploadListProps) {
return <ul role="list" className={cn("flex list-none flex-col gap-2", className)} {...props} />;
}
export interface FileUploadItemProps extends React.ComponentPropsWithoutRef<"li"> {
item: FileUploadItemData;
onRemove?: (id: string) => void;
}
export function FileUploadItem({ item, onRemove, className, ...props }: FileUploadItemProps) {
const Icon = item.file.type.startsWith("image/") ? ImageIcon : FileIcon;
return (
<li
className={cn("flex items-center gap-3 rounded-md border border-border bg-card p-3", className)}
{...props}
>
<Icon className="size-8 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium text-foreground">{item.file.name}</span>
<span className="shrink-0 text-xs text-muted-foreground">{formatBytes(item.file.size)}</span>
</div>
{item.status === "uploading" ? (
<div
role="progressbar"
aria-valuenow={Math.round(item.progress ?? 0)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`Uploading ${item.file.name}`}
className="h-1.5 w-full overflow-hidden rounded-full bg-muted"
>
<div
className="h-full rounded-full bg-primary transition-[width]"
style={{ width: `${Math.round(item.progress ?? 0)}%` }}
/>
</div>
) : item.status === "error" ? (
<p className="flex items-center gap-1 text-xs text-destructive">
<AlertCircle className="size-3.5 shrink-0" aria-hidden="true" />
{item.error ?? "Upload failed."}
</p>
) : null}
</div>
{onRemove ? (
<button
type="button"
onClick={() => onRemove(item.id)}
aria-label={`Remove ${item.file.name}`}
className={cn(
"shrink-0 rounded-md p-1.5 text-muted-foreground outline-none transition-colors",
"hover:bg-muted hover:text-foreground",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
<X className="size-4" aria-hidden="true" />
</button>
) : null}
</li>
);
}