Кожен зайвий клік у формі завантаження файлів знижує конверсію. Менеджеру потрібно завантажити 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 день.







