Drag-and-Drop File Upload Implementation
Drag-and-drop upload — dragging files directly from the file manager into the browser. It reduces clicks and is perceived as a sign of quality interface in tasks with frequent file uploads: document managers, CMS, personal accounts with attachments.
Browser Drag and Drop API
// hooks/useDragAndDrop.ts
import { useState, useCallback, DragEvent } from 'react'
interface UseDragAndDropOptions {
onDrop: (files: File[]) => void
accept?: string[] // MIME types: ['image/jpeg', 'image/png']
disabled?: boolean
}
export function useDragAndDrop({ onDrop, accept, disabled }: UseDragAndDropOptions) {
const [isDragOver, setIsDragOver] = useState(false)
const [isDragActive, setIsDragActive] = useState(false)
const handleDragEnter = useCallback((e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (disabled) return
setIsDragActive(true)
// Check that files are being dragged
if (e.dataTransfer.items?.length > 0) {
setIsDragOver(true)
}
}, [disabled])
const handleDragLeave = useCallback((e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
// Check that we left the zone completely (not to a child element)
if (e.currentTarget.contains(e.relatedTarget as Node)) return
setIsDragOver(false)
setIsDragActive(false)
}, [])
const handleDragOver = useCallback((e: DragEvent) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
}, [])
const handleDrop = useCallback((e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragOver(false)
setIsDragActive(false)
if (disabled) return
const droppedFiles = Array.from(e.dataTransfer.files)
const filtered = accept
? droppedFiles.filter(f => accept.some(mime => {
if (mime.endsWith('/*')) {
return f.type.startsWith(mime.replace('/*', '/'))
}
return f.type === mime
}))
: droppedFiles
if (filtered.length > 0) {
onDrop(filtered)
}
}, [onDrop, accept, disabled])
return {
isDragOver,
isDragActive,
dropZoneProps: {
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDragOver: handleDragOver,
onDrop: handleDrop,
},
}
}
Drop Zone Component
// components/DropZone.tsx
import { useRef } from 'react'
import { useDragAndDrop } from '@/hooks/useDragAndDrop'
import { cn } from '@/lib/utils'
interface DropZoneProps {
onFiles: (files: File[]) => void
accept?: string[]
maxFiles?: number
disabled?: boolean
children?: React.ReactNode
}
export function DropZone({ onFiles, accept, maxFiles, disabled, children }: DropZoneProps) {
const inputRef = useRef<HTMLInputElement>(null)
const { isDragOver, isDragActive, dropZoneProps } = useDragAndDrop({
onDrop: onFiles,
accept,
disabled,
})
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
onFiles(Array.from(e.target.files).slice(0, maxFiles))
e.target.value = '' // reset to select the same file again
}
}
return (
<div
{...dropZoneProps}
onClick={() => !disabled && inputRef.current?.click()}
role="button"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled}
onKeyDown={e => e.key === 'Enter' && !disabled && inputRef.current?.click()}
className={cn(
'relative border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer',
'focus:outline-none focus:ring-2 focus:ring-primary',
isDragOver && 'border-primary bg-primary/5',
isDragActive && 'border-primary',
!isDragOver && 'border-muted-foreground/30 hover:border-muted-foreground/60',
disabled && 'opacity-50 cursor-not-allowed',
)}
>
{/* Overlay during drag */}
{isDragOver && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-primary/10">
<p className="text-lg font-medium text-primary">Release to upload</p>
</div>
)}
{children ?? (
<div className="flex flex-col items-center gap-3 pointer-events-none">
<UploadIcon className="w-10 h-10 text-muted-foreground" />
<div>
<p className="font-medium">Drag files or click to select</p>
<p className="text-sm text-muted-foreground mt-1">
{accept?.join(', ') ?? 'Any files'} · up to {maxFiles ?? 10} files
</p>
</div>
</div>
)}
<input
ref={inputRef}
type="file"
multiple
accept={accept?.join(',')}
className="sr-only"
onChange={handleInputChange}
disabled={disabled}
aria-label="Select files"
/>
</div>
)
}
Image Preview Before Upload
// hooks/useFilePreview.ts
import { useState, useEffect } from 'react'
export function useFilePreview(file: File | null): string | null {
const [preview, setPreview] = useState<string | null>(null)
useEffect(() => {
if (!file || !file.type.startsWith('image/')) {
setPreview(null)
return
}
const url = URL.createObjectURL(file)
setPreview(url)
return () => URL.revokeObjectURL(url) // free memory
}, [file])
return preview
}
// File card component with preview
function FileCard({ uploadFile, onRemove }: { uploadFile: UploadFile; onRemove: () => void }) {
const preview = useFilePreview(
uploadFile.file.type.startsWith('image/') ? uploadFile.file : null
)
return (
<div className="flex items-center gap-3 p-3 border rounded-lg">
{preview ? (
<img
src={preview}
alt={uploadFile.file.name}
className="w-12 h-12 object-cover rounded"
/>
) : (
<FileIcon mimeType={uploadFile.file.type} className="w-12 h-12" />
)}
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{uploadFile.file.name}</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(uploadFile.file.size)}
</p>
{uploadFile.status === 'uploading' && (
<div className="mt-1 w-full bg-gray-200 rounded-full h-1.5">
<div
className="bg-primary h-1.5 rounded-full transition-all duration-300"
style={{ width: `${uploadFile.progress}%` }}
/>
</div>
)}
</div>
<button
onClick={onRemove}
disabled={uploadFile.status === 'uploading'}
className="text-muted-foreground hover:text-destructive"
aria-label={`Delete ${uploadFile.file.name}`}
>
✕
</button>
</div>
)
}
function formatFileSize(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`
}
Drag-and-drop Over Entire Page
Some applications (email clients, file managers) accept files anywhere on the page:
// hooks/useGlobalDrop.ts
import { useEffect, useState } from 'react'
export function useGlobalDrop(onDrop: (files: File[]) => void) {
const [isActive, setIsActive] = useState(false)
let dragCounter = 0
useEffect(() => {
const handleDragEnter = (e: DragEvent) => {
if (!e.dataTransfer?.types.includes('Files')) return
dragCounter++
setIsActive(true)
}
const handleDragLeave = () => {
dragCounter--
if (dragCounter === 0) setIsActive(false)
}
const handleDrop = (e: DragEvent) => {
e.preventDefault()
dragCounter = 0
setIsActive(false)
if (e.dataTransfer?.files.length) {
onDrop(Array.from(e.dataTransfer.files))
}
}
const handleDragOver = (e: DragEvent) => e.preventDefault()
document.addEventListener('dragenter', handleDragEnter)
document.addEventListener('dragleave', handleDragLeave)
document.addEventListener('dragover', handleDragOver)
document.addEventListener('drop', handleDrop)
return () => {
document.removeEventListener('dragenter', handleDragEnter)
document.removeEventListener('dragleave', handleDragLeave)
document.removeEventListener('dragover', handleDragOver)
document.removeEventListener('drop', handleDrop)
}
}, [onDrop])
return isActive
}
function App() {
const [files, setFiles] = useState<File[]>([])
const isGlobalDragActive = useGlobalDrop(newFiles => setFiles(prev => [...prev, ...newFiles]))
return (
<div>
{isGlobalDragActive && (
<div className="fixed inset-0 z-50 bg-primary/20 border-4 border-dashed border-primary flex items-center justify-center pointer-events-none">
<p className="text-2xl font-bold text-primary">Drop files</p>
</div>
)}
{/* rest of UI */}
</div>
)
}
react-dropzone: Ready-made Library
If you need a quick solution without writing hooks from scratch:
npm install react-dropzone
import { useDropzone } from 'react-dropzone'
function MediaUploader({ onFiles }: { onFiles: (files: File[]) => void }) {
const { getRootProps, getInputProps, isDragActive, acceptedFiles } = useDropzone({
onDrop: onFiles,
accept: { 'image/*': ['.jpg', '.jpeg', '.png', '.webp'] },
maxFiles: 20,
maxSize: 10 * 1024 * 1024,
})
return (
<div
{...getRootProps()}
className={cn('dropzone', isDragActive && 'dropzone--active')}
>
<input {...getInputProps()} />
{isDragActive ? (
<p>Drop files...</p>
) : (
<p>Drag images or click to select</p>
)}
</div>
)
}
Timeline
Custom hook + DropZone component with preview + progress indicator — 1.5–2 days. With global drag-over overlay, file sorting, retry logic, and presigned S3 URL integration — 3–4 days.







