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.

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-drop

Source 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: latest
  • lucide-react: latest
  • clsx: latest
  • tailwind-merge: latest
  • canvas-confetti: latest
  • @types/canvas-confetti: latest