File Upload
Drag and drop or browse for files, with validation, previews and a hook for custom UIs.
NewDrop a file here or click to browse
Any file up to 5 MB
1'use client';23import { FileUpload } from '@/components/ui/file-upload';4import { CloudUploadIcon } from '@/components/ui/cloud-upload';56export function Default() {7 return (8 <FileUpload className="max-w-md" maxSize={5 * 1024 * 1024}>9 <FileUpload.Dropzone>10 <CloudUploadIcon strokeWidth={1.5} />11 <p className="text-foreground font-medium">Drop a file here or click to browse</p>12 <p className="text-xs">Any file up to 5 MB</p>13 </FileUpload.Dropzone>14 <FileUpload.List />15 <FileUpload.Errors />16 </FileUpload>17 );18}
Installation
Copy and paste the following code into your project.
1'use client';23import * as React from 'react';4import { cn } from '../lib/cn';56type IconProps = React.SVGProps<SVGSVGElement> & {7 size?: number | string;8};910function FileIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {11 return (12 <svg13 xmlns="http://www.w3.org/2000/svg"14 width={size}15 height={size}16 viewBox="0 0 24 24"17 fill="none"18 stroke="currentColor"19 strokeWidth={strokeWidth}20 strokeLinecap="round"21 strokeLinejoin="round"22 aria-hidden="true"23 {...props}24 >25 <path d="M13.5 3H7.5A2.5 2.5 0 0 0 5 5.5v13A2.5 2.5 0 0 0 7.5 21h9a2.5 2.5 0 0 0 2.5-2.5V8.5L13.5 3Z" />26 <path d="M13.5 3v3.5a2 2 0 0 0 2 2H19" />27 </svg>28 );29}3031function XIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {32 return (33 <svg34 xmlns="http://www.w3.org/2000/svg"35 width={size}36 height={size}37 viewBox="0 0 24 24"38 fill="none"39 stroke="currentColor"40 strokeWidth={strokeWidth}41 strokeLinecap="round"42 strokeLinejoin="round"43 aria-hidden="true"44 {...props}45 >46 <path d="M18 6 6 18" />47 <path d="m6 6 12 12" />48 </svg>49 );50}5152interface FileMetadata {53 id: string;54 name: string;55 size: number;56 type: string;57 url: string;58}5960interface FileWithPreview {61 id: string;62 file: File | FileMetadata;63 preview?: string;64}6566interface UseFileUploadOptions {67 accept?: string;68 multiple?: boolean;69 maxFiles?: number;70 maxSize?: number;71 initialFiles?: FileMetadata[];72 disabled?: boolean;73 onFilesChange?: (files: FileWithPreview[]) => void;74 onFilesAdded?: (files: FileWithPreview[]) => void;75 onError?: (errors: string[]) => void;76}7778interface FileUploadInputProps {79 ref: React.RefObject<HTMLInputElement | null>;80 type: 'file';81 accept: string | undefined;82 multiple: boolean;83 disabled: boolean;84 onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;85}8687interface FileUploadDropzoneHandlers {88 onDragEnter: (event: React.DragEvent<HTMLElement>) => void;89 onDragLeave: (event: React.DragEvent<HTMLElement>) => void;90 onDragOver: (event: React.DragEvent<HTMLElement>) => void;91 onDrop: (event: React.DragEvent<HTMLElement>) => void;92}9394interface UseFileUploadReturn {95 files: FileWithPreview[];96 isDragging: boolean;97 errors: string[];98 disabled: boolean;99 inputRef: React.RefObject<HTMLInputElement | null>;100 addFiles: (files: FileList | File[]) => void;101 removeFile: (id: string) => void;102 clearFiles: () => void;103 clearErrors: () => void;104 openFileDialog: () => void;105 getInputProps: () => FileUploadInputProps;106 getDropzoneProps: () => FileUploadDropzoneHandlers;107 handleDragEnter: (event: React.DragEvent<HTMLElement>) => void;108 handleDragLeave: (event: React.DragEvent<HTMLElement>) => void;109 handleDragOver: (event: React.DragEvent<HTMLElement>) => void;110 handleDrop: (event: React.DragEvent<HTMLElement>) => void;111}112113const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;114115function formatBytes(bytes: number, decimals = 1): string {116 if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';117 const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), BYTE_UNITS.length - 1);118 const value = bytes / 1024 ** exponent;119 const rounded = exponent === 0 ? value : Number(value.toFixed(decimals));120 return `${rounded} ${BYTE_UNITS[exponent]}`;121}122123function isFileMetadata(file: File | FileMetadata): file is FileMetadata {124 return typeof File === 'undefined' || !(file instanceof File);125}126127function getFileId(file: File | FileMetadata): string {128 if (isFileMetadata(file)) return file.id;129 return `${file.name}-${file.size}-${file.lastModified}`;130}131132function getFilePreview(file: File | FileMetadata): string | undefined {133 if (isFileMetadata(file)) return file.type.startsWith('image/') ? file.url : undefined;134 if (!file.type.startsWith('image/')) return undefined;135 if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') return undefined;136 return URL.createObjectURL(file);137}138139function revokePreview(entry: FileWithPreview) {140 if (!entry.preview || isFileMetadata(entry.file)) return;141 if (typeof URL !== 'undefined' && typeof URL.revokeObjectURL === 'function') {142 URL.revokeObjectURL(entry.preview);143 }144}145146function matchesAccept(file: File, accept: string): boolean {147 const rules = accept148 .split(',')149 .map((rule) => rule.trim().toLowerCase())150 .filter(Boolean);151 if (rules.length === 0 || rules.includes('*') || rules.includes('*/*')) return true;152153 const name = file.name.toLowerCase();154 const type = file.type.toLowerCase();155156 return rules.some((rule) => {157 if (rule.startsWith('.')) return name.endsWith(rule);158 if (rule.endsWith('/*')) return type.startsWith(rule.slice(0, -1));159 return type === rule;160 });161}162163function validateFile(file: File, accept: string | undefined, maxSize: number): string | null {164 if (file.size > maxSize) {165 return `"${file.name}" exceeds the maximum size of ${formatBytes(maxSize)}.`;166 }167 if (accept && !matchesAccept(file, accept)) {168 return `"${file.name}" is not an accepted file type.`;169 }170 return null;171}172173function useFileUpload(options: UseFileUploadOptions = {}): UseFileUploadReturn {174 const {175 accept,176 multiple = false,177 maxFiles = Infinity,178 maxSize = Infinity,179 initialFiles = [],180 disabled = false,181 onFilesChange,182 onFilesAdded,183 onError,184 } = options;185186 const [files, setFiles] = React.useState<FileWithPreview[]>(() =>187 initialFiles.map((file) => ({ id: file.id, file, preview: getFilePreview(file) })),188 );189 const [isDragging, setIsDragging] = React.useState(false);190 const [errors, setErrors] = React.useState<string[]>([]);191 const inputRef = React.useRef<HTMLInputElement>(null);192 const dragDepth = React.useRef(0);193 const filesRef = React.useRef(files);194195 const syncInput = React.useCallback(() => {196 const input = inputRef.current;197 if (!input || typeof DataTransfer === 'undefined') return;198 try {199 const transfer = new DataTransfer();200 for (const entry of filesRef.current) {201 if (!isFileMetadata(entry.file)) transfer.items.add(entry.file);202 }203 input.files = transfer.files;204 } catch {205 return;206 }207 }, []);208209 React.useEffect(() => {210 filesRef.current = files;211 syncInput();212 }, [files, syncInput]);213214 React.useEffect(() => {215 return () => {216 filesRef.current.forEach(revokePreview);217 };218 }, []);219220 const commit = React.useCallback(221 (next: FileWithPreview[]) => {222 setFiles(next);223 onFilesChange?.(next);224 },225 [onFilesChange],226 );227228 const reportErrors = React.useCallback(229 (next: string[]) => {230 setErrors(next);231 if (next.length > 0) onError?.(next);232 },233 [onError],234 );235236 const addFiles = React.useCallback(237 (incoming: FileList | File[]) => {238 if (disabled) return;239 const candidates = Array.from(incoming);240 if (candidates.length === 0) return;241242 const batch = multiple ? candidates : candidates.slice(0, 1);243 const nextErrors: string[] = [];244245 if (multiple && files.length + batch.length > maxFiles) {246 reportErrors([`You can upload a maximum of ${maxFiles} files.`]);247 return;248 }249250 const accepted: FileWithPreview[] = [];251 for (const file of batch) {252 const id = getFileId(file);253 if (multiple && files.some((entry) => entry.id === id)) {254 nextErrors.push(`"${file.name}" is already in the list.`);255 continue;256 }257 const problem = validateFile(file, accept, maxSize);258 if (problem) {259 nextErrors.push(problem);260 continue;261 }262 accepted.push({ id, file, preview: getFilePreview(file) });263 }264265 if (accepted.length > 0) {266 if (!multiple) files.forEach(revokePreview);267 const next = multiple ? [...files, ...accepted] : accepted;268 commit(next);269 onFilesAdded?.(accepted);270 }271272 reportErrors(nextErrors);273 },274 [accept, commit, disabled, files, maxFiles, maxSize, multiple, onFilesAdded, reportErrors],275 );276277 const removeFile = React.useCallback(278 (id: string) => {279 const target = files.find((entry) => entry.id === id);280 if (!target) return;281 revokePreview(target);282 commit(files.filter((entry) => entry.id !== id));283 setErrors([]);284 },285 [commit, files],286 );287288 const clearFiles = React.useCallback(() => {289 files.forEach(revokePreview);290 if (inputRef.current) inputRef.current.value = '';291 commit([]);292 setErrors([]);293 }, [commit, files]);294295 const clearErrors = React.useCallback(() => setErrors([]), []);296297 const openFileDialog = React.useCallback(() => {298 if (disabled) return;299 inputRef.current?.click();300 }, [disabled]);301302 const handleDragEnter = React.useCallback(303 (event: React.DragEvent<HTMLElement>) => {304 event.preventDefault();305 event.stopPropagation();306 dragDepth.current += 1;307 if (!disabled && event.dataTransfer.items.length > 0) setIsDragging(true);308 },309 [disabled],310 );311312 const handleDragLeave = React.useCallback((event: React.DragEvent<HTMLElement>) => {313 event.preventDefault();314 event.stopPropagation();315 dragDepth.current = Math.max(0, dragDepth.current - 1);316 if (dragDepth.current === 0) setIsDragging(false);317 }, []);318319 const handleDragOver = React.useCallback((event: React.DragEvent<HTMLElement>) => {320 event.preventDefault();321 event.stopPropagation();322 }, []);323324 const handleDrop = React.useCallback(325 (event: React.DragEvent<HTMLElement>) => {326 event.preventDefault();327 event.stopPropagation();328 dragDepth.current = 0;329 setIsDragging(false);330 if (disabled) return;331 addFiles(event.dataTransfer.files);332 },333 [addFiles, disabled],334 );335336 const onChange = React.useCallback(337 (event: React.ChangeEvent<HTMLInputElement>) => {338 if (event.target.files) addFiles(event.target.files);339 event.target.value = '';340 syncInput();341 },342 [addFiles, syncInput],343 );344345 const getInputProps = React.useCallback(346 (): FileUploadInputProps => ({347 ref: inputRef,348 type: 'file',349 accept,350 multiple,351 disabled,352 onChange,353 }),354 [accept, disabled, multiple, onChange],355 );356357 const getDropzoneProps = React.useCallback(358 (): FileUploadDropzoneHandlers => ({359 onDragEnter: handleDragEnter,360 onDragLeave: handleDragLeave,361 onDragOver: handleDragOver,362 onDrop: handleDrop,363 }),364 [handleDragEnter, handleDragLeave, handleDragOver, handleDrop],365 );366367 return React.useMemo(368 () => ({369 files,370 isDragging,371 errors,372 disabled,373 inputRef,374 addFiles,375 removeFile,376 clearFiles,377 clearErrors,378 openFileDialog,379 getInputProps,380 getDropzoneProps,381 handleDragEnter,382 handleDragLeave,383 handleDragOver,384 handleDrop,385 }),386 [387 files,388 isDragging,389 errors,390 disabled,391 addFiles,392 removeFile,393 clearFiles,394 clearErrors,395 openFileDialog,396 getInputProps,397 getDropzoneProps,398 handleDragEnter,399 handleDragLeave,400 handleDragOver,401 handleDrop,402 ],403 );404}405406function defaultStatusLabel(count: number) {407 if (count === 0) return 'No files selected';408 return count === 1 ? '1 file selected' : `${count} files selected`;409}410411function focusFallback(root: HTMLElement) {412 const target =413 root.querySelector<HTMLElement>('[data-slot="file-upload-dropzone"]:not([aria-disabled])') ??414 root.querySelector<HTMLElement>('[data-slot="file-upload-trigger"]:not(:disabled)');415 target?.focus();416}417418function holdsFocus(control: HTMLElement) {419 const active = document.activeElement;420 return active instanceof Node && control.contains(active);421}422423function moveFocusFromRemove(control: HTMLElement) {424 const root = control.closest<HTMLElement>('[data-slot="file-upload"]');425 if (!root || !holdsFocus(control)) return;426 const buttons = Array.from(427 root.querySelectorAll<HTMLElement>('[data-slot="file-upload-item-remove"]:not(:disabled)'),428 );429 const index = buttons.indexOf(control);430 const next = buttons[index + 1] ?? buttons[index - 1];431 if (next) next.focus();432 else focusFallback(root);433}434435function moveFocusFromClear(control: HTMLElement) {436 const root = control.closest<HTMLElement>('[data-slot="file-upload"]');437 if (root && holdsFocus(control)) focusFallback(root);438}439440const FileUploadContext = React.createContext<UseFileUploadReturn | null>(null);441const FileUploadItemContext = React.createContext<FileWithPreview | null>(null);442443function useFileUploadContext(): UseFileUploadReturn {444 const context = React.use(FileUploadContext);445 if (!context) throw new Error('FileUpload components must be used within FileUpload');446 return context;447}448449function useFileUploadItemContext(): FileWithPreview {450 const context = React.use(FileUploadItemContext);451 if (!context) throw new Error('FileUpload item parts must be used within FileUpload.Item');452 return context;453}454455interface FileUploadProps456 extends UseFileUploadOptions, Omit<React.HTMLAttributes<HTMLDivElement>, 'onError'> {457 name?: string;458 getStatusLabel?: (count: number) => string;459}460461const FileUploadRoot = ({462 accept,463 multiple,464 maxFiles,465 maxSize,466 initialFiles,467 disabled,468 onFilesChange,469 onFilesAdded,470 onError,471 name,472 getStatusLabel = defaultStatusLabel,473 className,474 children,475 ref,476 ...props477}: FileUploadProps & { ref?: React.Ref<HTMLDivElement> }) => {478 const upload = useFileUpload({479 accept,480 multiple,481 maxFiles,482 maxSize,483 initialFiles,484 disabled,485 onFilesChange,486 onFilesAdded,487 onError,488 });489 const [announced, setAnnounced] = React.useState({ files: upload.files, message: '' });490491 if (announced.files !== upload.files) {492 setAnnounced({ files: upload.files, message: getStatusLabel(upload.files.length) });493 }494495 return (496 <FileUploadContext value={upload}>497 <div498 ref={ref}499 data-slot="file-upload"500 data-disabled={disabled ? '' : undefined}501 className={cn('flex w-full flex-col gap-3', className)}502 {...props}503 >504 <input505 {...upload.getInputProps()}506 name={name}507 tabIndex={-1}508 aria-hidden="true"509 className="sr-only"510 />511 <span role="status" aria-live="polite" className="sr-only">512 {announced.message}513 </span>514 {children}515 </div>516 </FileUploadContext>517 );518};519520FileUploadRoot.displayName = 'FileUpload';521522type FileUploadDropzoneProps = React.HTMLAttributes<HTMLDivElement>;523524const FileUploadDropzone = ({525 className,526 onClick,527 onKeyDown,528 ref,529 ...props530}: FileUploadDropzoneProps & { ref?: React.Ref<HTMLDivElement> }) => {531 const { disabled, isDragging, openFileDialog, getDropzoneProps } = useFileUploadContext();532533 return (534 <div535 ref={ref}536 role="button"537 tabIndex={disabled ? -1 : 0}538 aria-disabled={disabled || undefined}539 data-slot="file-upload-dropzone"540 data-dragging={isDragging ? '' : undefined}541 data-disabled={disabled ? '' : undefined}542 className={cn(543 'border-border bg-card text-muted-foreground flex min-h-40 w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border border-dashed p-6 text-center text-sm transition-colors outline-none',544 'hover:bg-muted/50 focus-visible:ring-ring ring-offset-background focus-visible:ring-2 focus-visible:ring-offset-2',545 'data-[dragging]:border-primary data-[dragging]:bg-primary/5 data-[disabled]:pointer-events-none data-[disabled]:opacity-40',546 '[&_svg]:size-6',547 className,548 )}549 onClick={(event) => {550 onClick?.(event);551 if (!event.defaultPrevented) openFileDialog();552 }}553 onKeyDown={(event) => {554 onKeyDown?.(event);555 if (event.defaultPrevented) return;556 if (event.key === 'Enter' || event.key === ' ') {557 event.preventDefault();558 openFileDialog();559 }560 }}561 {...getDropzoneProps()}562 {...props}563 />564 );565};566567FileUploadDropzone.displayName = 'FileUploadDropzone';568569type FileUploadTriggerProps = React.ButtonHTMLAttributes<HTMLButtonElement>;570571const FileUploadTrigger = ({572 className,573 onClick,574 disabled: disabledProp,575 ref,576 ...props577}: FileUploadTriggerProps & { ref?: React.Ref<HTMLButtonElement> }) => {578 const { disabled, openFileDialog } = useFileUploadContext();579580 return (581 <button582 ref={ref}583 type="button"584 data-slot="file-upload-trigger"585 disabled={disabled || disabledProp}586 className={cn(587 'border-border text-foreground hover:bg-muted focus-visible:ring-ring ring-offset-background inline-flex h-9 items-center justify-center gap-2 rounded-md border bg-transparent px-4 text-sm font-medium whitespace-nowrap transition-colors outline-none select-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-4',588 className,589 )}590 onClick={(event) => {591 onClick?.(event);592 if (!event.defaultPrevented) openFileDialog();593 }}594 {...props}595 />596 );597};598599FileUploadTrigger.displayName = 'FileUploadTrigger';600601interface FileUploadListProps extends Omit<React.HTMLAttributes<HTMLUListElement>, 'children'> {602 children?: React.ReactNode | ((file: FileWithPreview) => React.ReactNode);603}604605const FileUploadList = ({606 className,607 children,608 ref,609 ...props610}: FileUploadListProps & { ref?: React.Ref<HTMLUListElement> }) => {611 const { files } = useFileUploadContext();612613 if (files.length === 0) return null;614615 let content: React.ReactNode;616 if (typeof children === 'function') {617 content = files.map((file) => <React.Fragment key={file.id}>{children(file)}</React.Fragment>);618 } else if (children === undefined) {619 content = files.map((file) => <FileUploadItem key={file.id} file={file} />);620 } else {621 content = children;622 }623624 return (625 <ul626 ref={ref}627 data-slot="file-upload-list"628 className={cn('m-0 flex list-none flex-col gap-2 p-0', className)}629 {...props}630 >631 {content}632 </ul>633 );634};635636FileUploadList.displayName = 'FileUploadList';637638interface FileUploadItemProps extends React.LiHTMLAttributes<HTMLLIElement> {639 file: FileWithPreview;640}641642const FileUploadItem = ({643 file,644 className,645 children,646 ref,647 ...props648}: FileUploadItemProps & { ref?: React.Ref<HTMLLIElement> }) => {649 return (650 <FileUploadItemContext value={file}>651 <li652 ref={ref}653 data-slot="file-upload-item"654 className={cn(655 'border-border bg-card flex items-center gap-3 rounded-lg border p-3',656 className,657 )}658 {...props}659 >660 {children ?? (661 <>662 <FileUploadItemPreview />663 <FileUploadItemInfo />664 <FileUploadItemRemove />665 </>666 )}667 </li>668 </FileUploadItemContext>669 );670};671672FileUploadItem.displayName = 'FileUploadItem';673674type FileUploadItemPreviewProps = React.HTMLAttributes<HTMLDivElement>;675676const FileUploadItemPreview = ({677 className,678 children,679 ref,680 ...props681}: FileUploadItemPreviewProps & { ref?: React.Ref<HTMLDivElement> }) => {682 const { preview } = useFileUploadItemContext();683684 return (685 <div686 ref={ref}687 aria-hidden="true"688 data-slot="file-upload-item-preview"689 className={cn(690 'bg-muted text-muted-foreground flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-md [&_svg]:size-5',691 className,692 )}693 {...props}694 >695 {preview ? (696 <img src={preview} alt="" className="size-full object-cover" />697 ) : (698 (children ?? <FileIcon strokeWidth={1.5} />)699 )}700 </div>701 );702};703704FileUploadItemPreview.displayName = 'FileUploadItemPreview';705706type FileUploadItemInfoProps = React.HTMLAttributes<HTMLDivElement>;707708const FileUploadItemInfo = ({709 className,710 children,711 ref,712 ...props713}: FileUploadItemInfoProps & { ref?: React.Ref<HTMLDivElement> }) => {714 const { file } = useFileUploadItemContext();715716 return (717 <div718 ref={ref}719 data-slot="file-upload-item-info"720 className={cn('flex min-w-0 flex-1 flex-col gap-0.5', className)}721 {...props}722 >723 <span className="text-foreground truncate text-sm font-medium">{file.name}</span>724 <span className="text-muted-foreground text-xs">{formatBytes(file.size)}</span>725 {children}726 </div>727 );728};729730FileUploadItemInfo.displayName = 'FileUploadItemInfo';731732interface FileUploadItemRemoveProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {733 label?: string;734}735736const FileUploadItemRemove = ({737 label = 'Remove file',738 className,739 onClick,740 children,741 ref,742 ...props743}: FileUploadItemRemoveProps & { ref?: React.Ref<HTMLButtonElement> }) => {744 const { removeFile, disabled } = useFileUploadContext();745 const { id } = useFileUploadItemContext();746747 return (748 <button749 ref={ref}750 type="button"751 aria-label={label}752 data-slot="file-upload-item-remove"753 disabled={disabled}754 className={cn(755 'text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:ring-ring relative inline-flex size-7 shrink-0 items-center justify-center rounded-md transition-colors outline-none before:absolute before:-inset-1 focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-4',756 className,757 )}758 onClick={(event) => {759 onClick?.(event);760 if (event.defaultPrevented) return;761 moveFocusFromRemove(event.currentTarget);762 removeFile(id);763 }}764 {...props}765 >766 {children ?? <XIcon strokeWidth={2} />}767 </button>768 );769};770771FileUploadItemRemove.displayName = 'FileUploadItemRemove';772773type FileUploadErrorsProps = React.HTMLAttributes<HTMLUListElement>;774775const FileUploadErrors = ({776 className,777 ref,778 ...props779}: FileUploadErrorsProps & { ref?: React.Ref<HTMLUListElement> }) => {780 const { errors } = useFileUploadContext();781782 if (errors.length === 0) return null;783784 return (785 <ul786 ref={ref}787 role="alert"788 data-slot="file-upload-errors"789 className={cn(790 'text-destructive-text m-0 flex list-none flex-col gap-1 p-0 text-xs',791 className,792 )}793 {...props}794 >795 {errors.map((error) => (796 <li key={error}>{error}</li>797 ))}798 </ul>799 );800};801802FileUploadErrors.displayName = 'FileUploadErrors';803804type FileUploadClearProps = React.ButtonHTMLAttributes<HTMLButtonElement>;805806const FileUploadClear = ({807 className,808 onClick,809 ref,810 ...props811}: FileUploadClearProps & { ref?: React.Ref<HTMLButtonElement> }) => {812 const { files, clearFiles, disabled } = useFileUploadContext();813814 if (files.length === 0) return null;815816 return (817 <button818 ref={ref}819 type="button"820 data-slot="file-upload-clear"821 disabled={disabled}822 className={cn(823 'text-muted-foreground hover:text-foreground relative self-start text-xs underline-offset-4 transition-colors before:absolute before:-inset-x-2 before:-inset-y-2 hover:underline disabled:pointer-events-none disabled:opacity-40',824 className,825 )}826 onClick={(event) => {827 onClick?.(event);828 if (event.defaultPrevented) return;829 moveFocusFromClear(event.currentTarget);830 clearFiles();831 }}832 {...props}833 />834 );835};836837FileUploadClear.displayName = 'FileUploadClear';838839const FileUpload = Object.assign(FileUploadRoot, {840 Dropzone: FileUploadDropzone,841 Trigger: FileUploadTrigger,842 List: FileUploadList,843 Item: FileUploadItem,844 ItemPreview: FileUploadItemPreview,845 ItemInfo: FileUploadItemInfo,846 ItemRemove: FileUploadItemRemove,847 Errors: FileUploadErrors,848 Clear: FileUploadClear,849});850851export { FileUpload, formatBytes, useFileUpload, useFileUploadContext, useFileUploadItemContext };852export type {853 FileMetadata,854 FileUploadClearProps,855 FileUploadDropzoneHandlers,856 FileUploadDropzoneProps,857 FileUploadErrorsProps,858 FileUploadItemInfoProps,859 FileUploadItemPreviewProps,860 FileUploadItemProps,861 FileUploadItemRemoveProps,862 FileUploadListProps,863 FileUploadProps,864 FileUploadTriggerProps,865 FileWithPreview,866 UseFileUploadOptions,867 UseFileUploadReturn,868};
Update the import paths to match your project structure.
Anatomy
1import { FileUpload } from '@/components/ui/file-upload';
1<FileUpload multiple maxFiles={3} maxSize={2 * 1024 * 1024} accept="image/*,.pdf">2 <FileUpload.Dropzone>3 <UploadIcon />4 <p>Drop files here or click to browse</p>5 </FileUpload.Dropzone>6 <FileUpload.List />7 <FileUpload.Errors />8 <FileUpload.Clear>Remove all</FileUpload.Clear>9</FileUpload>
The root owns the hidden file input and the state: the accepted files, the validation errors and whether something is being dragged over. The dropzone opens the picker on click, Enter or Space and accepts drops.
List renders one Item per file with a preview, the name, the size and a remove button, and takes a render function when you want your own row. Image files get an object URL preview that is revoked when the file leaves the list.Composition
Use the following composition to build a
FileUpload:FileUploadโโโ FileUpload.Dropzoneโโโ FileUpload.Triggerโโโ FileUpload.Listโ โโโ FileUpload.Itemโ โโโ FileUpload.ItemPreviewโ โโโ FileUpload.ItemInfoโ โโโ FileUpload.ItemRemoveโโโ FileUpload.Errorsโโโ FileUpload.Clear
Variants
Multiple with limits
multiple keeps adding to the list. maxFiles, maxSize and accept reject what does not fit and the reasons show up in Errors. A batch that would go over maxFiles is refused as a whole.Drop up to 3 files
PDF, Word or images, 2 MB each
1'use client';23import { FileUpload } from '@/components/ui/file-upload';4import { CloudUploadIcon } from '@/components/ui/cloud-upload';56export function Multiple() {7 return (8 <FileUpload9 className="max-w-md"10 multiple11 maxFiles={3}12 maxSize={2 * 1024 * 1024}13 accept=".pdf,.docx,image/*"14 >15 <FileUpload.Dropzone>16 <CloudUploadIcon strokeWidth={1.5} />17 <p className="text-foreground font-medium">Drop up to 3 files</p>18 <p className="text-xs">PDF, Word or images, 2 MB each</p>19 </FileUpload.Dropzone>20 <FileUpload.List />21 <FileUpload.Errors />22 <FileUpload.Clear>Remove all</FileUpload.Clear>23 </FileUpload>24 );25}
Avatar
A single image with the preview inside the dropzone itself.
useFileUploadContext reads the state from any child, so the preview is a few lines.PNG or JPG up to 1 MB
1'use client';23import { FileUpload, useFileUploadContext } from '@/components/ui/file-upload';4import { CameraIcon } from '@/components/ui/camera';56function AvatarDropzone() {7 const { files } = useFileUploadContext();8 const preview = files[0]?.preview;910 return (11 <FileUpload.Dropzone12 aria-label="Upload avatar"13 className="size-24 min-h-0 overflow-hidden rounded-full p-0"14 >15 {preview ? (16 <img src={preview} alt="" className="size-full object-cover" />17 ) : (18 <CameraIcon strokeWidth={1.5} />19 )}20 </FileUpload.Dropzone>21 );22}2324export function Avatar() {25 return (26 <FileUpload accept="image/*" maxSize={1024 * 1024} className="w-auto items-center gap-2">27 <AvatarDropzone />28 <p className="text-muted-foreground text-xs">PNG or JPG up to 1 MB</p>29 <FileUpload.Clear>Remove photo</FileUpload.Clear>30 <FileUpload.Errors className="text-center" />31 </FileUpload>32 );33}
Compact
Trigger is a button that opens the picker, for forms where a dropzone takes too much room. The list is laid out as chips through className.Images or PDF
1'use client';23import { FileUpload } from '@/components/ui/file-upload';4import { PaperclipIcon } from '@/components/ui/paperclip';56export function Compact() {7 return (8 <FileUpload className="max-w-md" multiple accept="image/*,.pdf">9 <div className="flex items-center gap-3">10 <FileUpload.Trigger>11 <PaperclipIcon />12 Attach files13 </FileUpload.Trigger>14 <span className="text-muted-foreground text-xs">Images or PDF</span>15 </div>16 <FileUpload.List className="flex-row flex-wrap">17 {(file) => (18 <FileUpload.Item file={file} className="max-w-56 py-2 ps-2 pe-2">19 <FileUpload.ItemPreview className="size-8 rounded-sm [&_svg]:size-4" />20 <FileUpload.ItemInfo className="gap-0 [&>span:last-child]:hidden" />21 <FileUpload.ItemRemove className="size-6" />22 </FileUpload.Item>23 )}24 </FileUpload.List>25 <FileUpload.Errors />26 </FileUpload>27 );28}
Progress
Pass a render function to
List and compose the item parts around your own upload logic. This demo simulates the upload with a timer and a Progress bar.Drop files to start uploading
Progress is simulated in this demo
1'use client';23import { useEffect, useRef, useState } from 'react';4import { FileUpload, type FileWithPreview } from '@/components/ui/file-upload';5import { Progress } from '@/components/ui/progress';6import { CloudUploadIcon } from '@/components/ui/cloud-upload';78function UploadRow({ file }: { file: FileWithPreview }) {9 const [value, setValue] = useState(0);10 const timer = useRef<ReturnType<typeof setInterval> | null>(null);1112 useEffect(() => {13 timer.current = setInterval(() => {14 setValue((current) => {15 if (current >= 100) {16 if (timer.current) clearInterval(timer.current);17 return 100;18 }19 return Math.min(100, current + 8);20 });21 }, 180);22 return () => {23 if (timer.current) clearInterval(timer.current);24 };25 }, []);2627 return (28 <FileUpload.Item file={file}>29 <FileUpload.ItemPreview />30 <FileUpload.ItemInfo>31 <div className="mt-1 flex items-center gap-2">32 <Progress value={value} className="h-1 rounded-full" />33 <span className="text-muted-foreground w-9 text-end text-xs tabular-nums">{value}%</span>34 </div>35 </FileUpload.ItemInfo>36 <FileUpload.ItemRemove />37 </FileUpload.Item>38 );39}4041export function UploadProgress() {42 return (43 <FileUpload className="max-w-md" multiple maxFiles={4}>44 <FileUpload.Dropzone>45 <CloudUploadIcon strokeWidth={1.5} />46 <p className="text-foreground font-medium">Drop files to start uploading</p>47 <p className="text-xs">Progress is simulated in this demo</p>48 </FileUpload.Dropzone>49 <FileUpload.List>{(file) => <UploadRow file={file} />}</FileUpload.List>50 <FileUpload.Errors />51 </FileUpload>52 );53}
Image grid
The same parts arranged as a grid of thumbnails, with the remove button revealed on hover.
Drop images here
Up to 6 images
1'use client';23import { FileUpload } from '@/components/ui/file-upload';4import { ImagePlusIcon } from '@/components/ui/image-plus';56export function Images() {7 return (8 <FileUpload className="max-w-md" multiple accept="image/*" maxFiles={6}>9 <FileUpload.Dropzone className="min-h-32">10 <ImagePlusIcon strokeWidth={1.5} />11 <p className="text-foreground font-medium">Drop images here</p>12 <p className="text-xs">Up to 6 images</p>13 </FileUpload.Dropzone>14 <FileUpload.List className="grid grid-cols-3 gap-2">15 {(file) => (16 <FileUpload.Item file={file} className="group relative aspect-square p-0">17 <FileUpload.ItemPreview className="size-full rounded-lg [&_svg]:size-8" />18 <FileUpload.ItemRemove className="bg-background/90 absolute top-1.5 right-1.5 size-6 opacity-0 shadow-sm group-focus-within:opacity-100 group-hover:opacity-100" />19 </FileUpload.Item>20 )}21 </FileUpload.List>22 <FileUpload.Errors />23 </FileUpload>24 );25}
Hook
useFileUpload is the engine behind the component and is exported on its own for interfaces that do not fit the parts above. It takes the same options as the root and returns the state plus the handlers to wire into any element.1import { useFileUpload, formatBytes } from '@/components/ui/file-upload';23const { files, errors, isDragging, openFileDialog, getInputProps, getDropzoneProps, removeFile } =4 useFileUpload({ multiple: true, maxSize: 5 * 1024 * 1024 });56<input {...getInputProps()} className="sr-only" />7<div {...getDropzoneProps()} data-dragging={isDragging || undefined}>...</div>
| Return | Type | Description |
|---|---|---|
files | FileWithPreview[] | Accepted files with an id and an optional preview |
errors | string[] | Messages from the last validation |
isDragging | boolean | A drag is over the dropzone |
addFiles | (files: FileList | File[]) => void | Validate and add files programmatically |
removeFile | (id: string) => void | Remove one file |
clearFiles | () => void | Remove every file |
clearErrors | () => void | Reset the error list |
openFileDialog | () => void | Open the native picker |
getInputProps | () => InputProps | Props for the hidden file input |
getDropzoneProps | () => DragHandlers | Drag enter, leave, over and drop handlers |
formatBytes(bytes, decimals?) turns a byte count into 1.5 MB.API Reference
FileUpload
| Prop | Type | Default | Description |
|---|---|---|---|
accept | string | - | Accepted types, as the native accept attribute |
multiple | boolean | false | Keep adding files instead of replacing |
maxFiles | number | Infinity | Maximum number of files when multiple |
maxSize | number | Infinity | Maximum size per file, in bytes |
initialFiles | FileMetadata[] | [] | Files already uploaded, with id, name, size, type and url |
disabled | boolean | false | Disable the dropzone, trigger and actions |
name | string | - | Name of the hidden input for form submissions |
onFilesChange | (files: FileWithPreview[]) => void | - | Called with the full list after every change |
onFilesAdded | (files: FileWithPreview[]) => void | - | Called with the files accepted in a batch |
onError | (errors: string[]) => void | - | Called when a batch produces validation errors |
getStatusLabel | (count: number) => string | '2 files selected' | Text read by screen readers after files are added or removed. Pass it to translate the message |
className | string | - | Additional CSS classes |
FileUpload.List
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | (file: FileWithPreview) => ReactNode | - | Custom rows. With no children each file renders an Item |
className | string | - | Additional CSS classes |
FileUpload.Item
| Prop | Type | Default | Description |
|---|---|---|---|
file | FileWithPreview | - | The file to render |
children | ReactNode | - | Custom parts. Without children renders preview, info and remove |
className | string | - | Additional CSS classes |
FileUpload.Dropzone, FileUpload.Trigger, FileUpload.ItemPreview, FileUpload.ItemInfo, FileUpload.ItemRemove, FileUpload.Errors, FileUpload.Clear
Parts wired to the shared state. Each accepts
className and the standard attributes of the element it renders. ItemRemove takes a label prop for its accessible name, "Remove file" by default.When a remove button or
Clear takes the item it was focused on away with it, focus moves to the next remove button, or back to the dropzone or trigger when the list is empty, so keyboard users never land on the page body.Found something to improve?
Notice a bug, typo, or missing detail on this page? Help us make the documentation better by opening a GitHub issue.