File Input

A sophisticated file upload component with drag-and-drop functionality, animated loading states, and file previews. Features file validation, size limits, and smooth transitions between upload states with beautiful visual feedback.

Install dependencies

npm i framer-motion lucide-react tailwindcss

Utility function

Create a file lib/utils.ts and paste this code

import { ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }

Component

Create a file file-input.tsx in your components folder and paste this code

'use client'; import { cn } from '@/lib/utils'; import { AnimatePresence, motion } from 'framer-motion'; import { CloudUpload, File, Globe, MapPin, FileText, Image as ImageIcon, Trash } from 'lucide-react'; import Image from 'next/image'; import React, { useEffect, useRef, useState } from 'react'; const validateFile = ( file: File, accept?: string, maxSizeInMB?: number ): { success: boolean; message: string | null } => { // Validate file type if accept is specified if (accept) { const acceptedTypes = accept.split(',').map((type) => type.trim()); const fileType = file.type; const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase(); const isValidType = acceptedTypes.some((acceptedType) => { if (acceptedType.startsWith('.')) { return acceptedType === fileExtension; } return acceptedType === fileType; }); if (!isValidType) { return { success: false, message: 'File type not accepted.' }; } } // Validate file size if (maxSizeInMB) { const fileSizeInMB = file.size / (1024 * 1024); if (fileSizeInMB > maxSizeInMB) { return { success: false, message: `File size exceeds ${maxSizeInMB}MB limit.` }; } } return { success: true, message: null }; }; const formatFileSize = (bytes: number): string => { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; const getFileType = (file: File): 'image' | 'pdf' | 'text' | 'other' => { if (file.type.startsWith('image/')) return 'image'; if (file.type === 'application/pdf') return 'pdf'; if (file.type.startsWith('text/')) return 'text'; return 'other'; }; const getFileIcon = (fileType: 'image' | 'pdf' | 'text' | 'other') => { switch (fileType) { case 'image': return <ImageIcon size={24} className="text-blue-400" />; case 'pdf': return <FileText size={24} className="text-red-400" />; case 'text': return <FileText size={24} className="text-green-400" />; default: return <File size={24} className="text-zinc-400" />; } }; interface IdleProps { accept?: string; maxSizeInMB?: number; onClick: () => void; onDrop: (e: React.DragEvent) => void; error: string | null; } const Idle = ({ accept, maxSizeInMB, onClick, onDrop, error }: IdleProps) => { const [isDragOver, setIsDragOver] = useState(false); const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(true); }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(false); }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(false); onDrop(e); }; return ( <div className={cn( `h-full w-full bg-black overflow-hidden relative border-[1.5px] border-dashed rounded-lg flex flex-col items-center justify-center select-none cursor-pointer transition-colors`, isDragOver ? 'border-blue-500 bg-blue-950/20' : 'border-zinc-800 hover:border-zinc-700' )} onClick={onClick} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} > <CloudUpload size={40} className="text-blue-500" /> <p className="text-sm text-zinc-200 font-medium">Upload a file</p> <p className="text-xs mt-2 text-zinc-400">Click to upload or drag and drop</p> {accept && ( <p className="text-xs text-zinc-400"> Accepts:{' '} {accept ?.split(',') .map((item) => `.${item.split('/').pop()?.trim()}`) .join(', ')} </p> )} {maxSizeInMB && <p className="text-xs text-zinc-400">Max size: {maxSizeInMB} MB</p>} {error && ( <motion.p animate={{ x: [0, 2, -2, 2, -2, 0, 2, -2, 2, -2, 0] }} transition={{ duration: 0.3, delay: 0.15 }} className="text-xs text-red-500 absolute bottom-2" > {error} </motion.p> )} <AnimatePresence> {isDragOver && ( <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2, ease: 'easeOut' }} className="absolute inset-0 backdrop-blur-sm bg-black/50 z-10 flex items-center justify-center flex-col rounded-xl" > <MapPin size={32} className="text-blue-100" /> <p className="text-sm text-blue-100 font-medium">Drop your file here</p> </motion.div> )} </AnimatePresence> </div> ); }; const Loading = () => { const fromRef = useRef<HTMLDivElement>(null); const toRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null); const [path, setPath] = useState<string>(''); const [containerSize, setContainerSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 }); const gradientCoordinates = { x1: ['10%', '110%'], x2: ['0%', '100%'], y1: ['0%', '0%'], y2: ['0%', '0%'] }; useEffect(() => { const updatePath = () => { if (!fromRef.current || !toRef.current || !containerRef.current) return; const containerRect = containerRef.current.getBoundingClientRect(); const fromRect = fromRef.current.getBoundingClientRect(); const toRect = toRef.current.getBoundingClientRect(); setContainerSize({ width: containerRect.width, height: containerRect.height }); const x1 = fromRect.left - containerRect.left + fromRect.width / 2; const y1 = fromRect.top - containerRect.top + fromRect.height / 2; const x2 = toRect.left - containerRect.left + toRect.width / 2; const y2 = toRect.top - containerRect.top + toRect.height / 2; const controlY = x1 - 50; const d = `M ${x1},${y1} Q ${(x1 + x2) / 2},${controlY} ${x2},${y2}`; setPath(d); }; const resizeObserver = new ResizeObserver((entries) => { for (let entry of entries) { updatePath(); } }); if (containerRef.current) { resizeObserver.observe(containerRef.current); } updatePath(); return () => { resizeObserver.disconnect(); }; }, []); return ( <div className="h-full w-full bg-black relative border-[1.5px] border-zinc-900 rounded-xl flex flex-col items-center justify-center gap-4"> <div ref={containerRef} className="w-full px-10 flex items-center justify-between relative"> <motion.span animate={{ scale: [1, 1.05, 1] }} transition={{ duration: 1.5, repeat: Infinity, ease: 'easeInOut' }} ref={fromRef} className="bg-black rounded-full border border-blue-400 p-2 z-10 h-10 w-10 flex items-center justify-center shadow-sm shadow-blue-500" > <File size={20} strokeWidth={1.5} className="text-zinc-300" /> </motion.span> <motion.span animate={{ scale: [1, 1.05, 1] }} transition={{ delay: 0.5, duration: 1.5, repeat: Infinity, ease: 'easeInOut' }} ref={toRef} className="bg-black rounded-full border border-blue-400 p-2 z-10 h-10 w-10 flex items-center justify-center shadow-sm shadow-blue-500" > <Globe size={32} strokeWidth={1.5} className="text-zinc-300" /> </motion.span> <svg fill="none" width={containerSize.width} height={containerSize.height} xmlns="http://www.w3.org/2000/svg" className={cn('pointer-events-none absolute left-0 top-0 transform-gpu stroke-2')} viewBox={`0 0 ${containerSize.width} ${containerSize.height}`} > <path d={path} stroke="#3F3F47" strokeWidth={1} strokeOpacity={0.8} strokeLinecap="round" /> <path d={path} strokeWidth={3} stroke={`url(#${'path'})`} strokeOpacity="1" strokeLinecap="round" /> <defs> <motion.linearGradient className="transform-gpu" id={'path'} gradientUnits={'userSpaceOnUse'} initial={{ x1: '0%', x2: '0%', y1: '0%', y2: '0%' }} animate={{ x1: gradientCoordinates.x1, x2: gradientCoordinates.x2, y1: gradientCoordinates.y1, y2: gradientCoordinates.y2 }} transition={{ duration: 1.5, ease: 'easeInOut', repeat: Infinity, repeatType: 'loop' }} > <stop stopColor={'#ffffff'} stopOpacity="0"></stop> <stop stopColor={'#ffffff'} stopOpacity="0.3"></stop> <stop offset="15%" stopColor={'#00bfff'} stopOpacity="1"></stop> <stop offset="30%" stopColor={'#0080ff'} stopOpacity="0.8"></stop> <stop offset="50%" stopColor={'#0066cc'} stopOpacity="0.6"></stop> <stop offset="70%" stopColor={'#004499'} stopOpacity="0.4"></stop> <stop offset="85%" stopColor={'#002266'} stopOpacity="0.2"></stop> <stop offset="100%" stopColor={'#001133'} stopOpacity="0"></stop> </motion.linearGradient> </defs> </svg> </div> <p className="text-xs text-zinc-300 font-light">Uploading file...</p> </div> ); }; interface SuccessProps { file: File; onRemove: () => void; } const Success = ({ file, onRemove }: SuccessProps) => { const [imagePreview, setImagePreview] = useState<string | null>(null); const [previewLoaded, setPreviewLoaded] = useState(false); const fileType = getFileType(file); const fileIcon = getFileIcon(fileType); useEffect(() => { if (fileType === 'image') { const reader = new FileReader(); reader.onload = (e) => { setImagePreview(e.target?.result as string); }; reader.readAsDataURL(file); } }, [file, fileType]); return ( <div className="h-full w-full bg-black relative border-[1.5px] border-zinc-900 rounded-xl flex flex-col items-center justify-center gap-4 overflow-hidden"> <div className="w-full flex-1 flex flex-col items-center justify-center gap-3 relative h-full overflow-hidden"> <div className="absolute top-0 left-0 w-full h-full"> {fileType === 'image' && imagePreview ? ( <motion.div initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: previewLoaded ? 1 : 0, scale: previewLoaded ? 1 : 0.8 }} transition={{ duration: 0.3, ease: 'easeInOut', delay: 0.2 }} className="relative w-full h-full rounded-lg overflow-hidden border border-zinc-700" > <Image onLoad={() => setPreviewLoaded(true)} src={imagePreview} alt="File preview" fill className="w-full h-full object-cover" /> </motion.div> ) : ( <motion.div initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.3, delay: 0.3 }} className="w-full h-full bg-zinc-800 rounded-lg flex items-center justify-center border border-zinc-700" > {fileIcon} </motion.div> )} </div> <div style={{ // background: 'black', backdropFilter: 'blur(10px)', WebkitBackdropFilter: 'blur(10px)', maskImage: 'linear-gradient(to bottom, transparent, black 80%)', WebkitMaskImage: 'linear-gradient(to bottom, transparent, black 80%)' }} className="absolute inset-0" /> <div className="absolute inset-0 flex flex-col items-start justify-end p-3 bg-black/50"> <div className="flex items-end justify-between w-full"> <div className="flex flex-col items-start justify-end"> <p className="text-sm text-white font-medium truncate max-w-[200px]" title={file.name} > {file.name} </p> <p className="text-xs text-white">{formatFileSize(file.size)}</p> </div> <button type="button" onClick={onRemove} className="p-2 rounded-full hover:bg-white/5 group" > <Trash size={16} className="group-hover:text-red-600/90" /> </button> </div> </div> </div> </div> ); }; interface FileInputProps { accept?: string; // e.g. 'image/png, image/jpeg, application/pdf maxSizeInMB?: number; onFileChange?: (file: File) => void | Promise<void>; } const FileInput = ({ accept = 'image/png, image/jpeg, application/pdf', maxSizeInMB = 10, onFileChange }: FileInputProps) => { const [state, setState] = useState<'idle' | 'loading' | 'success'>('idle'); const [error, setError] = useState<string | null>(null); const [file, setFile] = useState<File | null>(null); const fileInputRef = useRef<HTMLInputElement>(null); const handleFileInputClick = () => { fileInputRef.current?.click(); }; const onChange = async (e: React.ChangeEvent<HTMLInputElement>) => { setError(null); const file = e.target.files?.[0]; if (!file) return; const { success, message } = validateFile(file, accept, maxSizeInMB); if (!success) { setError(message || 'Something went wrong.'); return; } console.log('file', file); setState('loading'); await onFileChange?.(file); await new Promise((resolve) => setTimeout(resolve, 5000)); setFile(file); setState('success'); }; const handleDrop = async (e: React.DragEvent) => { setError(null); const files = e.dataTransfer.files; if (files.length === 0) return; const file = files[0]; const { success, message } = validateFile(file, accept, maxSizeInMB); if (!success) { setError(message || 'Something went wrong.'); return; } console.log('file', file); setState('loading'); await onFileChange?.(file); await new Promise((resolve) => setTimeout(resolve, 5000)); setFile(file); setState('success'); }; const handleRemove = () => { setFile(null); if (fileInputRef.current) { fileInputRef.current.value = ''; } setState('idle'); }; return ( <div className="w-[300px] h-[200px] relative overflow-hidden"> <input type="file" ref={fileInputRef} className="hidden" accept={accept} onChange={onChange} /> <AnimatePresence mode="popLayout" initial={false}> <motion.div key={state} className="h-full w-full overflow-hidden rounded-xl" initial={{ y: state === 'loading' ? '-100%' : '100%', filter: 'blur(10px)', scale: 1, borderRadius: '100%' }} animate={{ y: 0, filter: 'blur(0px)', scale: 1, borderRadius: '0%' }} exit={{ y: ['loading', 'success'].includes(state) ? '-100%' : '100%', filter: 'blur(10px)', scale: 0.7, borderRadius: '100%' }} transition={{ type: 'spring', duration: 0.4, bounce: 0 }} > {state === 'idle' && ( <Idle accept={accept} maxSizeInMB={maxSizeInMB} onClick={handleFileInputClick} onDrop={handleDrop} error={error} /> )} {state === 'loading' && <Loading />} {state === 'success' && file && <Success file={file} onRemove={handleRemove} />} </motion.div> </AnimatePresence> </div> ); }; export default FileInput;

Usage

<FileInput />
Loading...