Drag & drop files here
Or click to browse (max 2 files, up to 5MB each)
"use client";
import { Button } from "@/components/ui/button";
import {
FileUpload,
FileUploadDropzone,
FileUploadItem,
FileUploadItemDelete,
FileUploadItemMetadata,
FileUploadItemPreview,
FileUploadList,
FileUploadTrigger,
} from "@/components/ui/file-upload";
import { Upload, X } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
export function FileUploadDemo() {
const [files, setFiles] = React.useState<File[]>([]);
const onFileReject = React.useCallback((file: File, message: string) => {
toast(message, {
description: `"${file.name.length > 20 ? `${file.name.slice(0, 20)}...` : file.name}" has been rejected`,
});
}, []);
return (
<FileUpload
maxFiles={2}
maxSize={5 * 1024 * 1024}
className="w-full max-w-md"
value={files}
onValueChange={setFiles}
onFileReject={onFileReject}
multiple
>
<FileUploadDropzone>
<div className="flex flex-col items-center gap-1 text-center">
<div className="flex items-center justify-center rounded-full border p-2.5">
<Upload className="size-6 text-muted-foreground" />
</div>
<p className="font-medium text-sm">Drag & drop files here</p>
<p className="text-muted-foreground text-xs">
Or click to browse (max 2 files, up to 5MB each)
</p>
</div>
<FileUploadTrigger asChild>
<Button variant="outline" size="sm" className="mt-2 w-fit">
Browse files
</Button>
</FileUploadTrigger>
</FileUploadDropzone>
<FileUploadList>
{files.map((file, index) => (
<FileUploadItem key={index} value={file}>
<FileUploadItemPreview />
<FileUploadItemMetadata />
<FileUploadItemDelete asChild>
<Button variant="ghost" size="icon" className="size-7">
<X />
</Button>
</FileUploadItemDelete>
</FileUploadItem>
))}
</FileUploadList>
</FileUpload>
);
}
Installation
CLI
npx shadcn@latest add "https://diceui.com/r/file-upload"
pnpm dlx shadcn@latest add "https://diceui.com/r/file-upload"
yarn dlx shadcn@latest add "https://diceui.com/r/file-upload"
bun x shadcn@latest add "https://diceui.com/r/file-upload"
Manual
Install the following dependencies:
npm install @radix-ui/react-slot
pnpm add @radix-ui/react-slot
yarn add @radix-ui/react-slot
bun add @radix-ui/react-slot
Copy and paste the following code into your project.
"use client";
import { cn } from "@/lib/utils";
import { Slot } from "@radix-ui/react-slot";
import {
FileArchiveIcon,
FileAudioIcon,
FileCodeIcon,
FileCogIcon,
FileIcon,
FileTextIcon,
FileVideoIcon,
} from "lucide-react";
import * as React from "react";
const ROOT_NAME = "FileUpload";
const DROPZONE_NAME = "FileUploadDropzone";
const TRIGGER_NAME = "FileUploadTrigger";
const LIST_NAME = "FileUploadList";
const ITEM_NAME = "FileUploadItem";
const ITEM_PREVIEW_NAME = "FileUploadItemPreview";
const ITEM_METADATA_NAME = "FileUploadItemMetadata";
const ITEM_PROGRESS_NAME = "FileUploadItemProgress";
const ITEM_DELETE_NAME = "FileUploadItemDelete";
const CLEAR_NAME = "FileUploadClear";
function useLazyRef<T>(fn: () => T) {
const ref = React.useRef<T | null>(null);
if (ref.current === null) {
ref.current = fn();
}
return ref as React.RefObject<T>;
}
type Direction = "ltr" | "rtl";
const DirectionContext = React.createContext<Direction | undefined>(undefined);
function useDirection(dirProp?: Direction): Direction {
const contextDir = React.useContext(DirectionContext);
return dirProp ?? contextDir ?? "ltr";
}
interface FileState {
file: File;
progress: number;
error?: string;
status: "idle" | "uploading" | "error" | "success";
}
interface StoreState {
files: Map<File, FileState>;
dragOver: boolean;
invalid: boolean;
}
type StoreAction =
| { type: "ADD_FILES"; files: File[] }
| { type: "SET_FILES"; files: File[] }
| { type: "SET_PROGRESS"; file: File; progress: number }
| { type: "SET_SUCCESS"; file: File }
| { type: "SET_ERROR"; file: File; error: string }
| { type: "REMOVE_FILE"; file: File }
| { type: "SET_DRAG_OVER"; dragOver: boolean }
| { type: "SET_INVALID"; invalid: boolean }
| { type: "CLEAR" };
function createStore(
listeners: Set<() => void>,
files: Map<File, FileState>,
invalid: boolean,
onValueChange?: (files: File[]) => void,
) {
let state: StoreState = {
files,
dragOver: false,
invalid: invalid,
};
function reducer(state: StoreState, action: StoreAction): StoreState {
switch (action.type) {
case "ADD_FILES": {
for (const file of action.files) {
files.set(file, {
file,
progress: 0,
status: "idle",
});
}
if (onValueChange) {
const fileList = Array.from(files.values()).map(
(fileState) => fileState.file,
);
onValueChange(fileList);
}
return { ...state, files };
}
case "SET_FILES": {
const newFileSet = new Set(action.files);
for (const existingFile of files.keys()) {
if (!newFileSet.has(existingFile)) {
files.delete(existingFile);
}
}
for (const file of action.files) {
const existingState = files.get(file);
if (!existingState) {
files.set(file, {
file,
progress: 0,
status: "idle",
});
}
}
return { ...state, files };
}
case "SET_PROGRESS": {
const fileState = files.get(action.file);
if (fileState) {
files.set(action.file, {
...fileState,
progress: action.progress,
status: "uploading",
});
}
return { ...state, files };
}
case "SET_SUCCESS": {
const fileState = files.get(action.file);
if (fileState) {
files.set(action.file, {
...fileState,
progress: 100,
status: "success",
});
}
return { ...state, files };
}
case "SET_ERROR": {
const fileState = files.get(action.file);
if (fileState) {
files.set(action.file, {
...fileState,
error: action.error,
status: "error",
});
}
return { ...state, files };
}
case "REMOVE_FILE": {
files.delete(action.file);
if (onValueChange) {
const fileList = Array.from(files.values()).map(
(fileState) => fileState.file,
);
onValueChange(fileList);
}
return { ...state, files };
}
case "SET_DRAG_OVER": {
return { ...state, dragOver: action.dragOver };
}
case "SET_INVALID": {
return { ...state, invalid: action.invalid };
}
case "CLEAR": {
files.clear();
if (onValueChange) {
onValueChange([]);
}
return { ...state, files, invalid: false };
}
default:
return state;
}
}
function getState() {
return state;
}
function dispatch(action: StoreAction) {
state = reducer(state, action);
for (const listener of listeners) {
listener();
}
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
return { getState, dispatch, subscribe };
}
const StoreContext = React.createContext<ReturnType<typeof createStore> | null>(
null,
);
function useStoreContext(consumerName: string) {
const context = React.useContext(StoreContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
function useStore<T>(selector: (state: StoreState) => T): T {
const store = useStoreContext(ROOT_NAME);
const lastValueRef = useLazyRef<{ value: T; state: StoreState } | null>(
() => null,
);
const getSnapshot = React.useCallback(() => {
const state = store.getState();
const prevValue = lastValueRef.current;
if (prevValue && prevValue.state === state) {
return prevValue.value;
}
const nextValue = selector(state);
lastValueRef.current = { value: nextValue, state };
return nextValue;
}, [store, selector, lastValueRef]);
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
interface FileUploadContextValue {
inputId: string;
dropzoneId: string;
listId: string;
labelId: string;
disabled: boolean;
dir: Direction;
inputRef: React.RefObject<HTMLInputElement | null>;
}
const FileUploadContext = React.createContext<FileUploadContextValue | null>(
null,
);
function useFileUploadContext(consumerName: string) {
const context = React.useContext(FileUploadContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
interface FileUploadRootProps
extends Omit<
React.ComponentPropsWithoutRef<"div">,
"defaultValue" | "onChange"
> {
value?: File[];
defaultValue?: File[];
onValueChange?: (files: File[]) => void;
onAccept?: (files: File[]) => void;
onFileAccept?: (file: File) => void;
onFileReject?: (file: File, message: string) => void;
onFileValidate?: (file: File) => string | null | undefined;
onUpload?: (
files: File[],
options: {
onProgress: (file: File, progress: number) => void;
onSuccess: (file: File) => void;
onError: (file: File, error: Error) => void;
},
) => Promise<void> | void;
accept?: string;
maxFiles?: number;
maxSize?: number;
dir?: Direction;
label?: string;
name?: string;
asChild?: boolean;
disabled?: boolean;
invalid?: boolean;
multiple?: boolean;
required?: boolean;
}
function FileUploadRoot(props: FileUploadRootProps) {
const {
value,
defaultValue,
onValueChange,
onAccept,
onFileAccept,
onFileReject,
onFileValidate,
onUpload,
accept,
maxFiles,
maxSize,
dir: dirProp,
label,
name,
asChild,
disabled = false,
invalid = false,
multiple = false,
required = false,
children,
className,
...rootProps
} = props;
const inputId = React.useId();
const dropzoneId = React.useId();
const listId = React.useId();
const labelId = React.useId();
const dir = useDirection(dirProp);
const listeners = useLazyRef(() => new Set<() => void>()).current;
const files = useLazyRef<Map<File, FileState>>(() => new Map()).current;
const inputRef = React.useRef<HTMLInputElement>(null);
const isControlled = value !== undefined;
const store = React.useMemo(
() => createStore(listeners, files, invalid, onValueChange),
[listeners, files, invalid, onValueChange],
);
const acceptTypes = React.useMemo(
() => accept?.split(",").map((t) => t.trim()) ?? null,
[accept],
);
const onProgress = useLazyRef(() => {
let frame = 0;
return (file: File, progress: number) => {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
store.dispatch({
type: "SET_PROGRESS",
file,
progress: Math.min(Math.max(0, progress), 100),
});
});
};
}).current;
React.useEffect(() => {
if (isControlled) {
store.dispatch({ type: "SET_FILES", files: value });
} else if (
defaultValue &&
defaultValue.length > 0 &&
!store.getState().files.size
) {
store.dispatch({ type: "SET_FILES", files: defaultValue });
}
}, [value, defaultValue, isControlled, store]);
const onFilesChange = React.useCallback(
(originalFiles: File[]) => {
if (disabled) return;
let filesToProcess = [...originalFiles];
let invalid = false;
if (maxFiles) {
const currentCount = store.getState().files.size;
const remainingSlotCount = Math.max(0, maxFiles - currentCount);
if (remainingSlotCount < filesToProcess.length) {
const rejectedFiles = filesToProcess.slice(remainingSlotCount);
invalid = true;
filesToProcess = filesToProcess.slice(0, remainingSlotCount);
for (const file of rejectedFiles) {
let rejectionMessage = `Maximum ${maxFiles} files allowed`;
if (onFileValidate) {
const validationMessage = onFileValidate(file);
if (validationMessage) {
rejectionMessage = validationMessage;
}
}
onFileReject?.(file, rejectionMessage);
}
}
}
const acceptedFiles: File[] = [];
const rejectedFiles: { file: File; message: string }[] = [];
for (const file of filesToProcess) {
let rejected = false;
let rejectionMessage = "";
if (onFileValidate) {
const validationMessage = onFileValidate(file);
if (validationMessage) {
rejectionMessage = validationMessage;
onFileReject?.(file, rejectionMessage);
rejected = true;
invalid = true;
continue;
}
}
if (acceptTypes) {
const fileType = file.type;
const fileExtension = `.${file.name.split(".").pop()}`;
if (
!acceptTypes.some(
(type) =>
type === fileType ||
type === fileExtension ||
(type.includes("/*") &&
fileType.startsWith(type.replace("/*", "/"))),
)
) {
rejectionMessage = "File type not accepted";
onFileReject?.(file, rejectionMessage);
rejected = true;
invalid = true;
}
}
if (maxSize && file.size > maxSize) {
rejectionMessage = "File too large";
onFileReject?.(file, rejectionMessage);
rejected = true;
invalid = true;
}
if (!rejected) {
acceptedFiles.push(file);
} else {
rejectedFiles.push({ file, message: rejectionMessage });
}
}
if (invalid) {
store.dispatch({ type: "SET_INVALID", invalid });
setTimeout(() => {
store.dispatch({ type: "SET_INVALID", invalid: false });
}, 2000);
}
if (acceptedFiles.length > 0) {
store.dispatch({ type: "ADD_FILES", files: acceptedFiles });
if (isControlled && onValueChange) {
const currentFiles = Array.from(store.getState().files.values()).map(
(f) => f.file,
);
onValueChange([...currentFiles]);
}
if (onAccept) {
onAccept(acceptedFiles);
}
for (const file of acceptedFiles) {
onFileAccept?.(file);
}
if (onUpload) {
requestAnimationFrame(() => {
onFilesUpload(acceptedFiles);
});
}
}
},
[
store,
isControlled,
onValueChange,
onAccept,
onFileAccept,
onUpload,
maxFiles,
onFileValidate,
onFileReject,
acceptTypes,
maxSize,
disabled,
],
);
const onFilesUpload = React.useCallback(
async (files: File[]) => {
try {
for (const file of files) {
store.dispatch({ type: "SET_PROGRESS", file, progress: 0 });
}
if (onUpload) {
await onUpload(files, {
onProgress,
onSuccess: (file) => {
store.dispatch({ type: "SET_SUCCESS", file });
},
onError: (file, error) => {
store.dispatch({
type: "SET_ERROR",
file,
error: error.message ?? "Upload failed",
});
},
});
} else {
for (const file of files) {
store.dispatch({ type: "SET_SUCCESS", file });
}
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Upload failed";
for (const file of files) {
store.dispatch({
type: "SET_ERROR",
file,
error: errorMessage,
});
}
}
},
[store, onUpload, onProgress],
);
const onInputChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
onFilesChange(files);
event.target.value = "";
},
[onFilesChange],
);
const contextValue = React.useMemo<FileUploadContextValue>(
() => ({
dropzoneId,
inputId,
listId,
labelId,
dir,
disabled,
inputRef,
}),
[dropzoneId, inputId, listId, labelId, dir, disabled],
);
const RootPrimitive = asChild ? Slot : "div";
return (
<StoreContext.Provider value={store}>
<FileUploadContext.Provider value={contextValue}>
<RootPrimitive
data-disabled={disabled ? "" : undefined}
data-slot="file-upload"
dir={dir}
{...rootProps}
className={cn("relative flex flex-col gap-2", className)}
>
{children}
<input
type="file"
id={inputId}
aria-labelledby={labelId}
aria-describedby={dropzoneId}
ref={inputRef}
tabIndex={-1}
accept={accept}
name={name}
className="sr-only"
disabled={disabled}
multiple={multiple}
required={required}
onChange={onInputChange}
/>
<span id={labelId} className="sr-only">
{label ?? "File upload"}
</span>
</RootPrimitive>
</FileUploadContext.Provider>
</StoreContext.Provider>
);
}
interface FileUploadDropzoneProps
extends React.ComponentPropsWithoutRef<"div"> {
asChild?: boolean;
}
function FileUploadDropzone(props: FileUploadDropzoneProps) {
const {
asChild,
className,
onClick: onClickProp,
onDragOver: onDragOverProp,
onDragEnter: onDragEnterProp,
onDragLeave: onDragLeaveProp,
onDrop: onDropProp,
onPaste: onPasteProp,
onKeyDown: onKeyDownProp,
...dropzoneProps
} = props;
const context = useFileUploadContext(DROPZONE_NAME);
const store = useStoreContext(DROPZONE_NAME);
const dragOver = useStore((state) => state.dragOver);
const invalid = useStore((state) => state.invalid);
const onClick = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
onClickProp?.(event);
if (event.defaultPrevented) return;
const target = event.target;
const isFromTrigger =
target instanceof HTMLElement &&
target.closest('[data-slot="file-upload-trigger"]');
if (!isFromTrigger) {
context.inputRef.current?.click();
}
},
[context.inputRef, onClickProp],
);
const onDragOver = React.useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
onDragOverProp?.(event);
if (event.defaultPrevented) return;
event.preventDefault();
store.dispatch({ type: "SET_DRAG_OVER", dragOver: true });
},
[store, onDragOverProp],
);
const onDragEnter = React.useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
onDragEnterProp?.(event);
if (event.defaultPrevented) return;
event.preventDefault();
store.dispatch({ type: "SET_DRAG_OVER", dragOver: true });
},
[store, onDragEnterProp],
);
const onDragLeave = React.useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
onDragLeaveProp?.(event);
if (event.defaultPrevented) return;
const relatedTarget = event.relatedTarget;
if (
relatedTarget &&
relatedTarget instanceof Node &&
event.currentTarget.contains(relatedTarget)
) {
return;
}
event.preventDefault();
store.dispatch({ type: "SET_DRAG_OVER", dragOver: false });
},
[store, onDragLeaveProp],
);
const onDrop = React.useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
onDropProp?.(event);
if (event.defaultPrevented) return;
event.preventDefault();
store.dispatch({ type: "SET_DRAG_OVER", dragOver: false });
const files = Array.from(event.dataTransfer.files);
const inputElement = context.inputRef.current;
if (!inputElement) return;
const dataTransfer = new DataTransfer();
for (const file of files) {
dataTransfer.items.add(file);
}
inputElement.files = dataTransfer.files;
inputElement.dispatchEvent(new Event("change", { bubbles: true }));
},
[store, context.inputRef, onDropProp],
);
const onPaste = React.useCallback(
(event: React.ClipboardEvent<HTMLDivElement>) => {
onPasteProp?.(event);
if (event.defaultPrevented) return;
event.preventDefault();
store.dispatch({ type: "SET_DRAG_OVER", dragOver: false });
const items = event.clipboardData?.items;
if (!items) return;
const files: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item?.kind === "file") {
const file = item.getAsFile();
if (file) {
files.push(file);
}
}
}
if (files.length === 0) return;
const inputElement = context.inputRef.current;
if (!inputElement) return;
const dataTransfer = new DataTransfer();
for (const file of files) {
dataTransfer.items.add(file);
}
inputElement.files = dataTransfer.files;
inputElement.dispatchEvent(new Event("change", { bubbles: true }));
},
[store, context.inputRef, onPasteProp],
);
const onKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
onKeyDownProp?.(event);
if (
!event.defaultPrevented &&
(event.key === "Enter" || event.key === " ")
) {
event.preventDefault();
context.inputRef.current?.click();
}
},
[context.inputRef, onKeyDownProp],
);
const DropzonePrimitive = asChild ? Slot : "div";
return (
<DropzonePrimitive
role="region"
id={context.dropzoneId}
aria-controls={`${context.inputId} ${context.listId}`}
aria-disabled={context.disabled}
aria-invalid={invalid}
data-disabled={context.disabled ? "" : undefined}
data-dragging={dragOver ? "" : undefined}
data-invalid={invalid ? "" : undefined}
data-slot="file-upload-dropzone"
dir={context.dir}
tabIndex={context.disabled ? undefined : 0}
{...dropzoneProps}
className={cn(
"relative flex select-none flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 outline-none transition-colors hover:bg-accent/30 focus-visible:border-ring/50 data-[disabled]:pointer-events-none data-[dragging]:border-primary/30 data-[invalid]:border-destructive data-[dragging]:bg-accent/30 data-[invalid]:ring-destructive/20",
className,
)}
onClick={onClick}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
onKeyDown={onKeyDown}
onPaste={onPaste}
/>
);
}
interface FileUploadTriggerProps
extends React.ComponentPropsWithoutRef<"button"> {
asChild?: boolean;
}
function FileUploadTrigger(props: FileUploadTriggerProps) {
const { asChild, onClick: onClickProp, ...triggerProps } = props;
const context = useFileUploadContext(TRIGGER_NAME);
const onClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
onClickProp?.(event);
if (event.defaultPrevented) return;
context.inputRef.current?.click();
},
[context.inputRef, onClickProp],
);
const TriggerPrimitive = asChild ? Slot : "button";
return (
<TriggerPrimitive
type="button"
aria-controls={context.inputId}
data-disabled={context.disabled ? "" : undefined}
data-slot="file-upload-trigger"
{...triggerProps}
disabled={context.disabled}
onClick={onClick}
/>
);
}
interface FileUploadListProps extends React.ComponentPropsWithoutRef<"div"> {
orientation?: "horizontal" | "vertical";
asChild?: boolean;
forceMount?: boolean;
}
function FileUploadList(props: FileUploadListProps) {
const {
className,
orientation = "vertical",
asChild,
forceMount,
...listProps
} = props;
const context = useFileUploadContext(LIST_NAME);
const fileCount = useStore((state) => state.files.size);
const shouldRender = forceMount || fileCount > 0;
if (!shouldRender) return null;
const ListPrimitive = asChild ? Slot : "div";
return (
<ListPrimitive
role="list"
id={context.listId}
aria-orientation={orientation}
data-orientation={orientation}
data-slot="file-upload-list"
data-state={shouldRender ? "active" : "inactive"}
dir={context.dir}
{...listProps}
className={cn(
"data-[state=inactive]:fade-out-0 data-[state=active]:fade-in-0 data-[state=inactive]:slide-out-to-top-2 data-[state=active]:slide-in-from-top-2 flex flex-col gap-2 data-[state=active]:animate-in data-[state=inactive]:animate-out",
orientation === "horizontal" && "flex-row overflow-x-auto p-1.5",
className,
)}
/>
);
}
interface FileUploadItemContextValue {
id: string;
fileState: FileState | undefined;
nameId: string;
sizeId: string;
statusId: string;
messageId: string;
}
const FileUploadItemContext =
React.createContext<FileUploadItemContextValue | null>(null);
function useFileUploadItemContext(consumerName: string) {
const context = React.useContext(FileUploadItemContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ITEM_NAME}\``);
}
return context;
}
interface FileUploadItemProps extends React.ComponentPropsWithoutRef<"div"> {
value: File;
asChild?: boolean;
}
function FileUploadItem(props: FileUploadItemProps) {
const { value, asChild, className, ...itemProps } = props;
const id = React.useId();
const statusId = `${id}-status`;
const nameId = `${id}-name`;
const sizeId = `${id}-size`;
const messageId = `${id}-message`;
const context = useFileUploadContext(ITEM_NAME);
const fileState = useStore((state) => state.files.get(value));
const fileCount = useStore((state) => state.files.size);
const fileIndex = useStore((state) => {
const files = Array.from(state.files.keys());
return files.indexOf(value) + 1;
});
const itemContext = React.useMemo(
() => ({
id,
fileState,
nameId,
sizeId,
statusId,
messageId,
}),
[id, fileState, statusId, nameId, sizeId, messageId],
);
if (!fileState) return null;
const statusText = fileState.error
? `Error: ${fileState.error}`
: fileState.status === "uploading"
? `Uploading: ${fileState.progress}% complete`
: fileState.status === "success"
? "Upload complete"
: "Ready to upload";
const ItemPrimitive = asChild ? Slot : "div";
return (
<FileUploadItemContext.Provider value={itemContext}>
<ItemPrimitive
role="listitem"
id={id}
aria-setsize={fileCount}
aria-posinset={fileIndex}
aria-describedby={`${nameId} ${sizeId} ${statusId} ${
fileState.error ? messageId : ""
}`}
aria-labelledby={nameId}
data-slot="file-upload-item"
dir={context.dir}
{...itemProps}
className={cn(
"relative flex items-center gap-2.5 rounded-md border p-3",
className,
)}
>
{props.children}
<span id={statusId} className="sr-only">
{statusText}
</span>
</ItemPrimitive>
</FileUploadItemContext.Provider>
);
}
function formatBytes(bytes: number) {
if (bytes === 0) return "0 B";
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / 1024 ** i).toFixed(i ? 1 : 0)} ${sizes[i]}`;
}
function getFileIcon(file: File) {
const type = file.type;
const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
if (type.startsWith("video/")) {
return <FileVideoIcon />;
}
if (type.startsWith("audio/")) {
return <FileAudioIcon />;
}
if (
type.startsWith("text/") ||
["txt", "md", "rtf", "pdf"].includes(extension)
) {
return <FileTextIcon />;
}
if (
[
"html",
"css",
"js",
"jsx",
"ts",
"tsx",
"json",
"xml",
"php",
"py",
"rb",
"java",
"c",
"cpp",
"cs",
].includes(extension)
) {
return <FileCodeIcon />;
}
if (["zip", "rar", "7z", "tar", "gz", "bz2"].includes(extension)) {
return <FileArchiveIcon />;
}
if (
["exe", "msi", "app", "apk", "deb", "rpm"].includes(extension) ||
type.startsWith("application/")
) {
return <FileCogIcon />;
}
return <FileIcon />;
}
interface FileUploadItemPreviewProps
extends React.ComponentPropsWithoutRef<"div"> {
render?: (file: File) => React.ReactNode;
asChild?: boolean;
}
function FileUploadItemPreview(props: FileUploadItemPreviewProps) {
const { render, asChild, children, className, ...previewProps } = props;
const itemContext = useFileUploadItemContext(ITEM_PREVIEW_NAME);
const urlCache = useLazyRef(() => new WeakMap<File, string>()).current;
const onPreviewRender = React.useCallback(
(file: File) => {
if (render) return render(file);
if (itemContext.fileState?.file.type.startsWith("image/")) {
let url = urlCache.get(file);
if (!url) {
url = URL.createObjectURL(file);
urlCache.set(file, url);
}
return (
<img
src={url}
alt={file.name}
className="size-full object-cover"
onLoad={(event) => {
if (!(event.target instanceof HTMLImageElement)) return;
const cachedUrl = urlCache.get(file);
if (cachedUrl) {
URL.revokeObjectURL(cachedUrl);
urlCache.delete(file);
}
}}
/>
);
}
return getFileIcon(file);
},
[render, itemContext.fileState?.file.type, urlCache],
);
if (!itemContext.fileState) return null;
const ItemPreviewPrimitive = asChild ? Slot : "div";
return (
<ItemPreviewPrimitive
aria-labelledby={itemContext.nameId}
data-slot="file-upload-preview"
{...previewProps}
className={cn(
"relative flex size-10 shrink-0 items-center justify-center overflow-hidden rounded border bg-accent/50 [&>svg]:size-10",
className,
)}
>
{onPreviewRender(itemContext.fileState.file)}
{children}
</ItemPreviewPrimitive>
);
}
interface FileUploadItemMetadataProps
extends React.ComponentPropsWithoutRef<"div"> {
asChild?: boolean;
size?: "default" | "sm";
}
function FileUploadItemMetadata(props: FileUploadItemMetadataProps) {
const {
asChild,
size = "default",
children,
className,
...metadataProps
} = props;
const context = useFileUploadContext(ITEM_METADATA_NAME);
const itemContext = useFileUploadItemContext(ITEM_METADATA_NAME);
if (!itemContext.fileState) return null;
const ItemMetadataPrimitive = asChild ? Slot : "div";
return (
<ItemMetadataPrimitive
data-slot="file-upload-metadata"
dir={context.dir}
{...metadataProps}
className={cn("flex min-w-0 flex-1 flex-col", className)}
>
{children ?? (
<>
<span
id={itemContext.nameId}
className={cn(
"truncate font-medium text-sm",
size === "sm" && "font-normal text-[13px] leading-snug",
)}
>
{itemContext.fileState.file.name}
</span>
<span
id={itemContext.sizeId}
className={cn(
"truncate text-muted-foreground text-xs",
size === "sm" && "text-[11px] leading-snug",
)}
>
{formatBytes(itemContext.fileState.file.size)}
</span>
{itemContext.fileState.error && (
<span
id={itemContext.messageId}
className="text-destructive text-xs"
>
{itemContext.fileState.error}
</span>
)}
</>
)}
</ItemMetadataPrimitive>
);
}
interface FileUploadItemProgressProps
extends React.ComponentPropsWithoutRef<"div"> {
variant?: "linear" | "circular" | "fill";
size?: number;
asChild?: boolean;
forceMount?: boolean;
}
function FileUploadItemProgress(props: FileUploadItemProgressProps) {
const {
variant = "linear",
size = 40,
asChild,
forceMount,
className,
...progressProps
} = props;
const itemContext = useFileUploadItemContext(ITEM_PROGRESS_NAME);
if (!itemContext.fileState) return null;
const shouldRender = forceMount || itemContext.fileState.progress !== 100;
if (!shouldRender) return null;
const ItemProgressPrimitive = asChild ? Slot : "div";
switch (variant) {
case "circular": {
const circumference = 2 * Math.PI * ((size - 4) / 2);
const strokeDashoffset =
circumference - (itemContext.fileState.progress / 100) * circumference;
return (
<ItemProgressPrimitive
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={itemContext.fileState.progress}
aria-valuetext={`${itemContext.fileState.progress}%`}
aria-labelledby={itemContext.nameId}
data-slot="file-upload-progress"
{...progressProps}
className={cn(
"-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-1/2",
className,
)}
>
<svg
className="rotate-[-90deg] transform"
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
fill="none"
stroke="currentColor"
>
<circle
className="text-primary/20"
strokeWidth="2"
cx={size / 2}
cy={size / 2}
r={(size - 4) / 2}
/>
<circle
className="text-primary transition-[stroke-dashoffset] duration-300 ease-linear"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
cx={size / 2}
cy={size / 2}
r={(size - 4) / 2}
/>
</svg>
</ItemProgressPrimitive>
);
}
case "fill": {
const progressPercentage = itemContext.fileState.progress;
const topInset = 100 - progressPercentage;
return (
<ItemProgressPrimitive
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progressPercentage}
aria-valuetext={`${progressPercentage}%`}
aria-labelledby={itemContext.nameId}
data-slot="file-upload-progress"
{...progressProps}
className={cn(
"absolute inset-0 bg-primary/50 transition-[clip-path] duration-300 ease-linear",
className,
)}
style={{
clipPath: `inset(${topInset}% 0% 0% 0%)`,
}}
/>
);
}
default:
return (
<ItemProgressPrimitive
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={itemContext.fileState.progress}
aria-valuetext={`${itemContext.fileState.progress}%`}
aria-labelledby={itemContext.nameId}
data-slot="file-upload-progress"
{...progressProps}
className={cn(
"relative h-1.5 w-full overflow-hidden rounded-full bg-primary/20",
className,
)}
>
<div
className="h-full w-full flex-1 bg-primary transition-transform duration-300 ease-linear"
style={{
transform: `translateX(-${100 - itemContext.fileState.progress}%)`,
}}
/>
</ItemProgressPrimitive>
);
}
}
interface FileUploadItemDeleteProps
extends React.ComponentPropsWithoutRef<"button"> {
asChild?: boolean;
}
function FileUploadItemDelete(props: FileUploadItemDeleteProps) {
const { asChild, onClick: onClickProp, ...deleteProps } = props;
const store = useStoreContext(ITEM_DELETE_NAME);
const itemContext = useFileUploadItemContext(ITEM_DELETE_NAME);
const onClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
onClickProp?.(event);
if (!itemContext.fileState || event.defaultPrevented) return;
store.dispatch({
type: "REMOVE_FILE",
file: itemContext.fileState.file,
});
},
[store, itemContext.fileState, onClickProp],
);
if (!itemContext.fileState) return null;
const ItemDeletePrimitive = asChild ? Slot : "button";
return (
<ItemDeletePrimitive
type="button"
aria-controls={itemContext.id}
aria-describedby={itemContext.nameId}
data-slot="file-upload-item-delete"
{...deleteProps}
onClick={onClick}
/>
);
}
interface FileUploadClearProps
extends React.ComponentPropsWithoutRef<"button"> {
forceMount?: boolean;
asChild?: boolean;
}
function FileUploadClear(props: FileUploadClearProps) {
const {
asChild,
forceMount,
disabled,
onClick: onClickProp,
...clearProps
} = props;
const context = useFileUploadContext(CLEAR_NAME);
const store = useStoreContext(CLEAR_NAME);
const fileCount = useStore((state) => state.files.size);
const isDisabled = disabled || context.disabled;
const onClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
onClickProp?.(event);
if (event.defaultPrevented) return;
store.dispatch({ type: "CLEAR" });
},
[store, onClickProp],
);
const shouldRender = forceMount || fileCount > 0;
if (!shouldRender) return null;
const ClearPrimitive = asChild ? Slot : "button";
return (
<ClearPrimitive
type="button"
aria-controls={context.listId}
data-slot="file-upload-clear"
data-disabled={isDisabled ? "" : undefined}
{...clearProps}
disabled={isDisabled}
onClick={onClick}
/>
);
}
export {
FileUploadRoot as FileUpload,
FileUploadDropzone,
FileUploadTrigger,
FileUploadList,
FileUploadItem,
FileUploadItemPreview,
FileUploadItemMetadata,
FileUploadItemProgress,
FileUploadItemDelete,
FileUploadClear,
//
FileUploadRoot as Root,
FileUploadDropzone as Dropzone,
FileUploadTrigger as Trigger,
FileUploadList as List,
FileUploadItem as Item,
FileUploadItemPreview as ItemPreview,
FileUploadItemMetadata as ItemMetadata,
FileUploadItemProgress as ItemProgress,
FileUploadItemDelete as ItemDelete,
FileUploadClear as Clear,
//
useStore as useFileUpload,
//
type FileUploadRootProps as FileUploadProps,
};
Layout
Import the parts, and compose them together.
import * as FileUpload from "@/components/ui/file-upload";
<FileUpload.Root>
<FileUpload.Dropzone />
<FileUpload.Trigger />
<FileUpload.List>
<FileUpload.Item>
<FileUpload.ItemPreview />
<FileUpload.ItemMetadata />
<FileUpload.ItemProgress />
<FileUpload.ItemDelete />
</FileUpload.Item>
</FileUpload.List>
<FileUpload.Clear />
</FileUpload.Root>
Examples
With Validation
Validate files with the onFileValidate
prop on the Root
component based on type, size, and custom rules. This will override the default file rejection message.
Drag & drop files here
Or click to browse (max 2 files)
"use client";
import { Button } from "@/components/ui/button";
import {
FileUpload,
FileUploadDropzone,
FileUploadItem,
FileUploadItemDelete,
FileUploadItemMetadata,
FileUploadItemPreview,
FileUploadList,
FileUploadTrigger,
} from "@/components/ui/file-upload";
import { Upload, X } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
export function FileUploadValidationDemo() {
const [files, setFiles] = React.useState<File[]>([]);
const onFileValidate = React.useCallback(
(file: File): string | null => {
// Validate max files
if (files.length >= 2) {
return "You can only upload up to 2 files";
}
// Validate file type (only images)
if (!file.type.startsWith("image/")) {
return "Only image files are allowed";
}
// Validate file size (max 2MB)
const MAX_SIZE = 2 * 1024 * 1024; // 2MB
if (file.size > MAX_SIZE) {
return `File size must be less than ${MAX_SIZE / (1024 * 1024)}MB`;
}
return null;
},
[files],
);
const onFileReject = React.useCallback((file: File, message: string) => {
toast(message, {
description: `"${file.name.length > 20 ? `${file.name.slice(0, 20)}...` : file.name}" has been rejected`,
});
}, []);
return (
<FileUpload
value={files}
onValueChange={setFiles}
onFileValidate={onFileValidate}
onFileReject={onFileReject}
accept="image/*"
maxFiles={2}
className="w-full max-w-md"
multiple
>
<FileUploadDropzone>
<div className="flex flex-col items-center gap-1">
<div className="flex items-center justify-center rounded-full border p-2.5">
<Upload className="size-6 text-muted-foreground" />
</div>
<p className="font-medium text-sm">Drag & drop files here</p>
<p className="text-muted-foreground text-xs">
Or click to browse (max 2 files)
</p>
</div>
<FileUploadTrigger asChild>
<Button variant="outline" size="sm" className="mt-2 w-fit">
Browse files
</Button>
</FileUploadTrigger>
</FileUploadDropzone>
<FileUploadList>
{files.map((file) => (
<FileUploadItem key={file.name} value={file}>
<FileUploadItemPreview />
<FileUploadItemMetadata />
<FileUploadItemDelete asChild>
<Button variant="ghost" size="icon" className="size-7">
<X />
</Button>
</FileUploadItemDelete>
</FileUploadItem>
))}
</FileUploadList>
</FileUpload>
);
}
Direct Upload
Upload files directly with the onUpload
prop on the Root
component.
Drag & drop files here
Or click to browse (max 2 files)
"use client";
import { Button } from "@/components/ui/button";
import {
FileUpload,
FileUploadDropzone,
FileUploadItem,
FileUploadItemDelete,
FileUploadItemMetadata,
FileUploadItemPreview,
FileUploadItemProgress,
FileUploadList,
type FileUploadProps,
FileUploadTrigger,
} from "@/components/ui/file-upload";
import { Upload, X } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
export function FileUploadDirectUploadDemo() {
const [files, setFiles] = React.useState<File[]>([]);
const onUpload: NonNullable<FileUploadProps["onUpload"]> = React.useCallback(
async (files, { onProgress, onSuccess, onError }) => {
try {
// Process each file individually
const uploadPromises = files.map(async (file) => {
try {
// Simulate file upload with progress
const totalChunks = 10;
let uploadedChunks = 0;
// Simulate chunk upload with delays
for (let i = 0; i < totalChunks; i++) {
// Simulate network delay (100-300ms per chunk)
await new Promise((resolve) =>
setTimeout(resolve, Math.random() * 200 + 100),
);
// Update progress for this specific file
uploadedChunks++;
const progress = (uploadedChunks / totalChunks) * 100;
onProgress(file, progress);
}
// Simulate server processing delay
await new Promise((resolve) => setTimeout(resolve, 500));
onSuccess(file);
} catch (error) {
onError(
file,
error instanceof Error ? error : new Error("Upload failed"),
);
}
});
// Wait for all uploads to complete
await Promise.all(uploadPromises);
} catch (error) {
// This handles any error that might occur outside the individual upload processes
console.error("Unexpected error during upload:", error);
}
},
[],
);
const onFileReject = React.useCallback((file: File, message: string) => {
toast(message, {
description: `"${file.name.length > 20 ? `${file.name.slice(0, 20)}...` : file.name}" has been rejected`,
});
}, []);
return (
<FileUpload
value={files}
onValueChange={setFiles}
onUpload={onUpload}
onFileReject={onFileReject}
maxFiles={2}
className="w-full max-w-md"
multiple
>
<FileUploadDropzone>
<div className="flex flex-col items-center gap-1 text-center">
<div className="flex items-center justify-center rounded-full border p-2.5">
<Upload className="size-6 text-muted-foreground" />
</div>
<p className="font-medium text-sm">Drag & drop files here</p>
<p className="text-muted-foreground text-xs">
Or click to browse (max 2 files)
</p>
</div>
<FileUploadTrigger asChild>
<Button variant="outline" size="sm" className="mt-2 w-fit">
Browse files
</Button>
</FileUploadTrigger>
</FileUploadDropzone>
<FileUploadList>
{files.map((file, index) => (
<FileUploadItem key={index} value={file} className="flex-col">
<div className="flex w-full items-center gap-2">
<FileUploadItemPreview />
<FileUploadItemMetadata />
<FileUploadItemDelete asChild>
<Button variant="ghost" size="icon" className="size-7">
<X />
</Button>
</FileUploadItemDelete>
</div>
<FileUploadItemProgress />
</FileUploadItem>
))}
</FileUploadList>
</FileUpload>
);
}
Circular Progress
Render a circular progress indicator instead of a linear one by enabling the circular
prop on the ItemProgress
component.
Drag & drop files here
Or click to browse (max 10 files, up to 5MB each)
"use client";
import { Button } from "@/components/ui/button";
import {
FileUpload,
FileUploadDropzone,
FileUploadItem,
FileUploadItemDelete,
FileUploadItemMetadata,
FileUploadItemPreview,
FileUploadItemProgress,
FileUploadList,
FileUploadTrigger,
} from "@/components/ui/file-upload";
import { Upload, X } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
export function FileUploadCircularProgressDemo() {
const [files, setFiles] = React.useState<File[]>([]);
const onUpload = React.useCallback(
async (
files: File[],
{
onProgress,
onSuccess,
onError,
}: {
onProgress: (file: File, progress: number) => void;
onSuccess: (file: File) => void;
onError: (file: File, error: Error) => void;
},
) => {
try {
// Process each file individually
const uploadPromises = files.map(async (file) => {
try {
// Simulate file upload with progress
const totalChunks = 10;
let uploadedChunks = 0;
// Simulate chunk upload with delays
for (let i = 0; i < totalChunks; i++) {
// Simulate network delay (100-300ms per chunk)
await new Promise((resolve) =>
setTimeout(resolve, Math.random() * 200 + 100),
);
// Update progress for this specific file
uploadedChunks++;
const progress = (uploadedChunks / totalChunks) * 100;
onProgress(file, progress);
}
// Simulate server processing delay
await new Promise((resolve) => setTimeout(resolve, 500));
onSuccess(file);
} catch (error) {
onError(
file,
error instanceof Error ? error : new Error("Upload failed"),
);
}
});
// Wait for all uploads to complete
await Promise.all(uploadPromises);
} catch (error) {
// This handles any error that might occur outside the individual upload processes
console.error("Unexpected error during upload:", error);
}
},
[],
);
const onFileReject = React.useCallback((file: File, message: string) => {
toast(message, {
description: `"${file.name.length > 20 ? `${file.name.slice(0, 20)}...` : file.name}" has been rejected`,
});
}, []);
return (
<FileUpload
value={files}
onValueChange={setFiles}
maxFiles={10}
maxSize={5 * 1024 * 1024}
className="w-full max-w-md"
onUpload={onUpload}
onFileReject={onFileReject}
multiple
>
<FileUploadDropzone>
<div className="flex flex-col items-center gap-1 text-center">
<div className="flex items-center justify-center rounded-full border p-2.5">
<Upload className="size-6 text-muted-foreground" />
</div>
<p className="font-medium text-sm">Drag & drop files here</p>
<p className="text-muted-foreground text-xs">
Or click to browse (max 10 files, up to 5MB each)
</p>
</div>
<FileUploadTrigger asChild>
<Button variant="outline" size="sm" className="mt-2 w-fit">
Browse files
</Button>
</FileUploadTrigger>
</FileUploadDropzone>
<FileUploadList orientation="horizontal">
{files.map((file, index) => (
<FileUploadItem key={index} value={file} className="p-0">
<FileUploadItemPreview className="size-20 [&>svg]:size-12">
<FileUploadItemProgress variant="circular" size={40} />
</FileUploadItemPreview>
<FileUploadItemMetadata className="sr-only" />
<FileUploadItemDelete asChild>
<Button
variant="secondary"
size="icon"
className="-top-1 -right-1 absolute size-5 rounded-full"
>
<X className="size-3" />
</Button>
</FileUploadItemDelete>
</FileUploadItem>
))}
</FileUploadList>
</FileUpload>
);
}
Fill Progress
Render a fill progress indicator instead of a linear one by enabling the fill
prop on the ItemProgress
component.
Drag & drop files here
Or click to browse (max 10 files, up to 5MB each)
"use client";
import { Button } from "@/components/ui/button";
import {
FileUpload,
FileUploadDropzone,
FileUploadItem,
FileUploadItemDelete,
FileUploadItemMetadata,
FileUploadItemPreview,
FileUploadItemProgress,
FileUploadList,
FileUploadTrigger,
} from "@/components/ui/file-upload";
import { Upload, X } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
export function FileUploadFillProgressDemo() {
const [files, setFiles] = React.useState<File[]>([]);
const onUpload = React.useCallback(
async (
files: File[],
{
onProgress,
onSuccess,
onError,
}: {
onProgress: (file: File, progress: number) => void;
onSuccess: (file: File) => void;
onError: (file: File, error: Error) => void;
},
) => {
try {
// Process each file individually
const uploadPromises = files.map(async (file) => {
try {
// Simulate file upload with progress
const totalChunks = 10;
let uploadedChunks = 0;
// Simulate chunk upload with delays
for (let i = 0; i < totalChunks; i++) {
// Simulate network delay (100-300ms per chunk)
await new Promise((resolve) =>
setTimeout(resolve, Math.random() * 200 + 100),
);
// Update progress for this specific file
uploadedChunks++;
const progress = (uploadedChunks / totalChunks) * 100;
onProgress(file, progress);
}
// Simulate server processing delay
await new Promise((resolve) => setTimeout(resolve, 500));
onSuccess(file);
} catch (error) {
onError(
file,
error instanceof Error ? error : new Error("Upload failed"),
);
}
});
// Wait for all uploads to complete
await Promise.all(uploadPromises);
} catch (error) {
// This handles any error that might occur outside the individual upload processes
console.error("Unexpected error during upload:", error);
}
},
[],
);
const onFileReject = React.useCallback((file: File, message: string) => {
toast(message, {
description: `"${file.name.length > 20 ? `${file.name.slice(0, 20)}...` : file.name}" has been rejected`,
});
}, []);
return (
<FileUpload
value={files}
onValueChange={setFiles}
maxFiles={10}
maxSize={5 * 1024 * 1024}
className="w-full max-w-md"
onUpload={onUpload}
onFileReject={onFileReject}
multiple
>
<FileUploadDropzone>
<div className="flex flex-col items-center gap-1 text-center">
<div className="flex items-center justify-center rounded-full border p-2.5">
<Upload className="size-6 text-muted-foreground" />
</div>
<p className="font-medium text-sm">Drag & drop files here</p>
<p className="text-muted-foreground text-xs">
Or click to browse (max 10 files, up to 5MB each)
</p>
</div>
<FileUploadTrigger asChild>
<Button variant="outline" size="sm" className="mt-2 w-fit">
Browse files
</Button>
</FileUploadTrigger>
</FileUploadDropzone>
<FileUploadList orientation="horizontal">
{files.map((file, index) => (
<FileUploadItem key={index} value={file} className="p-0">
<FileUploadItemPreview className="size-20">
<FileUploadItemProgress variant="fill" />
</FileUploadItemPreview>
<FileUploadItemMetadata className="sr-only" />
<FileUploadItemDelete asChild>
<Button
variant="secondary"
size="icon"
className="-top-1 -right-1 absolute size-5 rounded-full"
>
<X className="size-3" />
</Button>
</FileUploadItemDelete>
</FileUploadItem>
))}
</FileUploadList>
</FileUpload>
);
}
With uploadthing
Integrate with uploadthing for secure, type-safe file uploads with real-time progress tracking.
Drag & drop images here
Or click to browse (max 2 files, up to 4MB each)
"use client";
import { Button } from "@/components/ui/button";
import { uploadFiles } from "@/lib/uploadthing";
import {
FileUpload,
FileUploadDropzone,
FileUploadItem,
FileUploadItemDelete,
FileUploadItemMetadata,
FileUploadItemPreview,
FileUploadItemProgress,
FileUploadList,
type FileUploadProps,
FileUploadTrigger,
} from "@/components/ui/file-upload";
import { Upload, X } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { UploadThingError } from "uploadthing/server";
export function FileUploadUploadThingDemo() {
const [isUploading, setIsUploading] = React.useState(false);
const [files, setFiles] = React.useState<File[]>([]);
const onUpload: NonNullable<FileUploadProps["onUpload"]> = React.useCallback(
async (files, { onProgress }) => {
try {
setIsUploading(true);
const res = await uploadFiles("imageUploader", {
files,
onUploadProgress: ({ file, progress }) => {
onProgress(file, progress);
},
});
toast.success("Uploaded files:", {
description: (
<pre className="mt-2 w-80 rounded-md bg-accent/30 p-4 text-accent-foreground">
<code>
{JSON.stringify(
res.map((file) =>
file.name.length > 25
? `${file.name.slice(0, 25)}...`
: file.name,
),
null,
2,
)}
</code>
</pre>
),
});
} catch (error) {
setIsUploading(false);
if (error instanceof UploadThingError) {
const errorMessage =
error.data && "error" in error.data
? error.data.error
: "Upload failed";
toast.error(errorMessage);
return;
}
toast.error(
error instanceof Error ? error.message : "An unknown error occurred",
);
} finally {
setIsUploading(false);
}
},
[],
);
const onFileReject = React.useCallback((file: File, message: string) => {
toast(message, {
description: `"${file.name.length > 20 ? `${file.name.slice(0, 20)}...` : file.name}" has been rejected`,
});
}, []);
return (
<FileUpload
accept="image/*"
maxFiles={2}
maxSize={4 * 1024 * 1024}
className="w-full max-w-md"
onAccept={(files) => setFiles(files)}
onUpload={onUpload}
onFileReject={onFileReject}
multiple
disabled={isUploading}
>
<FileUploadDropzone>
<div className="flex flex-col items-center gap-1 text-center">
<div className="flex items-center justify-center rounded-full border p-2.5">
<Upload className="size-6 text-muted-foreground" />
</div>
<p className="font-medium text-sm">Drag & drop images here</p>
<p className="text-muted-foreground text-xs">
Or click to browse (max 2 files, up to 4MB each)
</p>
</div>
<FileUploadTrigger asChild>
<Button variant="outline" size="sm" className="mt-2 w-fit">
Browse files
</Button>
</FileUploadTrigger>
</FileUploadDropzone>
<FileUploadList>
{files.map((file, index) => (
<FileUploadItem key={index} value={file}>
<div className="flex w-full items-center gap-2">
<FileUploadItemPreview />
<FileUploadItemMetadata />
<FileUploadItemDelete asChild>
<Button variant="ghost" size="icon" className="size-7">
<X />
</Button>
</FileUploadItemDelete>
</div>
<FileUploadItemProgress />
</FileUploadItem>
))}
</FileUploadList>
</FileUpload>
);
}
With Chat Input
Integrate into a chat input for uploading files. For demo the Dropzone
is absolutely positioned to cover the entire viewport.
Drag & drop files here
Upload max 5 files each up to 5MB
"use client";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
FileUpload,
FileUploadDropzone,
FileUploadItem,
FileUploadItemDelete,
FileUploadItemMetadata,
FileUploadItemPreview,
FileUploadItemProgress,
FileUploadList,
type FileUploadProps,
FileUploadTrigger,
} from "@/components/ui/file-upload";
import { ArrowUp, Paperclip, Upload, X } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
export function FileUploadChatInputDemo() {
const [input, setInput] = React.useState("");
const [files, setFiles] = React.useState<File[]>([]);
const [isUploading, setIsUploading] = React.useState(false);
const onInputChange = React.useCallback(
(event: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(event.target.value);
},
[],
);
const onUpload: NonNullable<FileUploadProps["onUpload"]> = React.useCallback(
async (files, { onProgress, onSuccess, onError }) => {
try {
setIsUploading(true);
// Process each file individually
const uploadPromises = files.map(async (file) => {
try {
// Simulate file upload with progress
const totalChunks = 10;
let uploadedChunks = 0;
// Simulate chunk upload with delays
for (let i = 0; i < totalChunks; i++) {
// Simulate network delay (100-300ms per chunk)
await new Promise((resolve) =>
setTimeout(resolve, Math.random() * 200 + 100),
);
// Update progress for this specific file
uploadedChunks++;
const progress = (uploadedChunks / totalChunks) * 100;
onProgress(file, progress);
}
// Simulate server processing delay
await new Promise((resolve) => setTimeout(resolve, 500));
onSuccess(file);
} catch (error) {
onError(
file,
error instanceof Error ? error : new Error("Upload failed"),
);
} finally {
setIsUploading(false);
}
});
// Wait for all uploads to complete
await Promise.all(uploadPromises);
} catch (error) {
// This handles any error that might occur outside the individual upload processes
console.error("Unexpected error during upload:", error);
}
},
[],
);
const onFileReject = React.useCallback((file: File, message: string) => {
toast(message, {
description: `"${file.name.length > 20 ? `${file.name.slice(0, 20)}...` : file.name}" has been rejected`,
});
}, []);
const onSubmit = React.useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setInput("");
setFiles([]);
},
[],
);
return (
<FileUpload
value={files}
onValueChange={setFiles}
onUpload={onUpload}
onFileReject={onFileReject}
maxFiles={10}
maxSize={5 * 1024 * 1024}
className="relative h-[400px] w-full items-center p-8"
multiple
disabled={isUploading}
>
<FileUploadDropzone
tabIndex={-1}
// Prevents the dropzone from triggering on click
onClick={(event) => event.preventDefault()}
className="absolute top-0 left-0 z-0 flex size-full items-center justify-center rounded-none border-none bg-background/50 p-0 opacity-0 backdrop-blur transition-opacity duration-200 ease-out data-[dragging]:z-10 data-[dragging]:opacity-100"
>
<div className="flex flex-col items-center gap-1 text-center">
<div className="flex items-center justify-center rounded-full border p-2.5">
<Upload className="size-6 text-muted-foreground" />
</div>
<p className="font-medium text-sm">Drag & drop files here</p>
<p className="text-muted-foreground text-xs">
Upload max 5 files each up to 5MB
</p>
</div>
</FileUploadDropzone>
<form
onSubmit={onSubmit}
className="relative flex w-full max-w-md flex-col gap-2.5 rounded-md border border-input px-3 py-2 outline-none focus-within:ring-1 focus-within:ring-ring/50"
>
<FileUploadList
orientation="horizontal"
className="overflow-x-auto px-0 py-1"
>
{files.map((file, index) => (
<FileUploadItem key={index} value={file} className="max-w-52 p-1.5">
<FileUploadItemPreview className="size-8 [&>svg]:size-5">
<FileUploadItemProgress variant="fill" />
</FileUploadItemPreview>
<FileUploadItemMetadata size="sm" />
<FileUploadItemDelete asChild>
<Button
variant="secondary"
size="icon"
className="-top-1 -right-1 absolute size-4 shrink-0 cursor-pointer rounded-full"
>
<X className="size-2.5" />
</Button>
</FileUploadItemDelete>
</FileUploadItem>
))}
</FileUploadList>
<Textarea
value={input}
onChange={onInputChange}
placeholder="Type your message here..."
className="w-full resize-none border-0 bg-transparent p-0 shadow-none focus-visible:ring-0 dark:bg-transparent"
disabled={isUploading}
/>
<div className="absolute right-[8px] bottom-[7px] flex items-center gap-1.5">
<FileUploadTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
className="size-7 rounded-sm"
>
<Paperclip className="size-3.5" />
<span className="sr-only">Attach file</span>
</Button>
</FileUploadTrigger>
<Button
size="icon"
className="size-7 rounded-sm"
disabled={!input.trim() || isUploading}
>
<ArrowUp className="size-3.5" />
<span className="sr-only">Send message</span>
</Button>
</div>
</form>
</FileUpload>
);
}
With Form
Use the value
and onValueChange
props to handle file uploads with validation and submission.
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { CloudUpload, X } from "lucide-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
FileUpload,
FileUploadDropzone,
FileUploadItem,
FileUploadItemDelete,
FileUploadItemMetadata,
FileUploadItemPreview,
FileUploadList,
FileUploadTrigger,
} from "@/components/ui/file-upload";
import { toast } from "sonner";
const formSchema = z.object({
files: z
.array(z.custom<File>())
.min(1, "Please select at least one file")
.max(2, "Please select up to 2 files")
.refine((files) => files.every((file) => file.size <= 5 * 1024 * 1024), {
message: "File size must be less than 5MB",
path: ["files"],
}),
});
type FormValues = z.infer<typeof formSchema>;
export function FileUploadFormDemo() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
files: [],
},
});
const onSubmit = React.useCallback((data: FormValues) => {
toast("Submitted values:", {
description: (
<pre className="mt-2 w-80 rounded-md bg-accent/30 p-4 text-accent-foreground">
<code>
{JSON.stringify(
data.files.map((file) =>
file.name.length > 25
? `${file.name.slice(0, 25)}...`
: file.name,
),
null,
2,
)}
</code>
</pre>
),
});
}, []);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="w-full max-w-md">
<FormField
control={form.control}
name="files"
render={({ field }) => (
<FormItem>
<FormLabel>Attachments</FormLabel>
<FormControl>
<FileUpload
value={field.value}
onValueChange={field.onChange}
accept="image/*"
maxFiles={2}
maxSize={5 * 1024 * 1024}
onFileReject={(_, message) => {
form.setError("files", {
message,
});
}}
multiple
>
<FileUploadDropzone className="flex-row flex-wrap border-dotted text-center">
<CloudUpload className="size-4" />
Drag and drop or
<FileUploadTrigger asChild>
<Button variant="link" size="sm" className="p-0">
choose files
</Button>
</FileUploadTrigger>
to upload
</FileUploadDropzone>
<FileUploadList>
{field.value.map((file, index) => (
<FileUploadItem key={index} value={file}>
<FileUploadItemPreview />
<FileUploadItemMetadata />
<FileUploadItemDelete asChild>
<Button
variant="ghost"
size="icon"
className="size-7"
>
<X />
<span className="sr-only">Delete</span>
</Button>
</FileUploadItemDelete>
</FileUploadItem>
))}
</FileUploadList>
</FileUpload>
</FormControl>
<FormDescription>
Upload up to 2 images up to 5MB each.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="mt-4">
Submit
</Button>
</form>
</Form>
);
}
API Reference
Root
The main container component for the file upload functionality.
Prop | Type | Default |
---|---|---|
required? | boolean | false |
multiple? | boolean | false |
invalid? | boolean | false |
disabled? | boolean | false |
asChild? | boolean | false |
name? | string | - |
label? | string | "File upload" |
dir? | "ltr" | "rtl" | - |
maxSize? | number | - |
maxFiles? | number | - |
accept? | string | - |
onUpload? | ((files: File[], options: { onProgress: (file: File, progress: number) => void; onSuccess: (file: File) => void; onError: (file: File, error: Error) => void; }) => void | Promise<...>) | - |
onFileValidate? | ((file: File) => string | null | undefined) | - |
onFileReject? | ((file: File, message: string) => void) | - |
onFileAccept? | ((file: File) => void) | - |
onAccept? | ((files: File[]) => void) | - |
onValueChange? | ((files: File[]) => void) | - |
defaultValue? | File[] | - |
value? | File[] | - |
Dropzone
A container for drag and drop functionality.
Prop | Type | Default |
---|---|---|
asChild? | boolean | false |
Data Attribute | Value |
---|---|
[data-disabled] | Present when the dropzone is disabled. |
[data-dragging] | Present when files are being dragged over the dropzone. |
[data-invalid] | Present when there was an error with the files being uploaded. |
Trigger
A button that opens the file selection dialog.
Prop | Type | Default |
---|---|---|
asChild? | boolean | false |
Data Attribute | Value |
---|---|
[data-disabled] | Present when the trigger is disabled. |
List
A container for displaying uploaded files.
Prop | Type | Default |
---|---|---|
asChild? | boolean | false |
forceMount? | boolean | - |
orientation? | "horizontal" | "vertical" | "vertical" |
Data Attribute | Value |
---|---|
[data-orientation] | "horizontal" | "vertical" |
[data-state] | "active" | "inactive" |
Item
Individual file item component.
Prop | Type | Default |
---|---|---|
asChild? | boolean | false |
value | File | - |
ItemPreview
Displays a preview of the file, showing an image for image files or an appropriate icon for other file types.
Prop | Type | Default |
---|---|---|
asChild? | boolean | false |
render | (file: File) => ReactNode | - |
ItemMetadata
Displays file information such as name, size, and error messages.
Prop | Type | Default |
---|---|---|
asChild? | boolean | false |
size? | "default" | "sm" | "default" |
ItemProgress
Shows the upload progress for a file.
Prop | Type | Default |
---|---|---|
asChild? | boolean | false |
forceMount? | boolean | false |
size? | number | 40 |
variant? | "linear" | "circular" | "fill" | "linear" |
ItemDelete
A button to remove a file from the list.
Prop | Type | Default |
---|---|---|
asChild? | boolean | false |
Clear
A button to clear all files from the list.
Prop | Type | Default |
---|---|---|
asChild? | boolean | false |
forceMount? | boolean | false |
Data Attribute | Value |
---|---|
[data-disabled] | Present when the clear button is disabled. |
Accessibility
Keyboard Interactions
Key | Description |
---|---|
EnterSpace | When focus is on the dropzone or trigger, opens the file selection dialog. |
Tab | Moves focus between the dropzone, trigger, and file delete buttons. |
Shift + Tab | When the dropzone is focused, moves focus away from the dropzone. |
Credits
- Building a Hold to Delete Component - For the fill progress indicator.