Search Components...

Upload

Upload component using react-dropzone.

Single Upload:

Multiple Upload:

With error:

Invalid file format

Installation

Install the following dependencies:

npm install react-dropzone framer-motion numeral

1. Copy and paste the following code into your project.

"use client";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/buttons/button";
import { IconButton } from "@/components/ui/buttons/icon-button";
import { CloudUpload, X } from "lucide-react";
import { type Accept, type DropzoneOptions, useDropzone } from "react-dropzone";
import { type HTMLAttributes } from "react";
import { fileFormat } from "@/components/ui/file-thumbnail/utils";
 
import { ALLOWED_PREVIEW, type FileWithPathAndPreview } from "./utils";
import RejectionFiles from "./rejection-files";
import MultiFilePreview from "./preview-multi-file";
import SinglePreview from "./preview-single-file";
 
// ----------------------------------------------------------------------
 
interface UploadProps extends DropzoneOptions {
	disabled?: boolean;
	error?: boolean;
	helperText?: React.ReactNode;
	accept?: Accept;
	className?: Pick<HTMLAttributes<HTMLDivElement>, "className">;
	multiple?: boolean;
	thumbnail?: boolean;
 
	file?: FileWithPathAndPreview | null | string;
	onDelete?: () => any;
 
	files?: (FileWithPathAndPreview | null | string)[];
	onRemove?: (file: FileWithPathAndPreview | string, index: number) => any;
	onRemoveAll?: () => any;
	onUpload?: () => void;
}
 
const Upload = (props: UploadProps) => {
	const {
		disabled,
		multiple = false,
		error,
		helperText,
		//
		file,
		onDelete,
		//
		files,
		thumbnail = false,
		onUpload,
		onRemove,
		onRemoveAll,
		className,
		...other
	} = props;
 
	const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({
		multiple,
		disabled,
		...other,
	});
 
	const hasError = isDragReject || !!error;
	const hasFile = !!file && !multiple;
	const hasFiles = !!files && multiple && !!files.length;
 
	const isFileAllowedPreview = !hasFile ? false : ALLOWED_PREVIEW.includes(fileFormat(typeof file === "string" ? file : file.name));
 
	const removeSinglePreview = hasFile && isFileAllowedPreview && !!onDelete && (
		<IconButton size="xs" onClick={onDelete} className="top-4 right-4 z-10 absolute text-white bg-gray-500/70 hover:bg-gray-500/50">
			<X width={18} />
		</IconButton>
	);
 
	const renderMultiPreview = hasFiles && multiple && (
		<>
			<div className="my-6 space-y-3">
				<MultiFilePreview files={files} thumbnail={thumbnail} onRemove={onRemove} />
			</div>
			<div className="flex justify-end gap-2.5">
				{!!onRemoveAll && (
					<Button variant="outline" size="sm" onClick={onRemoveAll}>
						Remove All
					</Button>
				)}
				{!!onUpload && (
					<Button size="sm" variant="filled" onClick={onUpload}>
						<CloudUpload className="mr-1" />
						Upload
					</Button>
				)}
			</div>
		</>
	);
 
	return (
		<div className={cn("w-full relative", className)}>
			<div
				{...getRootProps()}
				className={cn(
					"min-h-[200px] outline-none rounded-lg cursor-pointer overflow-hidden relative bg-gray-400/12 outline-2 outline-dashed outline-input/65 transition-all hover:opacity-80",
					{
						"opacity-80": isDragActive,
						"pointer-events-none opacity-50": disabled,
						"text-error bg-error/10 dark:bg-error/5 outline-error/35": hasError || (!!fileRejections.length && files?.length === 0),
						"h-[400px] bg-transparent": hasFile && isFileAllowedPreview,
					},
				)}>
				<input {...getInputProps()} />
				<SinglePreview file={file} multiple={multiple} />
			</div>
 
			{removeSinglePreview}
 
			{!!fileRejections.length && <RejectionFiles fileRejections={fileRejections} />}
 
			{hasFile && !isFileAllowedPreview && (
				<div className="my-4 space-y-3">
					<MultiFilePreview files={[file] as FileWithPathAndPreview[] | string[]} onRemove={onDelete} thumbnail={thumbnail} />
				</div>
			)}
 
			{renderMultiPreview}
 
			{!!helperText && helperText}
		</div>
	);
};
 
Upload.displayName = "Upload";
 
export { Upload, type UploadProps };

2. Create these 4 components preview-multi-file.tsx, preview-single-file.tsx, rejection-files.tsx, upload-placeholder.tsx inside the @/components/ui/upload directory.


preview-multi-file.tsx

import type { FileWithPathAndPreview } from "./utils";
import { m, AnimatePresence } from "framer-motion";
import { fData } from "@/lib/format-number";
import { cn } from "@/lib/utils";
import { X } from "lucide-react";
 
import { IconButton } from "@/components/ui/buttons/icon-button";
import { varFade } from "@/components/ui/animate/variants/fade";
import FileThumbnail from "@/components/ui/file-thumbnail/file-thumbnail";
import { fileData } from "@/components/ui/file-thumbnail/utils";
 
// ----------------------------------------------------------------------
 
interface MultiFilePreviewProps {
	thumbnail?: boolean;
	files: (FileWithPathAndPreview | null | string)[];
	onRemove?: (file: FileWithPathAndPreview | string, index: number) => any;
	className?: string;
}
 
export default function MultiFilePreview({ thumbnail, files, onRemove, className }: MultiFilePreviewProps) {
	return (
		<AnimatePresence initial={false}>
			{files.map((file, idx) => {
				if (!file) return null;
				const { name = "", size = 0 } = fileData(file);
 
				const isNotFormatFile = typeof file === "string";
 
				if (thumbnail) {
					return (
						<m.div
							key={idx}
							{...varFade().inUp}
							className="inline-flex items-center justify-center m-1 w-20 h-20 rounded-sm overflow-hidden relative border border-gray-800/12 dark:border-gray-800/80">
							<FileThumbnail tooltip imageView file={file} className="absolute" />
 
							{!!onRemove && (
								<IconButton
									size="xs"
									onClick={() => onRemove(file, idx)}
									className="absolute p-1 top-1 right-1 text-white bg-gray-900/60 hover:bg-gray-600/55">
									<X width={14} />
								</IconButton>
							)}
						</m.div>
					);
				}
 
				return (
					<m.div
						key={idx}
						{...varFade().inUp}
						className={cn(
							"space-x-3 flex flex-row items-center py-3.5 px-3 rounded-lg border border-gray-900/15 dark:border-gray-600/55",
							className,
						)}>
						<FileThumbnail file={file} />
 
						<div className="flex justify-start items-center relative w-full">
							<div className="flex-1 min-w-0">
								<p className="text-sm block">{isNotFormatFile ? file : name}</p>
								<p className="text-xs block text-muted-foreground">{isNotFormatFile ? "" : fData(size)}</p>
							</div>
						</div>
 
						{!!onRemove && (
							<IconButton size="xs" onClick={() => onRemove(file, idx)}>
								<X width={16} />
							</IconButton>
						)}
					</m.div>
				);
			})}
		</AnimatePresence>
	);
}

You can create your own transition on preview-multi-file or copy this transition.

const varTranEnter = (props: { durationIn?: number; easeIn?: number }) => {
	const duration = props?.durationIn || 0.64;
	const ease = props?.easeIn || [0.43, 0.13, 0.23, 0.96];
 
	return { duration, ease };
};
 
const varTranExit = (props: { durationOut?: number; easeOut?: number }) => {
	const duration = props?.durationOut || 0.48;
	const ease = props?.easeOut || [0.43, 0.13, 0.23, 0.96];
 
	return { duration, ease };
};
 
const inUp = {
    initial: { y: distance, opacity: 0 },
    animate: {
        y: 0,
        opacity: 1,
        transition: varTranEnter({ durationIn, easeIn }),
    },
    exit: {
        y: distance,
        opacity: 0,
        transition: varTranExit({ durationOut, easeOut }),
    },
}

The fData can be placed in @/lib/format-number.ts.

export function fData(number: number) {
	const format = number ? numeral(number).format("0.0 b") : "";
 
	return result(format, ".0");
}
 
function result(format: string, key = ".00") {
	const isInteger = format.includes(key);
 
	return isInteger ? format.replace(key, "") : format;
}

preview-single-file.tsx

"use client";
import Image from "next/image";
import { ALLOWED_PREVIEW, type FileWithPathAndPreview } from "./utils";
import { fileFormat as checkFileFormat } from "@/components/ui/file-thumbnail/utils";
import UploadPlaceholder from "./upload-placeholder";
 
interface SinglePreviewProps {
	file?: FileWithPathAndPreview | string | null;
	multiple: boolean;
}
 
export default function SinglePreview({ file, multiple }: SinglePreviewProps) {
	if (!file) {
		return <UploadPlaceholder multiple={multiple} />;
	}
 
	const fileFormat = checkFileFormat(typeof file === "string" ? file : file.name);
 
	if (!ALLOWED_PREVIEW.includes(fileFormat)) return <UploadPlaceholder multiple={multiple} />;
 
	if (fileFormat === "image") {
		return (
			<div className="inset-0 w-full h-full absolute">
				<Image
					src={typeof file === "string" ? file : file.preview}
					alt={`${typeof file === "string" ? file : file.name}-file preview`}
					height={400}
					width={400}
					className="h-[400px] w-full object-cover"
				/>
			</div>
		);
	}
 
	const src = typeof file === "string" ? file : file.preview;
	if (fileFormat === "pdf") {
		return (
			<div className="h-[460px]">
				<embed type="application/pdf" src={src} className="-mt-8 w-full h-[460px]" />
			</div>
		);
	}
 
	return <UploadPlaceholder multiple={multiple} />;
}

rejection-files.tsx

import type { FileRejection } from "react-dropzone";
import type { FileWithPathAndPreview } from "./utils";
 
import { fData } from "@/lib/format-number";
import { fileData } from "@/components/ui/file-thumbnail/utils";
 
// ----------------------------------------------------------------------
 
export default function RejectionFiles({ fileRejections }: { fileRejections: readonly FileRejection[] }) {
	return (
		<div className="py-2 px-4 mt-6 rounded-lg border border-dashed border-error bg-error/12 text-left">
			{fileRejections.map(({ file, errors }) => {
				const { path, size } = fileData(file as FileWithPathAndPreview);
				return (
					<div key={path} className="my-1">
						<p className="text-common text-sm font-medium text-nowrap text-ellipsis overflow-hidden">
							{path}
							{size ? ` - ${fData(size)}` : ""}
						</p>
						{errors.map((err) => (
							<span key={err.code} className="text-xs">
								- {err.message}
							</span>
						))}
					</div>
				);
			})}
		</div>
	);
}

upload-placeholder.tsx

import type { FileRejection } from "react-dropzone";
import type { FileWithPathAndPreview } from "./utils";
 
import { fData } from "@/lib/format-number";
import { fileData } from "@/components/ui/file-thumbnail/utils";
 
// ----------------------------------------------------------------------
 
export default function RejectionFiles({ fileRejections }: { fileRejections: readonly FileRejection[] }) {
	return (
		<div className="py-2 px-4 mt-6 rounded-lg border border-dashed border-error bg-error/12 text-left">
			{fileRejections.map(({ file, errors }) => {
				const { path, size } = fileData(file as FileWithPathAndPreview);
				return (
					<div key={path} className="my-1">
						<p className="text-common text-sm font-medium text-nowrap text-ellipsis overflow-hidden">
							{path}
							{size ? ` - ${fData(size)}` : ""}
						</p>
						{errors.map((err) => (
							<span key={err.code} className="text-xs">
								- {err.message}
							</span>
						))}
					</div>
				);
			})}
		</div>
	);
}

3. Now, create a directory inside components/ui named file-thumbnail, create and copy & paste these files file-thumbnail.tsx, download-button.tsx, utils.ts.


file-thumbnail.tsx

import type { MouseEventHandler } from "react";
import type { FileWithPathAndPreview } from "@/components/ui/upload/utils";
import { cn } from "@/lib/utils";
import { fileData, fileThumb, fileFormat } from "./utils";
import DownloadButton from "./download-button";
import Image from "next/image";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
 
// ----------------------------------------------------------------------
 
interface FileThumbnailProps {
	file: string | FileWithPathAndPreview;
	tooltip?: boolean;
	imageView?: boolean;
	onDownload?: MouseEventHandler<HTMLButtonElement>;
	className?: string;
}
 
export default function FileThumbnail({ file, tooltip, imageView, onDownload, className }: FileThumbnailProps) {
	const { name = "", path = "", preview = "" } = fileData(file);
 
	const format = fileFormat(path || preview);
 
	const renderContent =
		format === "image" && imageView ? (
			<Image alt="image preview" src={preview} fill className={cn("w-full h-full flex-shrink-0 object-cover", className)} />
		) : (
			<Image alt="image preview" src={fileThumb(format)} width={32} height={32} className={cn("flex-shrink-0", className)} />
		);
 
	if (tooltip) {
		return (
			<TooltipProvider>
				<Tooltip>
					<TooltipTrigger>
						<span className="flex items-center justify-center w-fit h-[inherit]">
							{renderContent}
							{onDownload && <DownloadButton onDownload={onDownload} />}
						</span>
					</TooltipTrigger>
					<TooltipContent>
						<span>{name}</span>
					</TooltipContent>
				</Tooltip>
			</TooltipProvider>
		);
	}
 
	return (
		<>
			{renderContent}
			{onDownload && <DownloadButton onDownload={onDownload} />}
		</>
	);
}

download-button.tsx

import type { MouseEventHandler } from "react";
import { IconButton } from "@/components/ui/buttons/icon-button";
import { CircleArrowDown } from "lucide-react";
 
// ----------------------------------------------------------------------
 
export default function DownloadButton({ onDownload }: { onDownload?: MouseEventHandler<HTMLButtonElement> }) {
	return (
		<IconButton
			onClick={onDownload}
			className="inline-flex p-0 inset-0 w-full h-full absolute rounded-[unset] justify-center bg-gray-600 text-white transition-opacity hover:opacity-100 hover:blur-sm">
			<CircleArrowDown width={24} />
		</IconButton>
	);
}

utils.ts

import { type FileWithPath } from "react-dropzone";
 
// ----------------------------------------------------------------------
 
// Define more types here
const FORMAT_PDF = ["pdf"];
const FORMAT_TEXT = ["txt"];
const FORMAT_PHOTOSHOP = ["psd"];
const FORMAT_WORD = ["doc", "docx"];
const FORMAT_EXCEL = ["xls", "xlsx"];
const FORMAT_ZIP = ["zip", "rar", "iso"];
const FORMAT_ILLUSTRATOR = ["ai", "esp"];
const FORMAT_POWERPOINT = ["ppt", "pptx"];
const FORMAT_AUDIO = ["wav", "aif", "mp3", "aac"];
const FORMAT_IMG = ["jpg", "jpeg", "gif", "bmp", "png", "svg", "webp"];
const FORMAT_VIDEO = ["m4v", "avi", "mpg", "mp4", "webm"];
 
const iconUrl = (icon: string) => `/assets/icons/files/${icon}.svg`;
 
// ----------------------------------------------------------------------
export type FileTypes =
	| "txt"
	| "zip"
	| "audio"
	| "image"
	| "video"
	| "word"
	| "excel"
	| "powerpoint"
	| "pdf"
	| "photoshop"
	| "illustrator"
	| "folder";
 
export function fileFormat(fileUrl: string): FileTypes {
	let format: FileTypes;
 
	switch (fileUrl?.includes(fileTypeByUrl(fileUrl))) {
		case FORMAT_TEXT.includes(fileTypeByUrl(fileUrl)):
			format = "txt";
			break;
		case FORMAT_ZIP.includes(fileTypeByUrl(fileUrl)):
			format = "zip";
			break;
		case FORMAT_AUDIO.includes(fileTypeByUrl(fileUrl)):
			format = "audio";
			break;
		case FORMAT_IMG.includes(fileTypeByUrl(fileUrl)):
			format = "image";
			break;
		case FORMAT_VIDEO.includes(fileTypeByUrl(fileUrl)):
			format = "video";
			break;
		case FORMAT_WORD.includes(fileTypeByUrl(fileUrl)):
			format = "word";
			break;
		case FORMAT_EXCEL.includes(fileTypeByUrl(fileUrl)):
			format = "excel";
			break;
		case FORMAT_POWERPOINT.includes(fileTypeByUrl(fileUrl)):
			format = "powerpoint";
			break;
		case FORMAT_PDF.includes(fileTypeByUrl(fileUrl)):
			format = "pdf";
			break;
		case FORMAT_PHOTOSHOP.includes(fileTypeByUrl(fileUrl)):
			format = "photoshop";
			break;
		case FORMAT_ILLUSTRATOR.includes(fileTypeByUrl(fileUrl)):
			format = "illustrator";
			break;
		default:
			format = fileTypeByUrl(fileUrl) as FileTypes;
	}
 
	return format;
}
 
// ----------------------------------------------------------------------
 
export function fileThumb(fileUrl: string) {
	let thumb;
 
	switch (fileFormat(fileUrl)) {
		case "folder":
			thumb = iconUrl("ic_folder");
			break;
		case "txt":
			thumb = iconUrl("ic_txt");
			break;
		case "zip":
			thumb = iconUrl("ic_zip");
			break;
		case "audio":
			thumb = iconUrl("ic_audio");
			break;
		case "video":
			thumb = iconUrl("ic_video");
			break;
		case "word":
			thumb = iconUrl("ic_word");
			break;
		case "excel":
			thumb = iconUrl("ic_excel");
			break;
		case "powerpoint":
			thumb = iconUrl("ic_power_point");
			break;
		case "pdf":
			thumb = iconUrl("ic_pdf");
			break;
		case "photoshop":
			thumb = iconUrl("ic_pts");
			break;
		case "illustrator":
			thumb = iconUrl("ic_ai");
			break;
		case "image":
			thumb = iconUrl("ic_img");
			break;
		default:
			thumb = iconUrl("ic_file");
	}
	return thumb;
}
 
// ----------------------------------------------------------------------
 
export function fileTypeByUrl(fileUrl = "") {
	return (fileUrl && fileUrl.split(".").pop()) || "";
}
 
// ----------------------------------------------------------------------
 
export function fileNameByUrl(fileUrl: string) {
	return fileUrl.split("/").pop();
}
 
// ----------------------------------------------------------------------
 
export function fileData(file: string | (FileWithPath & { preview?: string })) {
	// Url
	if (typeof file === "string") {
		return {
			key: file,
			preview: file,
			name: fileNameByUrl(file),
			type: fileTypeByUrl(file),
		};
	}
 
	// File
	return {
		key: file?.preview,
		name: file.name,
		size: file.size,
		path: file.path,
		type: file.type,
		preview: file?.preview,
		lastModified: file.lastModified,
		// lastModifiedDate: file.lastModifiedDate,
	};
}

4. For the file thumbnail, you can download some svg icon and add to your public/assets/icons/files directory.


// Update the following...
// base on your svg icon file names.
const iconUrl = (icon: string) => `/assets/icons/files/${icon}.svg`;
function fileThumb(fileUrl: string)
type FileTypes