Smart File Drop
An intelligent file upload zone with drag-and-drop physics, file type detection, and simulated progress states. Provides rich visual feedback for every interaction.
Preview
Live interactive preview.
Installation
Add this component to your project using the CLI:
terminal
npx -y vui-registry-cli-v1@latest add smart-file-dropSource Code
smart-file-drop.tsx
'use client'
import React, { useState, useRef, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
UploadCloud,
File,
FileImage,
FileText,
Check,
AlertCircle,
Copy,
Trash2,
ShieldCheck,
} from 'lucide-react'
import { cn } from '@/lib/utils'
// @ts-ignore
import confetti from 'canvas-confetti'
type FileStatus = 'idle' | 'dragging' | 'uploading' | 'success' | 'error'
type FileFilter = 'all' | 'image' | 'pdf' | 'doc'
const FILTERS: { id: FileFilter; label: string; accept: string }[] = [
{ id: 'all', label: 'All', accept: 'image/*,.pdf,.doc,.docx' },
{ id: 'image', label: 'Images', accept: 'image/*' },
{ id: 'pdf', label: 'PDF', accept: '.pdf,application/pdf' },
{ id: 'doc', label: 'Docs', accept: '.doc,.docx,application/msword' },
]
interface SmartFileDropProps {
onUpload?: (file: File) => Promise<void> | void
acceptedFileTypes?: string[]
maxSize?: number
}
export default function SmartFileDrop({
onUpload,
acceptedFileTypes,
maxSize = 5,
}: SmartFileDropProps) {
return (
<div className="flex items-center justify-center w-full p-6">
<FileDropZone onUpload={onUpload} acceptedFileTypes={acceptedFileTypes} maxSize={maxSize} />
</div>
)
}
function FileDropZone({ onUpload, acceptedFileTypes, maxSize = 5 }: SmartFileDropProps) {
const [status, setStatus] = useState<FileStatus>('idle')
const [file, setFile] = useState<File | null>(null)
const [progress, setProgress] = useState(0)
const [filter, setFilter] = useState<FileFilter>('all')
const [copied, setCopied] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const activeFilter = FILTERS.find((f) => f.id === filter) ?? FILTERS[0]
const accept = acceptedFileTypes?.join(',') ?? activeFilter.accept
const filterHint = useMemo(() => {
if (filter === 'image') return 'PNG, JPG, WEBP, SVG'
if (filter === 'pdf') return 'PDF only'
if (filter === 'doc') return 'DOC, DOCX'
return 'Images, PDF, or documents'
}, [filter])
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
if (status === 'idle' || status === 'error') setStatus('dragging')
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
if (status === 'dragging') setStatus('idle')
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
if (status === 'uploading' || status === 'success') return
const droppedFile = e.dataTransfer.files[0]
if (droppedFile) processFile(droppedFile)
}
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.[0]) processFile(e.target.files[0])
}
const validateFile = (f: File): string | null => {
const sizeMb = f.size / (1024 * 1024)
if (sizeMb > maxSize) return `File exceeds ${maxSize}MB limit`
if (filter === 'image' && !f.type.startsWith('image/')) return 'Select an image file'
if (filter === 'pdf' && !f.type.includes('pdf')) return 'Select a PDF file'
if (filter === 'doc' && !f.name.match(/\.docx?$/i)) return 'Select a document file'
return null
}
const processFile = async (f: File) => {
const error = validateFile(f)
if (error) {
setFile(f)
setStatus('error')
return
}
setFile(f)
setStatus('uploading')
setProgress(0)
if (!onUpload) {
let p = 0
const interval = setInterval(() => {
p += Math.random() * 12
if (p >= 100) {
p = 100
clearInterval(interval)
setStatus('success')
confetti({ particleCount: 60, spread: 55, origin: { y: 0.65 }, colors: ['#34d399', '#059669', '#10b981'] })
}
setProgress(p)
}, 180)
} else {
try {
await onUpload(f)
setStatus('success')
confetti({ particleCount: 60, spread: 55, origin: { y: 0.65 }, colors: ['#34d399', '#059669', '#10b981'] })
} catch {
setStatus('error')
}
}
}
const reset = (e?: React.MouseEvent) => {
e?.stopPropagation()
setStatus('idle')
setFile(null)
setProgress(0)
if (inputRef.current) inputRef.current.value = ''
}
const copyName = (e: React.MouseEvent) => {
e.stopPropagation()
if (!file) return
navigator.clipboard.writeText(file.name)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
const getIcon = () => {
if (!file) return <UploadCloud size={40} className="text-emerald-500/70" />
if (file.type.startsWith('image/')) return <FileImage size={40} className="text-emerald-600" />
if (file.type.includes('pdf')) return <FileText size={40} className="text-rose-500" />
return <File size={40} className="text-neutral-500" />
}
return (
<div className="w-full max-w-lg">
<div className="mb-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<div>
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-neutral-500">Asset Upload</p>
<h3 className="text-sm font-semibold text-neutral-900 dark:text-white">Smart File Drop</h3>
</div>
<div className="flex items-center gap-1.5 text-[10px] text-neutral-400">
<ShieldCheck className="w-3.5 h-3.5 text-emerald-500" />
Encrypted
</div>
</div>
<motion.div
layout
className={cn(
'relative w-full rounded-xl border transition-all duration-300 flex flex-col overflow-hidden cursor-pointer',
'bg-white dark:bg-neutral-900',
status === 'dragging'
? 'border-emerald-500/60 ring-2 ring-emerald-500/15 scale-[1.01]'
: 'border-neutral-200 dark:border-white/10 hover:border-emerald-500/30',
status === 'success' && 'border-emerald-500/50',
status === 'error' && 'border-red-400/60'
)}
onClick={() => status !== 'uploading' && inputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input ref={inputRef} type="file" className="hidden" onChange={handleFileSelect} accept={accept} />
<div className="p-6 min-h-[200px] flex flex-col items-center justify-center">
<AnimatePresence mode="wait">
{status === 'idle' || status === 'dragging' ? (
<motion.div
key="idle"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
className="flex flex-col items-center text-center gap-3 w-full"
>
<div
className={cn(
'w-16 h-16 rounded-2xl border border-neutral-100 dark:border-white/5 bg-neutral-50 dark:bg-white/5 flex items-center justify-center transition-all',
status === 'dragging' && 'scale-105 border-emerald-500/30 bg-emerald-500/5'
)}
>
<UploadCloud
size={28}
className={cn('text-neutral-400', status === 'dragging' && 'text-emerald-500')}
/>
</div>
<div>
<p className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">
{status === 'dragging' ? 'Release to upload' : 'Drop file here or browse'}
</p>
<p className="text-xs text-neutral-400 mt-1">
{filterHint} · max {maxSize}MB
</p>
</div>
</motion.div>
) : (
<motion.div
key="active"
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-4 w-full max-w-xs"
onClick={(e) => e.stopPropagation()}
>
<div className="relative">
<div className="w-20 h-20 rounded-xl border border-neutral-100 dark:border-white/10 bg-neutral-50 dark:bg-neutral-800 flex items-center justify-center">
{getIcon()}
</div>
{status === 'success' && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute -right-1.5 -bottom-1.5 w-7 h-7 bg-emerald-500 rounded-full flex items-center justify-center border-2 border-white dark:border-neutral-900"
>
<Check size={14} className="text-white" strokeWidth={3} />
</motion.div>
)}
</div>
<div className="w-full text-center">
<p className="text-sm font-medium text-neutral-900 dark:text-white truncate px-2">{file?.name}</p>
<p className="text-xs text-neutral-500 mt-0.5">
{(file?.size || 0) > 1024 * 1024
? `${((file?.size || 0) / (1024 * 1024)).toFixed(1)} MB`
: `${((file?.size || 0) / 1024).toFixed(0)} KB`}
</p>
</div>
{status === 'uploading' && (
<div className="w-full space-y-1.5">
<div className="h-1.5 bg-neutral-100 dark:bg-neutral-800 rounded-full overflow-hidden">
<motion.div
className="h-full bg-gradient-to-r from-emerald-400 to-emerald-600"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
/>
</div>
<p className="text-[10px] text-neutral-400 text-center tabular-nums">{Math.round(progress)}%</p>
</div>
)}
{status === 'success' && (
<div className="flex flex-col sm:flex-row items-center gap-2">
<button
type="button"
onClick={copyName}
className="px-3 py-1.5 rounded-lg border border-neutral-200 dark:border-white/10 text-xs font-medium text-neutral-600 dark:text-neutral-300 hover:border-emerald-500/30 transition-colors flex items-center gap-1.5"
>
<Copy size={12} />
{copied ? 'Copied' : 'Copy name'}
</button>
<button
type="button"
onClick={reset}
className="px-3 py-1.5 rounded-lg bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 text-xs font-semibold hover:opacity-90 transition-opacity"
>
Upload another
</button>
</div>
)}
{status === 'error' && (
<div className="flex items-center gap-2 text-red-500">
<AlertCircle size={14} />
<span className="text-xs font-medium">Invalid file for selected type</span>
<button type="button" onClick={reset} className="text-xs underline ml-1">
Retry
</button>
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
{/* Filters inside drop zone footer */}
<div
className="border-t border-neutral-100 dark:border-white/5 px-4 py-3 flex flex-col sm:flex-row items-center justify-between gap-3 bg-neutral-50/80 dark:bg-white/[0.02]"
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-wrap gap-1.5">
{FILTERS.map((f) => (
<button
key={f.id}
type="button"
onClick={() => {
setFilter(f.id)
reset()
}}
className={cn(
'px-2.5 py-1 rounded-md text-[10px] font-semibold uppercase tracking-wide transition-all',
filter === f.id
? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border border-emerald-500/25'
: 'text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200 border border-transparent'
)}
>
{f.label}
</button>
))}
</div>
{file && status !== 'idle' && status !== 'dragging' && (
<button
type="button"
onClick={reset}
className="p-1.5 rounded-md text-neutral-400 hover:text-red-500 hover:bg-red-500/5 transition-colors"
>
<Trash2 size={14} />
</button>
)}
</div>
</motion.div>
</div>
)
}
Dependencies
framer-motion: latestlucide-react: latestclsx: latesttailwind-merge: latestcanvas-confetti: latest@types/canvas-confetti: latest