Реализация Drag-and-Drop загрузки файлов на сайт
Drag-and-drop загрузка — перетаскивание файлов из файлового менеджера прямо в браузер. Уменьшает количество кликов и воспринимается как признак качественного интерфейса в задачах с частой загрузкой файлов: менеджеры документов, CMS, личные кабинеты с вложениями.
Браузерный Drag and Drop API
// hooks/useDragAndDrop.ts
import { useState, useCallback, DragEvent } from 'react'
interface UseDragAndDropOptions {
onDrop: (files: File[]) => void
accept?: string[] // MIME-типы: ['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)
// Проверяем, что перетаскиваются файлы
if (e.dataTransfer.items?.length > 0) {
setIsDragOver(true)
}
}, [disabled])
const handleDragLeave = useCallback((e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
// Проверяем, что ушли с зоны полностью (не на дочерний элемент)
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,
},
}
}
Компонент зоны сброса
// 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 = '' // сбросить, чтобы можно было выбрать тот же файл снова
}
}
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 при перетаскивании */}
{isDragOver && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-primary/10">
<p className="text-lg font-medium text-primary">Отпустите для загрузки</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">Перетащите файлы или нажмите для выбора</p>
<p className="text-sm text-muted-foreground mt-1">
{accept?.join(', ') ?? 'Любые файлы'} · до {maxFiles ?? 10} файлов
</p>
</div>
</div>
)}
<input
ref={inputRef}
type="file"
multiple
accept={accept?.join(',')}
className="sr-only"
onChange={handleInputChange}
disabled={disabled}
aria-label="Выбор файлов"
/>
</div>
)
}
Превью изображений до загрузки
// 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) // освобождаем память
}, [file])
return 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={`Удалить ${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 поверх всей страницы
Некоторые приложения (почтовые клиенты, файловые менеджеры) принимают файлы в любом месте страницы:
// 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">Отпустите файлы</p>
</div>
)}
{/* остальной UI */}
</div>
)
}
react-dropzone: готовая библиотека
Если нужно быстро, без написания хуков с нуля:
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>Отпустите файлы...</p>
) : (
<p>Перетащите изображения или кликните для выбора</p>
)}
</div>
)
}
Сроки
Кастомный хук + компонент DropZone с превью + индикатор прогресса — 1.5–2 дня. С глобальным drag-over оверлеем, сортировкой файлов, повторными попытками загрузки и интеграцией с presigned S3 URL — 3–4 дня.







