Velocity UI
Loading…
Menu

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.

Installation

Add this component to your project using the CLI:

terminal
npx vui-registry-cli-v1 add smart-file-drop

Source Code

smart-file-drop.tsx
'use client'

import React, { useState, useRef } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { UploadCloud, File, FileImage, FileText, X, Check, AlertCircle, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
// @ts-ignore
import confetti from 'canvas-confetti'

type FileStatus = 'idle' | 'dragging' | 'uploading' | 'success' | 'error'

interface SmartFileDropProps {
  onUpload?: (file: File) => Promise<void> | void;
  acceptedFileTypes?: string[];
  maxSize?: number; // in MB
}

export default function SmartFileDrop({
  onUpload,
  acceptedFileTypes,
  maxSize = 5
}: SmartFileDropProps) {
  return (
    <div className="flex items-center justify-center min-h-[500px] bg-neutral-100 dark:bg-neutral-950 p-8">
      <FileDropZone 
        onUpload={onUpload} 
        acceptedFileTypes={acceptedFileTypes} 
        maxSize={maxSize} 
      />
    </div>
  )
}

function FileDropZone({
  onUpload,
  acceptedFileTypes,
  maxSize
}: SmartFileDropProps) {
  const [status, setStatus] = useState<FileStatus>('idle')
  const [file, setFile] = useState<File | null>(null)
  const [progress, setProgress] = useState(0)
  const inputRef = useRef<HTMLInputElement>(null)

  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 && e.target.files[0]) {
      processFile(e.target.files[0])
    }
  }

  const processFile = async (file: File) => {
    setFile(file)
    setStatus('uploading')
    
    // Simulate upload if no onUpload provided
    if (!onUpload) {
        let p = 0
        const interval = setInterval(() => {
        p += Math.random() * 10
        if (p > 100) {
            p = 100
            clearInterval(interval)
            setStatus('success')
            confetti({
            particleCount: 100,
            spread: 70,
            origin: { y: 0.6 }
            })
        }
        setProgress(p)
        }, 200)
    } else {
        try {
            await onUpload(file);
            setStatus('success');
             confetti({
                particleCount: 100,
                spread: 70,
                origin: { y: 0.6 }
            })
        } catch (e) {
            setStatus('error');
        }
    }
  }

  const reset = (e: React.MouseEvent) => {
    e.stopPropagation()
    setStatus('idle')
    setFile(null)
    setProgress(0)
    if (inputRef.current) inputRef.current.value = ''
  }

  const getIcon = () => {
    if (!file) return <UploadCloud size={48} className="text-neutral-400" />
    if (file.type.startsWith('image/')) return <FileImage size={48} className="text-purple-500" />
    if (file.type.includes('pdf')) return <FileText size={48} className="text-red-500" />
    return <File size={48} className="text-blue-500" />
  }

  return (
    <motion.div
      layout
      className={cn(
        "relative w-full max-w-lg aspect-video rounded-[2rem] border-2 border-dashed transition-all duration-500 flex flex-col items-center justify-center p-8 cursor-pointer overflow-hidden bg-white/80 dark:bg-neutral-900/80 backdrop-blur-xl shadow-2xl",
        status === 'dragging' 
          ? "border-blue-500 bg-blue-50/50 dark:bg-blue-900/20 scale-[1.02] shadow-blue-500/20" 
          : "border-neutral-300 dark:border-white/10 hover:border-neutral-400 dark:hover:border-white/20 hover:bg-white dark:hover:bg-neutral-900",
        status === 'success' && "border-green-500 bg-green-50/50 dark:bg-green-900/20",
        status === 'error' && "border-red-500 bg-red-50/50 dark:bg-red-900/20"
      )}
      onClick={() => inputRef.current?.click()}
      onDragOver={handleDragOver}
      onDragLeave={handleDragLeave}
      onDrop={handleDrop}
    >
      <input 
        ref={inputRef}
        type="file" 
        className="hidden" 
        onChange={handleFileSelect} 
        accept={acceptedFileTypes?.join(',')}
      />

      <AnimatePresence mode="wait">
        {status === 'idle' || status === 'dragging' ? (
          <motion.div 
            key="idle"
            initial={{ opacity: 0, y: 10 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: -10 }}
            className="flex flex-col items-center text-center gap-4"
          >
            <div className={cn(
                "w-20 h-20 rounded-full bg-neutral-100 dark:bg-white/5 flex items-center justify-center transition-transform duration-500",
                status === 'dragging' && "scale-110 bg-blue-100 dark:bg-blue-500/20"
            )}>
                <UploadCloud 
                    size={32} 
                    className={cn(
                        "text-neutral-400 transition-colors duration-500",
                        status === 'dragging' && "text-blue-500"
                    )} 
                />
            </div>
            <div>
                <p className="text-lg font-semibold text-neutral-700 dark:text-neutral-200">
                    {status === 'dragging' ? "Drop it like it's hot!" : "Click or drag file to upload"}
                </p>
                <p className="text-sm text-neutral-400 mt-1">
                    SVG, PNG, JPG or GIF (max {maxSize}MB)
                </p>
            </div>
          </motion.div>
        ) : (
          <motion.div
            key="active"
            initial={{ opacity: 0, scale: 0.9 }}
            animate={{ opacity: 1, scale: 1 }}
            className="flex flex-col items-center gap-6 w-full max-w-xs"
          >
            <div className="relative">
                {status === 'uploading' && (
                    <motion.div 
                        className="absolute inset-0 rounded-full border-4 border-neutral-200 dark:border-neutral-800"
                        style={{ borderTopColor: 'var(--blue-500)', rotate: 360 }}
                        animate={{ rotate: 360 }}
                        transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
                    />
                )}
                <div className="w-24 h-24 rounded-2xl bg-white dark:bg-neutral-800 shadow-xl flex items-center justify-center relative z-10">
                    {getIcon()}
                    {status === 'success' && (
                        <motion.div 
                            initial={{ scale: 0 }} 
                            animate={{ scale: 1 }} 
                            className="absolute -right-2 -bottom-2 w-8 h-8 bg-green-500 rounded-full flex items-center justify-center border-4 border-white dark:border-neutral-900"
                        >
                            <Check size={16} className="text-white" strokeWidth={3} />
                        </motion.div>
                    )}
                </div>
            </div>

            <div className="w-full text-center">
                <p className="font-medium text-neutral-900 dark:text-white truncate px-4">
                    {file?.name}
                </p>
                <p className="text-sm text-neutral-500 mt-1">
                    {(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 h-2 bg-neutral-100 dark:bg-neutral-800 rounded-full overflow-hidden">
                    <motion.div 
                        className="h-full bg-blue-500"
                        initial={{ width: 0 }}
                        animate={{ width: `${progress}%` }}
                    />
                </div>
            )}

            {status === 'success' && (
                <motion.button
                    initial={{ opacity: 0, y: 10 }}
                    animate={{ opacity: 1, y: 0 }}
                    onClick={reset}
                    className="px-6 py-2 rounded-full bg-neutral-900 dark:bg-white text-white dark:text-black font-medium text-sm hover:scale-105 transition-transform"
                >
                    Upload Another
                </motion.button>
            )}

            {status === 'error' && (
                <div className="flex items-center gap-2 text-red-500">
                    <AlertCircle size={16} />
                    <span className="text-sm font-medium">Upload failed</span>
                    <button onClick={reset} className="ml-2 underline text-xs">Try again</button>
                </div>
            )}
          </motion.div>
        )}
      </AnimatePresence>
    </motion.div>
  )
}

Dependencies

  • framer-motion: latest
  • lucide-react: latest
  • clsx: latest
  • tailwind-merge: latest
  • canvas-confetti: latest
  • @types/canvas-confetti: latest