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







