File Upload Dropzone
A file upload component with drag-and-drop support, progress simulation, and file previews.
Preview
Live interactive preview.
Installation
Add this component to your project using the CLI:
npx -y vui-registry-cli-v1@latest add file-uploadSource Code
'use client'
import React, { useState, useRef, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Upload, X, File, FileImage, FileText, CheckCircle2 } from 'lucide-react'
import { cn } from '@/lib/utils'
type FileFilter = 'all' | 'image' | 'pdf'
const FILTERS: { id: FileFilter; label: string; accept: string }[] = [
{ id: 'all', label: 'All', accept: 'image/*,.pdf' },
{ id: 'image', label: 'Images', accept: 'image/*' },
{ id: 'pdf', label: 'PDF', accept: '.pdf,application/pdf' },
]
interface FileUploadProps {
onUpload?: (files: File[]) => void
maxFiles?: number
accept?: string[]
className?: string
}
interface FileStatus {
file: File
id: string
progress: number
status: 'uploading' | 'completed' | 'error'
preview?: string
}
export function FileUpload({ onUpload, maxFiles = 5, accept, className }: FileUploadProps) {
const [files, setFiles] = useState<FileStatus[]>([])
const [isDragging, setIsDragging] = useState(false)
const [filter, setFilter] = useState<FileFilter>('all')
const fileInputRef = useRef<HTMLInputElement>(null)
const activeFilter = FILTERS.find((f) => f.id === filter) ?? FILTERS[0]
const acceptValue = accept?.join(',') ?? activeFilter.accept
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
handleFiles(Array.from(e.dataTransfer.files))
},
[maxFiles, accept, files.length, filter]
)
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) handleFiles(Array.from(e.target.files))
}
const handleFiles = (newFiles: File[]) => {
const validFiles = newFiles.slice(0, maxFiles - files.length)
const newFileStatuses: FileStatus[] = validFiles.map((file) => ({
file,
id: Math.random().toString(36).substring(7),
progress: 0,
status: 'uploading',
preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined,
}))
setFiles((prev) => [...prev, ...newFileStatuses])
newFileStatuses.forEach((fileStatus) => simulateUpload(fileStatus.id))
onUpload?.(validFiles)
}
const simulateUpload = (id: string) => {
let progress = 0
const interval = setInterval(() => {
progress += Math.random() * 10 + 5
if (progress >= 100) {
progress = 100
clearInterval(interval)
setFiles((prev) => prev.map((f) => (f.id === id ? { ...f, progress: 100, status: 'completed' } : f)))
} else {
setFiles((prev) => prev.map((f) => (f.id === id ? { ...f, progress } : f)))
}
}, 200)
}
const removeFile = (id: string) => {
setFiles((prev) => prev.filter((f) => f.id !== id))
}
const getFileIcon = (file: File) => {
if (file.type.startsWith('image/')) return <FileImage className="w-5 h-5 text-emerald-600" />
if (file.type === 'application/pdf') return <FileText className="w-5 h-5 text-rose-500" />
return <File className="w-5 h-5 text-neutral-500" />
}
return (
<div className={cn('w-full max-w-xl mx-auto font-sans', className)}>
<motion.div
layout
className={cn(
'relative overflow-hidden rounded-xl border-2 border-dashed transition-colors duration-300 bg-white dark:bg-neutral-900',
isDragging
? 'border-emerald-500/50 bg-emerald-500/[0.03]'
: 'border-neutral-200 dark:border-white/10 hover:border-emerald-500/25'
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<div className="flex flex-col items-center justify-center py-10 px-6 text-center cursor-pointer">
<div className="relative mb-4">
<div className="relative bg-neutral-50 dark:bg-white/5 p-4 rounded-2xl border border-neutral-100 dark:border-white/10">
<Upload className="w-7 h-7 text-neutral-500" />
</div>
</div>
<h3 className="text-base font-semibold text-neutral-900 dark:text-white mb-1">Drag files to upload</h3>
<p className="text-xs text-neutral-500 mb-4">or click to select from your device</p>
</div>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileInput}
accept={acceptValue}
key={filter}
/>
<div
className="border-t border-neutral-100 dark:border-white/5 px-4 py-3 flex items-center justify-between bg-neutral-50/80 dark:bg-white/[0.02]"
onClick={(e) => e.stopPropagation()}
>
<div className="flex gap-1.5">
{FILTERS.map((f) => (
<button
key={f.id}
type="button"
onClick={() => setFilter(f.id)}
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'
)}
>
{f.label}
</button>
))}
</div>
<span className="text-[10px] text-neutral-400 tabular-nums">{files.length}/{maxFiles}</span>
</div>
</motion.div>
<div className="mt-4 space-y-2">
<AnimatePresence mode="popLayout">
{files.map((fileStatus) => (
<motion.div
key={fileStatus.id}
layout
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -8 }}
className="group flex items-center gap-3 p-3 rounded-xl border border-neutral-200 dark:border-white/10 bg-white dark:bg-neutral-900"
>
<div className="w-11 h-11 rounded-lg overflow-hidden shrink-0 border border-neutral-100 dark:border-white/5 flex items-center justify-center bg-neutral-50 dark:bg-white/5">
{fileStatus.preview ? (
<img src={fileStatus.preview} alt="" className="w-full h-full object-cover" />
) : (
getFileIcon(fileStatus.file)
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<p className="text-sm font-medium text-neutral-900 dark:text-white truncate max-w-[200px]">
{fileStatus.file.name}
</p>
<span className="text-[10px] text-neutral-400 tabular-nums">
{(fileStatus.file.size / 1024 / 1024).toFixed(2)} MB
</span>
</div>
<div className="h-1 w-full bg-neutral-100 dark:bg-neutral-800 rounded-full overflow-hidden">
<motion.div
className={cn(
'h-full rounded-full',
fileStatus.status === 'completed' ? 'bg-emerald-500' : 'bg-emerald-400'
)}
initial={{ width: 0 }}
animate={{ width: `${fileStatus.progress}%` }}
/>
</div>
</div>
<div className="flex items-center gap-1.5">
{fileStatus.status === 'completed' && (
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
)}
{fileStatus.status === 'uploading' && (
<span className="text-[10px] text-emerald-600 font-medium tabular-nums">
{Math.round(fileStatus.progress)}%
</span>
)}
<button
type="button"
onClick={() => removeFile(fileStatus.id)}
className="p-1 rounded-md hover:bg-red-500/10 hover:text-red-500 text-neutral-400 transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
)
}
Dependencies
framer-motion: latestlucide-react: latestclsx: latesttailwind-merge: latest
Props
Component property reference.
| Name | Type | Default | Description |
|---|---|---|---|
| onUpload | (files: File[]) => void | undefined | Callback with uploaded files. |
| maxFiles | number | 5 | Maximum number of files allowed. |
| accept | string[] | undefined | Accepted MIME types (e.g., ['image/png']). |
| className | string | undefined | Additional CSS classes. |
Most components here are inspired by outstanding libraries and creators in the ecosystem. I don’t claim to be the original author — this is my space for learning, rebuilding, and understanding great work at a deeper level.
I’m still a student of the craft, constantly studying the best and translating what I learn through my own perspective. Every piece reflects curiosity, respect for the community, and small creative touches that feel true to me.
I’ve done my best to credit inspirations properly. If anything is missing or inaccurate, I truly appreciate a message so it can be corrected with care.