Каждый лишний клик в форме загрузки файлов снижает конверсию. Менеджеру нужно загрузить 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 день.







