Каждый лишний клик в форме загрузки файлов снижает конверсию. Менеджеру нужно загрузить 50 документов в CRM — 50 раз открыть диалог выбора. Drag-and-drop решает проблему: пользователь перетаскивает файлы мышью, и процесс занимает секунды. Мы реализуем кастомную drag-and-drop загрузку: TypeScript-хук с валидацией MIME, глобальный дроп, превью и прогресс-бар. Экономия времени — до 60%, снижение ошибок ввода — на 40%. Опыт десятков проектов с файловыми менеджерами подтверждает эффективность.
Как реализовать drag-and-drop загрузку с нуля?
Drag-and-drop уменьшает количество действий пользователя в 2–3 раза по сравнению с классическим выбором. Визуальная обратная связь (подсветка зоны, иконки) даёт понять, что файлы принимаются, а предпросмотр до отправки снижает риск ошибок. Это стандарт для современных веб-приложений — Google Docs, Trello, Notion используют его повсеместно. Согласно MDN Drag and Drop API, нативный DnD API поддерживается всеми современными браузерами.
Cross-browser поведение
События dragenter, dragover, dragleave, drop работают неидеально во всех браузерах. Типичная ошибка — dragleave срабатывает при входе на дочерний элемент. Мы используем e.currentTarget.contains(e.relatedTarget) для проверки.
Валидация MIME-типов
Нужно отсеивать нежелательные форматы до отправки. Фильтр по шаблону image/* или точному MIME. В коде ниже показана гибкая проверка с поддержкой wildcard.
Как мы это делаем: кастомный хук useDragAndDrop
Разрабатываем хук на TypeScript, который возвращает пропсы для зоны сброса и состояние isDragOver / isDragActive. Поддерживаем кастомный accept-фильтр и отключение.
// 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,
},
}
}
Компонент DropZone: визуальная обратная связь и клик
Инкапсулируем хук в UI-компонент с aria-атрибутами для доступности. При перетаскивании показываем overlay «Отпустите для загрузки». Если пользователь кликает — открываем системный диалог.
// 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',
)}
>
{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>
)
}
Превью изображений до загрузки
Показываем миниатюру сразу после выбора файла, не дожидаясь отправки на сервер. Используем URL.createObjectURL и освобождаем память при размонтировании.
// 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
}
Как реализовать глобальный drag-and-drop?
Некоторые приложения (почтовые клиенты, файловые менеджеры) принимают файлы в любом месте страницы. Для этого используем хук useGlobalDrop с счётчиком событий dragenter/dragleave.
// 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
}
Глобальный оверлей добавляется с помощью флага isActive: если true, показываем полупрозрачное наложение на весь экран.
Как обрабатывать папки целиком?
Если браузер поддерживает DataTransferItem.webkitGetAsEntry, мы рекурсивно обходим содержимое папки и собираем все файлы, фильтруя по MIME-типам. Для старых браузеров используем сообщение пользователю о необходимости выбрать файлы вручную.
react-dropzone: когда нужно быстро
Если времени на кастомную реализацию нет, используем библиотеку react-dropzone. Она покрывает 90% кейсов: валидация, ограничение числа файлов, размера. Кастомный хук даёт больше контроля над визуалом и поведением.
| Критерий | Кастомный хук | react-dropzone |
|---|---|---|
| Гибкость UI | Полная | Ограничена пропсами |
| Размер бандла | ~2 KB | ~5 KB (в 2.5 раза больше) |
| Поддержка глобального дропа | Вручную | Не встроена |
| Accessibility | Нужно добавлять вручную | Есть базовая |
Типичные ошибки Drag and Drop API
| Ошибка | Причина | Решение |
|---|---|---|
dragleave срабатывает при наведении на дочерний элемент |
Событие всплывает | Проверка e.currentTarget.contains(e.relatedTarget) |
drop не срабатывает |
Не вызван preventDefault в dragover |
Всегда вызывать e.preventDefault() в dragover |
| Файлы не отображаются в глобальном дропе | Не проверен тип Files в dragenter |
Проверять e.dataTransfer.types.includes('Files') |
Настройка Nginx для загрузки больших файлов
client_max_body_size 100M;
proxy_request_buffering off;
proxy_buffering off;
Для серверов с ограничением по времени добавьте proxy_read_timeout 300s;.
Почему кастомный хук лучше react-dropzone?
Кастомный хук уменьшает размер бандла в 2.5 раза по сравнению с react-dropzone (2 KB против 5 KB). Кроме того, он даёт полный контроль над анимациями, оверлеями и поддержкой глобального дропа. Для сложных проектов с нестандартным UI это оптимальный выбор.
Процесс работы
- Аналитика — изучаем сценарии загрузки пользователей, определяем ограничения (макс. размер, типы файлов).
- Проектирование — прототип взаимодействия, выбор между кастомным хуком и библиотекой.
- Реализация — разработка компонентов, валидация, превью, индикатор прогресса.
- Тестирование — проверка в Chrome, Firefox, Safari, Edge, мобильных браузерах.
- Деплой — настройка Nginx/Cloudflare для больших файлов, интеграция с объектным хранилищем.
Сроки ориентировочно
- Базовый компонент с кастомным хуком, превью и прогресс-баром — 1.5–2 дня.
- Расширенная версия с глобальным дропом, сортировкой, retry, presigned URL — 3–4 дня.
Что входит в работу
- Исходный код (TypeScript, React) с комментариями.
- Документация по интеграции.
- Доступ к репозиторию.
- Обучение команды заказчика (1 час онлайн).
- Гарантия 6 месяцев на код.
Нужна консультация? Свяжитесь — мы подберем оптимальное решение и оценим ваш проект за 1 день.







