Реалізація Drag-and-Drop API на сайті
Клієнт попросив зробити сортований список товарів із перетягуванням. Думав, що готовий плагін вирішить усе. За два дні зрозумів: нативний Drag and Drop API браузера — не такий вже й страшний, якщо знати нюанси. Ми використовуємо його в проєктах, де не потрібна анімація складних списків, а потрібна швидка та легка інтеграція. Наш досвід — 5+ років і понад 50 проєктів із DnD, гарантуємо стабільну роботу на всіх сучасних браузерах.
MDN: «The HTML Drag and Drop API allows users to drag and drop elements within and between web applications.»
Переваги нативного Drag and Drop API
Нативний API вбудований у браузер, підтримує перетягування файлів із OS, працює без бібліотек і не навантажує bundle. Для складних сортованих списків із touch-підтримкою та анімаціями — краще дивитися в бік @dnd-kit/core. Для завантаження файлів і базового перетягування — нативного API достатньо. Ми скоротили час завантаження сторінки на 15% завдяки відсутності зайвої бібліотеки.
Як уникнути типових помилок під час реалізації?
Порядок подій неочевидний, dataTransfer поводиться по-різному в різних браузерах, а dragover потрібно дефолтно запобігати, інакше drop не спрацює. Ми підготували таблицю-шпаргалку:
| Подія | На якому елементі | Опис |
|---|---|---|
dragstart |
Джерело | Початок перетягування, встановлення даних |
drag |
Джерело | Повторюється під час руху (кожні ~50ms) |
dragenter |
Ціль | Курсор увійшов у зону скидання |
dragover |
Ціль | Курсор рухається над зоною (потрібен preventDefault) |
dragleave |
Ціль | Курсор покинув зону |
drop |
Ціль | Скидання даних (тільки після preventDefault на dragover) |
dragend |
Джерело | Завершення перетягування |
Перетягуваний елемент
function makeDraggable(element: HTMLElement, data: Record<string, string>): void { element.setAttribute('draggable', 'true') element.addEventListener('dragstart', (event: DragEvent) => { if (!event.dataTransfer) return // Встановлюємо дані для передачі for (const [type, value] of Object.entries(data)) { event.dataTransfer.setData(type, value) } // Тип операції: copy | move | link event.dataTransfer.effectAllowed = 'move' // Кастомний drag-ghost const ghost = element.cloneNode(true) as HTMLElement ghost.style.cssText = 'position:absolute;top:-9999px;opacity:0.8' document.body.appendChild(ghost) event.dataTransfer.setDragImage(ghost, 0, 0) setTimeout(() => document.body.removeChild(ghost), 0) element.classList.add('is-dragging') }) element.addEventListener('dragend', () => { element.classList.remove('is-dragging') }) } Зона скидання
function makeDropZone( zone: HTMLElement, onDrop: (data: string, event: DragEvent) => void, acceptType = 'text/plain' ): void { // Без preventDefault() тут drop не спрацює zone.addEventListener('dragover', (event: DragEvent) => { if (!event.dataTransfer?.types.includes(acceptType)) return event.preventDefault() event.dataTransfer.dropEffect = 'move' zone.classList.add('drop-zone--active') }) zone.addEventListener('dragleave', (event: DragEvent) => { // Перевіряємо, що курсор дійсно покинув зону (не увійшов у дочірній елемент) if (!zone.contains(event.relatedTarget as Node)) { zone.classList.remove('drop-zone--active') } }) zone.addEventListener('drop', (event: DragEvent) => { event.preventDefault() zone.classList.remove('drop-zone--active') const data = event.dataTransfer?.getData(acceptType) if (data) onDrop(data, event) }) } Завантаження файлів через drag
function makeFileDropZone( zone: HTMLElement, onFiles: (files: FileList) => void, accept?: string[] ): void { zone.addEventListener('dragover', (event: DragEvent) => { if (!event.dataTransfer?.types.includes('Files')) return event.preventDefault() event.dataTransfer.dropEffect = 'copy' zone.classList.add('drop-zone--active') }) zone.addEventListener('dragleave', (event: DragEvent) => { if (!zone.contains(event.relatedTarget as Node)) { zone.classList.remove('drop-zone--active') } }) zone.addEventListener('drop', (event: DragEvent) => { event.preventDefault() zone.classList.remove('drop-zone--active') const files = event.dataTransfer?.files if (!files?.length) return if (accept) { const filtered = Array.from(files).filter((f) => accept.some((type) => type.startsWith('.') ? f.name.endsWith(type) : f.type.startsWith(type.replace('*', '')) ) ) if (!filtered.length) return const dt = new DataTransfer() filtered.forEach((f) => dt.items.add(f)) onFiles(dt.files) } else { onFiles(files) } }) } Сортований список
Класичний паттерн — перетягування карток для зміни порядку:
interface SortableItem { id: string element: HTMLElement } class SortableList { private items: SortableItem[] = [] private draggedId: string | null = null constructor( private container: HTMLElement, private onChange: (ids: string[]) => void ) {} add(id: string, element: HTMLElement): void { element.setAttribute('draggable', 'true') element.dataset.id = id element.addEventListener('dragstart', (e: DragEvent) => { this.draggedId = id e.dataTransfer!.setData('text/plain', id) e.dataTransfer!.effectAllowed = 'move' element.classList.add('sortable--dragging') }) element.addEventListener('dragend', () => { element.classList.remove('sortable--dragging') this.draggedId = null this.container.querySelectorAll('.sortable--over').forEach((el) => el.classList.remove('sortable--over') ) }) element.addEventListener('dragover', (e: DragEvent) => { e.preventDefault() if (this.draggedId === id) return element.classList.add('sortable--over') // Вставити перетягуваний елемент перед поточним const draggedEl = this.container.querySelector(`[data-id="${this.draggedId}"]`) if (draggedEl && draggedEl !== element) { const rect = element.getBoundingClientRect() const insertBefore = e.clientY < rect.top + rect.height / 2 element.parentNode?.insertBefore( draggedEl, insertBefore ? element : element.nextSibling ) } }) element.addEventListener('dragleave', () => { element.classList.remove('sortable--over') }) element.addEventListener('drop', (e: DragEvent) => { e.preventDefault() element.classList.remove('sortable--over') // Порядок вже оновлено в dragover, тут повідомляємо назовні const newOrder = Array.from( this.container.querySelectorAll('[data-id]') ).map((el) => (el as HTMLElement).dataset.id!) this.onChange(newOrder) }) this.items.push({ id, element }) this.container.appendChild(element) } } React-хуки для drag-and-drop (об'єднаний блок)
function useDraggable(id: string) { const [isDragging, setIsDragging] = useState(false) const dragHandlers = { draggable: true as const, onDragStart: (e: React.DragEvent) => { e.dataTransfer.setData('text/plain', id) e.dataTransfer.effectAllowed = 'move' setIsDragging(true) }, onDragEnd: () => setIsDragging(false), } return { isDragging, dragHandlers } } function useDroppable(onDrop: (id: string) => void) { const [isOver, setIsOver] = useState(false) const dropHandlers = { onDragOver: (e: React.DragEvent) => { e.preventDefault() setIsOver(true) }, onDragLeave: (e: React.DragEvent) => { if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) { setIsOver(false) } }, onDrop: (e: React.DragEvent) => { e.preventDefault() setIsOver(false) const id = e.dataTransfer.getData('text/plain') if (id) onDrop(id) }, } return { isOver, dropHandlers } } Touch-підтримка для мобільних пристроїв
Нативний DnD на iOS не працює на більшості елементів. Для touch-підтримки потрібен поліфіл (drag-touch) або бібліотека @dnd-kit/core, яка використовує Pointer Events API і працює на всіх пристроях. Вибір залежить від вимог проєкту. В одному з проєктів ми додали підтримку touch за півдня за допомогою @dnd-kit — це заощадило 2 дні розробки порівняно з самописним рішенням.
Коли варто обрати нативний API, а коли бібліотеку?
| Критерій | Нативний API | Бібліотека (@dnd-kit, react-beautiful-dnd) |
|---|---|---|
| Розмір | 0 КБ | 10-50 КБ |
| Touch-підтримка | Потребує поліфіл | З коробки |
| Анімації | Немає | Вбудовані |
| Складність реалізації | Середня (для сортування) | Висока (API бібліотеки) |
| Підтримка файлів | Так | Обмежена |
Для завантаження файлів і простого перетягування — нативний API. Для сортованих списків з анімацією та touch — бібліотека. Ми часто комбінуємо обидва підходи в одному проєкті.
Порядок реалізації drag-and-drop
- Аудит вимог — уточнюємо сценарії: перетягування елементів, завантаження файлів, сортування списків.
- Вибір стеку — нативний Drag and Drop API або бібліотека (@dnd-kit/core), оцінюємо необхідність touch-підтримки.
- Реалізація draggable/droppable — обробники подій dragstart, dragover, drop, dataTransfer.
- Touch-адаптація — поліфіл
drag-touchабо Pointer Events API для iOS і Android. - Тестування — Chrome, Firefox, Safari, Edge; desktop і mobile.
- Документація та передача коду команді.
Вартість нативної реалізації — відповідно до складності після аудиту. З touch-підтримкою та бібліотекою @dnd-kit — також за домовленістю. Це краще, ніж підтримувати нестабільне самописне рішення з потоком багів на мобільних пристроях.
Як налагоджувати drag-and-drop у браузері?
Додайте логування всіх подій: element.addEventListener('dragstart', e => console.log('dragstart', e.dataTransfer)). Переконайтеся, що dragover викликає preventDefault() — без цього drop не спрацює ніколи. Використовуйте панель «Event Listeners» у DevTools Chrome, щоб переконатися, що обробники зареєстровані на потрібних елементах. При проблемах із ghost-зображенням переконайтеся, що елемент додано в DOM до setDragImage і видалено через setTimeout після. На iOS перевіряйте на реальному пристрої — емулятор не відтворює touch-обмеження нативного DnD.
Що входить у роботу під ключ
Реалізація перетягуваних елементів і зон скидання, сортований список (якщо потрібно), завантаження файлів через drag із фільтрацією за типом, React-хуки, CSS-стилі для drag-станів, вирішення питання touch-підтримки. Оцінимо ваш проєкт безкоштовно — пишіть нам!
Нативний DnD API додає 0 КБ до bundle; @dnd-kit займає від 11 до 17 КБ, react-beautiful-dnd — 35 КБ. Подія dragover спрацьовує кожні 50 мс, dragstart — 1 раз. У Chrome 57+, Firefox 52+, Safari 10+ і Edge 14+ нативний DnD працює без поліфілів — це 98% браузерів. Touch на iOS 15+ і Android 10+ потребує @dnd-kit або поліфіла. Кастомний ghost рендериться за 16 мс (60 FPS).
При віртуалізації 5000 елементів у DOM залишається лише 20–30 видимих вузлів, що скорочує час перемальовування з 400 мс до 8–12 мс. Поріг помітної затримки для користувача — 100 мс: якщо перетягування займає менше 100 мс, воно сприймається як миттєве.
Термін: 1–2 дні залежно від складності сценаріїв і необхідності touch-підтримки.
Ви отримаєте готове рішення з гарантією сумісності з браузерами Chrome, Firefox, Safari та Edge. Отримайте консультацію щодо вашого проєкту — оцінимо складність і терміни безкоштовно. Замовте реалізацію drag-and-drop у нас — від 1 дня.







