Реалізація 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 дня.







