Velocity UI
Loading…
Menu

File Upload Dropzone

A file upload component with drag-and-drop support, progress simulation, and file previews.

Installation

Add this component to your project using the CLI:

terminal
npx vui-registry-cli-v1 add file-upload

Source Code

file-upload.tsx
'use client'

import React, { useState, useRef, useCallback } from 'react'
import { motion, AnimatePresence, useAnimation } from 'framer-motion'
import { Upload, X, File, FileImage, FileText, CheckCircle2, AlertCircle, Trash2 } from 'lucide-react'
import { cn } from '@/lib/utils'

interface FileUploadProps {
  onUpload?: (files: File[]) => void
  maxFiles?: number
  accept?: string[] // e.g., ['image/png', 'application/pdf']
  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 fileInputRef = useRef<HTMLInputElement>(null)
  const controls = useAnimation()

  const handleDragEnter = (e: React.DragEvent) => {
    e.preventDefault()
    e.stopPropagation()
    setIsDragging(true)
    controls.start({ scale: 1.02, borderColor: 'var(--primary)' })
  }

  const handleDragLeave = (e: React.DragEvent) => {
    e.preventDefault()
    e.stopPropagation()
    setIsDragging(false)
    controls.start({ scale: 1, borderColor: 'var(--border)' })
  }

  const handleDragOver = (e: React.DragEvent) => {
    e.preventDefault()
    e.stopPropagation()
  }

  const handleDrop = useCallback(
    (e: React.DragEvent) => {
      e.preventDefault()
      e.stopPropagation()
      setIsDragging(false)
      controls.start({ scale: 1, borderColor: 'var(--border)' })

      const droppedFiles = Array.from(e.dataTransfer.files)
      handleFiles(droppedFiles)
    },
    [maxFiles, accept],
  )

  const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) {
      const selectedFiles = Array.from(e.target.files)
      handleFiles(selectedFiles)
    }
  }

  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])

    // Simulate upload progress
    newFileStatuses.forEach((fileStatus) => {
      simulateUpload(fileStatus.id)
    })
    
    if (onUpload) {
        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-blue-500" />
    if (file.type === 'application/pdf') return <FileText className="w-5 h-5 text-red-500" />
    return <File className="w-5 h-5 text-gray-500" />
  }

  return (
    <div className={cn('w-full max-w-xl mx-auto font-sans', className)}>
      <motion.div
        animate={controls}
        initial={{ scale: 1, borderColor: 'var(--border)' }}
        className={cn(
          'relative overflow-hidden rounded-2xl border-2 border-dashed transition-colors duration-300',
          isDragging
            ? 'border-blue-500/50 bg-blue-500/5'
            : 'border-foreground/10 bg-foreground/2 hover:bg-foreground/4',
        )}
        onDragEnter={handleDragEnter}
        onDragLeave={handleDragLeave}
        onDragOver={handleDragOver}
        onDrop={handleDrop}
        onClick={() => fileInputRef.current?.click()}
      >
        <div className="flex flex-col items-center justify-center py-12 px-6 text-center cursor-pointer">
          <div className="relative mb-4">
             <motion.div 
                className="absolute inset-0 bg-blue-500/20 rounded-full blur-xl"
                animate={{ scale: isDragging ? 1.5 : 1 }}
             />
             <div className="relative bg-background p-4 rounded-full border border-foreground/10 shadow-sm">
                <Upload className="w-8 h-8 text-foreground/70" />
             </div>
          </div>
          <h3 className="text-lg font-semibold text-foreground mb-1">
            Drag files to upload
          </h3>
          <p className="text-sm text-muted-foreground mb-4">
            or click to select files
          </p>
          <div className="flex gap-2 text-xs text-muted-foreground/60">
             <span>Supported formats: PNG, JPG, PDF</span>
             <span></span>
             <span>Max size: 10MB</span>
          </div>
        </div>
        <input
          ref={fileInputRef}
          type="file"
          multiple
          className="hidden"
          onChange={handleFileInput}
          accept={accept?.join(',')}
        />
        
        {/* Decorative Grid */}
        <div className="absolute inset-0 pointer-events-none opacity-[0.03]" 
             style={{ 
                 backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', 
                 backgroundSize: '20px 20px' 
             }} 
        />
      </motion.div>

      <div className="mt-6 space-y-3">
        <AnimatePresence mode='popLayout'>
          {files.map((fileStatus) => (
            <motion.div
              key={fileStatus.id}
              layout
              initial={{ opacity: 0, y: 10, scale: 0.95 }}
              animate={{ opacity: 1, y: 0, scale: 1 }}
              exit={{ opacity: 0, x: -10, scale: 0.95 }}
              className="group relative flex items-center gap-4 p-3 rounded-xl border border-foreground/10 bg-background/50 backdrop-blur-sm hover:bg-background/80 transition-colors"
            >
              <div className="relative w-12 h-12 rounded-lg overflow-hidden flex-shrink-0 border border-foreground/5 flex items-center justify-center bg-foreground/5">
                {fileStatus.preview ? (
                  <img src={fileStatus.preview} alt={fileStatus.file.name} 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-foreground truncate max-w-[200px]">
                    {fileStatus.file.name}
                  </p>
                  <span className="text-xs text-muted-foreground tabular-nums">
                    {(fileStatus.file.size / 1024 / 1024).toFixed(2)} MB
                  </span>
                </div>
                
                <div className="relative h-1.5 w-full bg-foreground/5 rounded-full overflow-hidden">
                  <motion.div
                    className={cn(
                        "absolute top-0 left-0 h-full rounded-full transition-colors duration-300",
                        fileStatus.status === 'completed' ? "bg-green-500" : "bg-blue-500"
                    )}
                    initial={{ width: 0 }}
                    animate={{ width: `${fileStatus.progress}%` }}
                    transition={{ ease: "easeOut" }}
                  />
                </div>
              </div>

              <div className="flex items-center gap-2">
                 {fileStatus.status === 'completed' && (
                    <motion.div initial={{ scale: 0 }} animate={{ scale: 1 }}>
                        <CheckCircle2 className="w-5 h-5 text-green-500" />
                    </motion.div>
                 )}
                 {fileStatus.status === 'uploading' && (
                     <span className="text-xs text-blue-500 font-medium">{Math.round(fileStatus.progress)}%</span>
                 )}
                 
                 <button
                   onClick={() => removeFile(fileStatus.id)}
                   className="p-1.5 rounded-full hover:bg-red-500/10 hover:text-red-500 text-muted-foreground transition-colors"
                 >
                    <X className="w-4 h-4" />
                 </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.