Menu

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:

terminal
npx -y vui-registry-cli-v1@latest add file-upload

Source Code

file-upload.tsx
'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: latest
  • lucide-react: latest
  • clsx: latest
  • tailwind-merge: latest

Props

Component property reference.

NameTypeDefaultDescription
onUpload(files: File[]) => voidundefinedCallback with uploaded files.
maxFilesnumber5Maximum number of files allowed.
acceptstring[]undefinedAccepted MIME types (e.g., ['image/png']).
classNamestringundefinedAdditional CSS classes.
Context Worth Keeping In Orbit

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.