Реализация 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.
- Документация и передача кода команде.
Стоимость нативной реализации — от 15 000 рублей. С touch-поддержкой и библиотекой @dnd-kit — от 25 000 рублей. Это лучше, чем поддерживать нестабильное самописное решение с потоком багов на мобильных устройствах.
Как отлаживать 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 дня.







