File Upload
A composable file upload with dropzone, list, previews, and progress indicators.
Preview
import * as React from "react";
import { Cross2Icon, UploadIcon } from "@radix-ui/react-icons";
import { toast } from "sonner";
import { Button } from "@/components/ui/Button";
import {
FileUpload,
FileUploadClear,
FileUploadDropzone,
FileUploadItem,
FileUploadItemDelete,
FileUploadItemMetadata,
FileUploadItemPreview,
FileUploadItemProgress,
FileUploadList,
type FileUploadProps,
FileUploadTrigger,
} from "@/components/ui/FileUpload";
export function FileUploadDemo() {
const [files, setFiles] = React.useState<File[]>([]);
return (
<FileUpload
maxFiles={2}
maxSize={5 * 1024 * 1024}
className="w-full max-w-md"
value={files}
onValueChange={setFiles}
onFileReject={(file, message) => {
toast(message, {
description: `"${file.name.length > 20 ? `${file.name.slice(0, 20)}...` : file.name}" has been rejected`,
});
}}
multiple
>
<FileUploadDropzone>
<div className="flex flex-col items-center gap-1 text-center">
<div className="flex size-4 items-center justify-center rounded-full border border-gray-6 bg-gray-2 text-gray-11">
<UploadIcon className="icon h4" aria-hidden />
</div>
<p className="text-sm font-medium text-gray-12">
Drag & drop files here
</p>
<p className="text-xs text-gray-11">
Or click to browse (max 2 files, up to 5MB each)
</p>
</div>
<FileUploadTrigger asChild>
<Button type="button" variant="pill" size="sm" className="mt-1 w-fit">
Browse files
</Button>
</FileUploadTrigger>
</FileUploadDropzone>
<FileUploadList>
{files.map((file) => (
<FileUploadItem key={file.name} value={file}>
<FileUploadItemPreview />
<FileUploadItemMetadata />
<FileUploadItemDelete asChild>
<Button
type="button"
variant="pill"
size="icon"
aria-label={`Remove ${file.name}`}
>
<Cross2Icon className="icon" />
</Button>
</FileUploadItemDelete>
</FileUploadItem>
))}
</FileUploadList>
</FileUpload>
);
}Installation
Make sure that namespace is set in your component.json file. Namespace docs: Learn more about namespaces
pnpm dlx shadcn@latest add @aura/file-uploadManual
Install the following dependencies:
pnpm install @radix-ui/react-icons radix-uiCopy and paste the class names utility into your utils/class-names.ts file.
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy and paste the use as ref hook into your hooks/use-as-ref.ts file.
import * as React from "react";
import { useIsomorphicLayoutEffect } from "@/hooks/use-isomorphic-layout-effect";
function useAsRef<T>(props: T) {
const ref = React.useRef<T>(props);
useIsomorphicLayoutEffect(() => {
ref.current = props;
});
return ref;
}
export { useAsRef };
Copy and paste the use lazy ref hook into your hooks/use-lazy-ref.ts file.
import * as React from "react";
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>;
}
export { useLazyRef };
Copy and paste the FileUpload component into your components/ui/FileUpload.tsx file.
"use client";
/**
* @description Composable file upload with dropzone, list, previews, and progress.
*/
import * as React from "react";
import {
ArchiveIcon,
CodeIcon,
FileIcon,
FileTextIcon,
GearIcon,
SpeakerLoudIcon,
VideoIcon,
} from "@radix-ui/react-icons";
import {
Direction as DirectionPrimitive,
Slot as SlotPrimitive,
} from "radix-ui";
import { cn } from "@/utils/class-names";
import { useAsRef } from "@/hooks/use-as-ref";
import { useLazyRef } from "@/hooks/use-lazy-ref";
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 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 <VideoIcon className="icon h4" aria-hidden />;
}
if (type.startsWith("audio/")) {
return <SpeakerLoudIcon className="icon h4" aria-hidden />;
}
if (
type.startsWith("text/") ||
["txt", "md", "rtf", "pdf"].includes(extension)
) {
return <FileTextIcon className="icon h4" aria-hidden />;
}
if (
[
"html",
"css",
"js",
"jsx",
"ts",
"tsx",
"json",
"xml",
"php",
"py",
"rb",
"java",
"c",
"cpp",
"cs",
].includes(extension)
) {
return <CodeIcon className="icon h4" aria-hidden />;
}
if (["zip", "rar", "7z", "tar", "gz", "bz2"].includes(extension)) {
return <ArchiveIcon className="icon h4" aria-hidden />;
}
if (
["exe", "msi", "app", "apk", "deb", "rpm"].includes(extension) ||
type.startsWith("application/")
) {
return <GearIcon className="icon h4" aria-hidden />;
}
return <FileIcon className="icon h4" aria-hidden />;
}
type Direction = "ltr" | "rtl";
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" };
type Store = {
getState: () => StoreState;
dispatch: (action: StoreAction) => void;
subscribe: (listener: () => void) => () => void;
};
const StoreContext = React.createContext<Store | 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("useStore");
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>;
urlCache: WeakMap<File, string>;
}
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 FileUploadProps extends Omit<
React.ComponentProps<"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 FileUpload(props: FileUploadProps) {
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 = DirectionPrimitive.useDirection(dirProp);
const listeners = useLazyRef(() => new Set<() => void>()).current;
const files = useLazyRef<Map<File, FileState>>(() => new Map()).current;
const urlCache = useLazyRef(() => new WeakMap<File, string>()).current;
const inputRef = React.useRef<HTMLInputElement>(null);
const isControlled = value !== undefined;
const propsRef = useAsRef({
onValueChange,
onAccept,
onFileAccept,
onFileReject,
onFileValidate,
onUpload,
});
const store = React.useMemo<Store>(() => {
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 (propsRef.current.onValueChange) {
const fileList = Array.from(files.values()).map(
(fileState) => fileState.file,
);
propsRef.current.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": {
const cachedUrl = urlCache.get(action.file);
if (cachedUrl) {
URL.revokeObjectURL(cachedUrl);
urlCache.delete(action.file);
}
files.delete(action.file);
if (propsRef.current.onValueChange) {
const fileList = Array.from(files.values()).map(
(fileState) => fileState.file,
);
propsRef.current.onValueChange(fileList);
}
return { ...state, files };
}
case "SET_DRAG_OVER": {
return { ...state, dragOver: action.dragOver };
}
case "SET_INVALID": {
return { ...state, invalid: action.invalid };
}
case "CLEAR": {
for (const file of files.keys()) {
const cachedUrl = urlCache.get(file);
if (cachedUrl) {
URL.revokeObjectURL(cachedUrl);
urlCache.delete(file);
}
}
files.clear();
if (propsRef.current.onValueChange) {
propsRef.current.onValueChange([]);
}
return { ...state, files, invalid: false };
}
default:
return state;
}
}
return {
getState: () => state,
dispatch: (action) => {
state = reducer(state, action);
for (const listener of listeners) {
listener();
}
},
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}, [listeners, files, invalid, propsRef, urlCache]);
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]);
React.useEffect(() => {
return () => {
for (const file of files.keys()) {
const cachedUrl = urlCache.get(file);
if (cachedUrl) {
URL.revokeObjectURL(cachedUrl);
}
}
};
}, [files, urlCache]);
const onFilesUpload = React.useCallback(
async (files: File[]) => {
try {
for (const file of files) {
store.dispatch({ type: "SET_PROGRESS", file, progress: 0 });
}
if (propsRef.current.onUpload) {
await propsRef.current.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, propsRef, onProgress],
);
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 (propsRef.current.onFileValidate) {
const validationMessage = propsRef.current.onFileValidate(file);
if (validationMessage) {
rejectionMessage = validationMessage;
}
}
propsRef.current.onFileReject?.(file, rejectionMessage);
}
}
}
const acceptedFiles: File[] = [];
const rejectedFiles: { file: File; message: string }[] = [];
for (const file of filesToProcess) {
let rejected = false;
let rejectionMessage = "";
if (propsRef.current.onFileValidate) {
const validationMessage = propsRef.current.onFileValidate(file);
if (validationMessage) {
rejectionMessage = validationMessage;
propsRef.current.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";
propsRef.current.onFileReject?.(file, rejectionMessage);
rejected = true;
invalid = true;
}
}
if (maxSize && file.size > maxSize) {
rejectionMessage = "File too large";
propsRef.current.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 && propsRef.current.onValueChange) {
const currentFiles = Array.from(store.getState().files.values()).map(
(f) => f.file,
);
propsRef.current.onValueChange([...currentFiles]);
}
if (propsRef.current.onAccept) {
propsRef.current.onAccept(acceptedFiles);
}
for (const file of acceptedFiles) {
propsRef.current.onFileAccept?.(file);
}
if (propsRef.current.onUpload) {
requestAnimationFrame(() => {
onFilesUpload(acceptedFiles);
});
}
}
},
[
store,
isControlled,
propsRef,
onFilesUpload,
maxFiles,
acceptTypes,
maxSize,
disabled,
],
);
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,
urlCache,
}),
[dropzoneId, inputId, listId, labelId, dir, disabled, urlCache],
);
const RootPrimitive = asChild ? SlotPrimitive.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-1", 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}
/>
<div id={labelId} className="sr-only">
{label ?? "File upload"}
</div>
</RootPrimitive>
</FileUploadContext.Provider>
</StoreContext.Provider>
);
}
interface FileUploadDropzoneProps extends React.ComponentProps<"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 propsRef = useAsRef({
onClick: onClickProp,
onDragOver: onDragOverProp,
onDragEnter: onDragEnterProp,
onDragLeave: onDragLeaveProp,
onDrop: onDropProp,
onPaste: onPasteProp,
onKeyDown: onKeyDownProp,
});
const onClick = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
propsRef.current.onClick?.(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, propsRef],
);
const onDragOver = React.useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
propsRef.current.onDragOver?.(event);
if (event.defaultPrevented) return;
event.preventDefault();
store.dispatch({ type: "SET_DRAG_OVER", dragOver: true });
},
[store, propsRef],
);
const onDragEnter = React.useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
propsRef.current.onDragEnter?.(event);
if (event.defaultPrevented) return;
event.preventDefault();
store.dispatch({ type: "SET_DRAG_OVER", dragOver: true });
},
[store, propsRef],
);
const onDragLeave = React.useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
propsRef.current.onDragLeave?.(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, propsRef],
);
const onDrop = React.useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
propsRef.current.onDrop?.(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, propsRef],
);
const onPaste = React.useCallback(
(event: React.ClipboardEvent<HTMLDivElement>) => {
propsRef.current.onPaste?.(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, propsRef],
);
const onKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
propsRef.current.onKeyDown?.(event);
if (
!event.defaultPrevented &&
(event.key === "Enter" || event.key === " ")
) {
event.preventDefault();
context.inputRef.current?.click();
}
},
[context.inputRef, propsRef],
);
const DropzonePrimitive = asChild ? SlotPrimitive.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 flex-col items-center justify-center gap-1 rounded-lg border-2 border-dashed border-gray-7 bg-gray-1 p-2 text-gray-12 transition-colors outline-none select-none hover:bg-accent-3 focus-visible:border-gray-8 focus-visible:ring-2 focus-visible:ring-gray-8 data-disabled:pointer-events-none data-dragging:border-accent-8 data-dragging:bg-accent-3 data-invalid:border-danger data-invalid:ring-2 data-invalid:ring-danger/20",
className,
)}
onClick={onClick}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
onKeyDown={onKeyDown}
onPaste={onPaste}
/>
);
}
interface FileUploadTriggerProps extends React.ComponentProps<"button"> {
asChild?: boolean;
}
function FileUploadTrigger(props: FileUploadTriggerProps) {
const { asChild, onClick: onClickProp, ...triggerProps } = props;
const context = useFileUploadContext(TRIGGER_NAME);
const propsRef = useAsRef({
onClick: onClickProp,
});
const onClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
propsRef.current.onClick?.(event);
if (event.defaultPrevented) return;
context.inputRef.current?.click();
},
[context.inputRef, propsRef],
);
const TriggerPrimitive = asChild ? SlotPrimitive.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.ComponentProps<"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 ? SlotPrimitive.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(
"flex flex-col gap-1 data-[state=active]:animate-file-upload-list-show",
orientation === "horizontal" && "flex-row overflow-x-auto p-1",
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.ComponentProps<"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 ? SlotPrimitive.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-1 rounded-md border border-gray-6 bg-gray-2 p-1",
className,
)}
>
{props.children}
<span id={statusId} className="sr-only">
{statusText}
</span>
</ItemPrimitive>
</FileUploadItemContext.Provider>
);
}
interface FileUploadItemPreviewProps extends React.ComponentProps<"div"> {
render?: (file: File, fallback: () => React.ReactNode) => React.ReactNode;
asChild?: boolean;
}
function FileUploadItemPreview(props: FileUploadItemPreviewProps) {
const { render, asChild, children, className, ...previewProps } = props;
const itemContext = useFileUploadItemContext(ITEM_PREVIEW_NAME);
const context = useFileUploadContext(ITEM_PREVIEW_NAME);
const getDefaultRender = React.useCallback(
(file: File) => {
if (itemContext.fileState?.file.type.startsWith("image/")) {
let url = context.urlCache.get(file);
if (!url) {
url = URL.createObjectURL(file);
context.urlCache.set(file, url);
}
return (
<img src={url} alt={file.name} className="size-full object-cover" />
);
}
return getFileIcon(file);
},
[itemContext.fileState?.file.type, context.urlCache],
);
const onPreviewRender = React.useCallback(
(file: File) => {
if (render) {
return render(file, () => getDefaultRender(file));
}
return getDefaultRender(file);
},
[render, getDefaultRender],
);
if (!itemContext.fileState) return null;
const ItemPreviewPrimitive = asChild ? SlotPrimitive.Slot : "div";
return (
<ItemPreviewPrimitive
aria-labelledby={itemContext.nameId}
data-slot="file-upload-preview"
{...previewProps}
className={cn(
"relative flex size-3 shrink-0 items-center justify-center overflow-hidden rounded border border-gray-6 bg-accent-3 text-gray-11",
className,
)}
>
{onPreviewRender(itemContext.fileState.file)}
{children}
</ItemPreviewPrimitive>
);
}
interface FileUploadItemMetadataProps extends React.ComponentProps<"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 ? SlotPrimitive.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 text-sm font-medium text-gray-12",
size === "sm" && "text-xs leading-snug font-normal",
)}
>
{itemContext.fileState.file.name}
</span>
<span
id={itemContext.sizeId}
className={cn(
"truncate text-xs text-gray-11",
size === "sm" && "text-xs leading-snug",
)}
>
{formatBytes(itemContext.fileState.file.size)}
</span>
{itemContext.fileState.error && (
<span
id={itemContext.messageId}
className="text-xs text-danger-contrast"
>
{itemContext.fileState.error}
</span>
)}
</>
)}
</ItemMetadataPrimitive>
);
}
interface FileUploadItemProgressProps extends React.ComponentProps<"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 ? SlotPrimitive.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(
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-accent-9",
className,
)}
>
<svg
className="-rotate-90 transform"
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
fill="none"
stroke="currentColor"
>
<circle
className="text-accent-a5"
strokeWidth="2"
cx={size / 2}
cy={size / 2}
r={(size - 4) / 2}
/>
<circle
className="text-accent-9 transition-[stroke-dashoffset] duration-300 ease-linear motion-reduce:transition-none"
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-accent-9/50 transition-[clip-path] duration-300 ease-linear motion-reduce:transition-none",
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-0.5 w-full overflow-hidden rounded-full bg-accent-a4",
className,
)}
>
<div
className="h-full w-full flex-1 bg-accent-9 transition-transform duration-300 ease-linear motion-reduce:transition-none"
style={{
transform: `translateX(-${100 - itemContext.fileState.progress}%)`,
}}
/>
</ItemProgressPrimitive>
);
}
}
interface FileUploadItemDeleteProps extends React.ComponentProps<"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 ? SlotPrimitive.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.ComponentProps<"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 ? SlotPrimitive.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 {
FileUpload,
FileUploadClear,
FileUploadDropzone,
FileUploadItem,
FileUploadItemDelete,
FileUploadItemMetadata,
FileUploadItemPreview,
FileUploadItemProgress,
FileUploadList,
type FileUploadProps,
FileUploadTrigger,
useStore as useFileUpload,
};
Usage
WithValidation
export const WithValidation = () => {
const [files, setFiles] = React.useState<File[]>([]);
const onFileValidate = React.useCallback(
(file: File): string | null => {
if (files.length >= 2) {
return "You can only upload up to 2 files";
}
if (!file.type.startsWith("image/")) {
return "Only image files are allowed";
}
const maxSize = 2 * 1024 * 1024;
if (file.size > maxSize) {
return "File size must be less than 2MB";
}
return null;
},
[files],
);
return (
<FileUpload
value={files}
onValueChange={setFiles}
onFileValidate={onFileValidate}
onFileReject={(file, message) => {
toast(message, {
description: `"${file.name.length > 20 ? `${file.name.slice(0, 20)}...` : file.name}" has been rejected`,
});
}}
accept="image/*"
maxFiles={2}
className="w-full max-w-md"
multiple
>
<FileUploadDropzone>
<div className="flex flex-col items-center gap-1 text-center">
<div className="flex size-4 items-center justify-center rounded-full border border-gray-6 bg-gray-2 text-gray-11">
<UploadIcon className="icon h4" aria-hidden />
</div>
<p className="text-sm font-medium text-gray-12">
Drag & drop files here
</p>
<p className="text-xs text-gray-11">
Images only — max 2 files, 2MB each
</p>
</div>
<FileUploadTrigger asChild>
<Button type="button" variant="pill" size="sm" className="mt-1 w-fit">
Browse files
</Button>
</FileUploadTrigger>
</FileUploadDropzone>
<FileUploadList>
{files.map((file) => (
<FileUploadItem key={file.name} value={file}>
<FileUploadItemPreview />
<FileUploadItemMetadata />
<FileUploadItemDelete asChild>
<Button
type="button"
variant="pill"
size="icon"
aria-label={`Remove ${file.name}`}
>
<Cross2Icon className="icon" />
</Button>
</FileUploadItemDelete>
</FileUploadItem>
))}
</FileUploadList>
</FileUpload>
);
};DirectUpload
export const DirectUpload = () => {
const [files, setFiles] = React.useState<File[]>([]);
const onUpload: NonNullable<FileUploadProps["onUpload"]> = React.useCallback(
async (nextFiles, { onProgress, onSuccess, onError }) => {
await Promise.all(
nextFiles.map(async (file) => {
try {
const totalChunks = 10;
for (let i = 0; i < totalChunks; i++) {
await new Promise((resolve) =>
setTimeout(resolve, Math.random() * 200 + 100),
);
onProgress(file, ((i + 1) / totalChunks) * 100);
}
await new Promise((resolve) => setTimeout(resolve, 250));
onSuccess(file);
} catch (error) {
onError(
file,
error instanceof Error ? error : new Error("Upload failed"),
);
}
}),
);
},
[],
);
return (
<FileUpload
value={files}
onValueChange={setFiles}
onUpload={onUpload}
onFileReject={(file, message) => {
toast(message, {
description: `"${file.name.length > 20 ? `${file.name.slice(0, 20)}...` : file.name}" has been rejected`,
});
}}
maxFiles={2}
className="w-full max-w-md"
multiple
>
<FileUploadDropzone>
<div className="flex flex-col items-center gap-1 text-center">
<div className="flex size-4 items-center justify-center rounded-full border border-gray-6 bg-gray-2 text-gray-11">
<UploadIcon className="icon h4" aria-hidden />
</div>
<p className="text-sm font-medium text-gray-12">
Drag & drop files here
</p>
<p className="text-xs text-gray-11">
Or click to browse (max 2 files)
</p>
</div>
<FileUploadTrigger asChild>
<Button type="button" variant="pill" size="sm" className="mt-1 w-fit">
Browse files
</Button>
</FileUploadTrigger>
</FileUploadDropzone>
<FileUploadList>
{files.map((file) => (
<FileUploadItem key={file.name} value={file} className="flex-col">
<div className="flex w-full items-center gap-1">
<FileUploadItemPreview />
<FileUploadItemMetadata />
<FileUploadItemDelete asChild>
<Button
type="button"
variant="pill"
size="icon"
aria-label={`Remove ${file.name}`}
>
<Cross2Icon className="icon" />
</Button>
</FileUploadItemDelete>
</div>
<FileUploadItemProgress />
</FileUploadItem>
))}
</FileUploadList>
<FileUploadClear asChild>
<Button type="button" variant="pill" size="sm" className="w-fit">
Clear all
</Button>
</FileUploadClear>
</FileUpload>
);
};CircularProgress
export const CircularProgress = () => {
const [files, setFiles] = React.useState<File[]>([]);
const onUpload: NonNullable<FileUploadProps["onUpload"]> = React.useCallback(
async (nextFiles, { onProgress, onSuccess, onError }) => {
await Promise.all(
nextFiles.map(async (file) => {
try {
const totalChunks = 10;
for (let i = 0; i < totalChunks; i++) {
await new Promise((resolve) =>
setTimeout(resolve, Math.random() * 200 + 100),
);
onProgress(file, ((i + 1) / totalChunks) * 100);
}
await new Promise((resolve) => setTimeout(resolve, 250));
onSuccess(file);
} catch (error) {
onError(
file,
error instanceof Error ? error : new Error("Upload failed"),
);
}
}),
);
},
[],
);
return (
<FileUpload
value={files}
onValueChange={setFiles}
maxFiles={10}
maxSize={5 * 1024 * 1024}
className="w-full max-w-md"
onUpload={onUpload}
onFileReject={(file, message) => {
toast(message, {
description: `"${file.name.length > 20 ? `${file.name.slice(0, 20)}...` : file.name}" has been rejected`,
});
}}
multiple
>
<FileUploadDropzone>
<div className="flex flex-col items-center gap-1 text-center">
<div className="flex size-4 items-center justify-center rounded-full border border-gray-6 bg-gray-2 text-gray-11">
<UploadIcon className="icon h4" aria-hidden />
</div>
<p className="text-sm font-medium text-gray-12">
Drag & drop files here
</p>
<p className="text-xs text-gray-11">
Or click to browse (max 10 files, up to 5MB each)
</p>
</div>
<FileUploadTrigger asChild>
<Button type="button" variant="pill" size="sm" className="mt-1 w-fit">
Browse files
</Button>
</FileUploadTrigger>
</FileUploadDropzone>
<FileUploadList orientation="horizontal">
{files.map((file) => (
<FileUploadItem key={file.name} value={file} className="p-0">
<FileUploadItemPreview className="size-5">
<FileUploadItemProgress variant="circular" size={40} />
</FileUploadItemPreview>
<FileUploadItemMetadata className="sr-only" />
<FileUploadItemDelete asChild>
<Button
type="button"
variant="pill"
size="icon"
className="absolute -top-0.5 -right-0.5 size-2"
aria-label={`Remove ${file.name}`}
>
<Cross2Icon className="icon" />
</Button>
</FileUploadItemDelete>
</FileUploadItem>
))}
</FileUploadList>
</FileUpload>
);
};FillProgress
export const FillProgress = () => {
const [files, setFiles] = React.useState<File[]>([]);
const onUpload: NonNullable<FileUploadProps["onUpload"]> = React.useCallback(
async (nextFiles, { onProgress, onSuccess, onError }) => {
await Promise.all(
nextFiles.map(async (file) => {
try {
const totalChunks = 10;
for (let i = 0; i < totalChunks; i++) {
await new Promise((resolve) =>
setTimeout(resolve, Math.random() * 200 + 100),
);
onProgress(file, ((i + 1) / totalChunks) * 100);
}
await new Promise((resolve) => setTimeout(resolve, 250));
onSuccess(file);
} catch (error) {
onError(
file,
error instanceof Error ? error : new Error("Upload failed"),
);
}
}),
);
},
[],
);
return (
<FileUpload
value={files}
onValueChange={setFiles}
maxFiles={10}
maxSize={5 * 1024 * 1024}
className="w-full max-w-md"
onUpload={onUpload}
onFileReject={(file, message) => {
toast(message, {
description: `"${file.name.length > 20 ? `${file.name.slice(0, 20)}...` : file.name}" has been rejected`,
});
}}
multiple
>
<FileUploadDropzone>
<div className="flex flex-col items-center gap-1 text-center">
<div className="flex size-4 items-center justify-center rounded-full border border-gray-6 bg-gray-2 text-gray-11">
<UploadIcon className="icon h4" aria-hidden />
</div>
<p className="text-sm font-medium text-gray-12">
Drag & drop files here
</p>
<p className="text-xs text-gray-11">
Or click to browse (max 10 files, up to 5MB each)
</p>
</div>
<FileUploadTrigger asChild>
<Button type="button" variant="pill" size="sm" className="mt-1 w-fit">
Browse files
</Button>
</FileUploadTrigger>
</FileUploadDropzone>
<FileUploadList orientation="horizontal">
{files.map((file) => (
<FileUploadItem key={file.name} value={file} className="p-0">
<FileUploadItemPreview className="size-5">
<FileUploadItemProgress variant="fill" />
</FileUploadItemPreview>
<FileUploadItemMetadata className="sr-only" />
<FileUploadItemDelete asChild>
<Button
type="button"
variant="pill"
size="icon"
className="absolute -top-0.5 -right-0.5 size-2"
aria-label={`Remove ${file.name}`}
>
<Cross2Icon className="icon" />
</Button>
</FileUploadItemDelete>
</FileUploadItem>
))}
</FileUploadList>
</FileUpload>
);
};Empty
A placeholder component for empty states, with optional icon, title, description, and content slots.
Form
A comprehensive form system with schema-based validation, error handling, and field management. Built with Radix UI primitives and AJV validation, supporting text inputs, textareas, selects, checkboxes, switches, and checkbox groups with automatic error propagation and touch state management.