File Upload Dropzone

A drag-and-drop file upload zone with a dashed border, drag-over highlight, and a file list showing name, size, a progress bar, and status icon. Progress is simulated automatically. Supports click-to-browse via a hidden file input.

Forms & InputsInspired by Vercel
Live PreviewInteractive
Vercel · Deploy

Drag files here or click to browse

Max 10 MB per file

product-screenshot.png

2.2 MB

design-tokens.json

47.1 KB

export.csv

153.1 KB

Upload failed · click × to remove

Drag files onto the zone to see the highlight · Progress ticks automatically · Remove any file with ×

FileUploadDropzone.tsx
// Dependencies: react ^18, lucide-react
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { Upload, X, CheckCircle2, AlertCircle, File } from 'lucide-react';

function cn(...classes: (string | false | null | undefined)[]) {
  return classes.filter(Boolean).join(' ');
}

export type UploadItem = {
  id: string;
  name: string;
  size: number;
  status: 'uploading' | 'complete' | 'error';
  progress: number;
};

type Props = {
  accept?: string;
  maxSizeMb?: number;
  initialItems?: UploadItem[];
};

function formatBytes(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

export function FileUploadDropzone({ accept, maxSizeMb, initialItems = [] }: Props) {
  const [items, setItems] = useState<UploadItem[]>(initialItems);
  const [dragging, setDragging] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);
  const hasUploading = items.some((i) => i.status === 'uploading');

  useEffect(() => {
    if (!hasUploading) return;
    const timer = setInterval(() => {
      setItems((prev) =>
        prev.map((item): UploadItem => {
          if (item.status !== 'uploading') return item;
          const next = Math.min(item.progress + Math.random() * 15 + 5, 100);
          if (next >= 100) return { ...item, progress: 100, status: 'complete' };
          return { ...item, progress: next };
        })
      );
    }, 300);
    return () => clearInterval(timer);
  }, [hasUploading]);

  const addFiles = useCallback((files: FileList | null) => {
    if (!files) return;
    setItems((prev) => [
      ...prev,
      ...Array.from(files).map((f) => ({
        id: `${Date.now()}-${Math.random()}`,
        name: f.name,
        size: f.size,
        status: 'uploading' as const,
        progress: 0,
      })),
    ]);
  }, []);

  const remove = (id: string) => setItems((prev) => prev.filter((i) => i.id !== id));

  return (
    <div className="w-full flex flex-col gap-3">
      <div
        onClick={() => inputRef.current?.click()}
        onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
        onDragLeave={() => setDragging(false)}
        onDrop={(e) => { e.preventDefault(); setDragging(false); addFiles(e.dataTransfer.files); }}
        className={cn(
          'flex flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed p-8 cursor-pointer transition-colors select-none',
          dragging ? 'border-indigo-500 bg-indigo-500/5' : 'border-[#2a2a2a] bg-[#0d0d0d] hover:border-zinc-600 hover:bg-zinc-900/40',
        )}
      >
        <input ref={inputRef} type="file" multiple accept={accept} className="hidden" onChange={(e) => addFiles(e.target.files)} />
        <div className={cn('w-10 h-10 rounded-full flex items-center justify-center transition-colors', dragging ? 'bg-indigo-500/20' : 'bg-zinc-800')}>
          <Upload className={cn('w-5 h-5 transition-colors', dragging ? 'text-indigo-400' : 'text-zinc-400')} />
        </div>
        <div className="text-center">
          <p className={cn('text-sm font-medium transition-colors', dragging ? 'text-indigo-300' : 'text-zinc-300')}>
            {dragging ? 'Drop files here' : 'Drag files here or click to browse'}
          </p>
          {maxSizeMb && <p className="text-[11px] text-zinc-600 mt-0.5">Max {maxSizeMb} MB per file</p>}
        </div>
      </div>

      {items.length > 0 && (
        <div className="flex flex-col gap-2">
          {items.map((item) => (
            <div key={item.id} className="flex items-center gap-3 rounded-lg border border-[#1a1a1a] bg-[#0a0a0a] px-3 py-2.5">
              <File className="w-4 h-4 text-zinc-500 shrink-0" />
              <div className="flex-1 min-w-0">
                <div className="flex items-center justify-between gap-2 mb-1.5">
                  <p className="text-xs font-medium text-zinc-300 truncate">{item.name}</p>
                  <p className="text-[10px] text-zinc-600 shrink-0">{formatBytes(item.size)}</p>
                </div>
                {item.status === 'error' ? (
                  <p className="text-[10px] text-red-400">Upload failed · click × to remove</p>
                ) : (
                  <div className="h-1 rounded-full bg-zinc-800 overflow-hidden">
                    <div
                      className={cn('h-full rounded-full transition-all duration-300', item.status === 'complete' ? 'bg-emerald-500' : 'bg-indigo-500')}
                      style={{ width: `${item.status === 'complete' ? 100 : item.progress}%` }}
                    />
                  </div>
                )}
              </div>
              <div className="shrink-0 flex items-center gap-1.5">
                {item.status === 'complete' && <CheckCircle2 className="w-4 h-4 text-emerald-400" />}
                {item.status === 'error' && <AlertCircle className="w-4 h-4 text-red-400" />}
                <button
                  onClick={(e) => { e.stopPropagation(); remove(item.id); }}
                  className="text-zinc-600 hover:text-zinc-400 transition-colors"
                  aria-label="Remove file"
                >
                  <X className="w-4 h-4" />
                </button>
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// Usage:
// <FileUploadDropzone accept="image/*,.pdf" maxSizeMb={10} />
// Pre-seed with existing uploads:
// const items = [{ id: '1', name: 'logo.png', size: 45200, status: 'complete', progress: 100 }];
// <FileUploadDropzone initialItems={items} />