Upload
Upload component using react-dropzone.
Single Upload:
Drop or select file
Drop files here or clickbrowsethorough your machine
Multiple Upload:
Drop or select multiple file
Drop files here or clickbrowsethorough your machine
With error:
Drop or select file
Drop files here or clickbrowsethorough your machine
Invalid file format
Installation
npx shadcn@latest add /registry/upload.json
Manual 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?: () => void;
files?: (FileWithPathAndPreview | null | string)[];
onRemove?: (file: FileWithPathAndPreview | string, index: number) => any;
onRemoveAll?: () => void;
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 dark:outline-input/35 outline-input 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