Drag and drop

Механика drag-and-drop в библиотеке Motion One строится на управлении состоянием элемента через трансформации (transform) и события указателя (pointer events). В отличие от громоздких решений, Motion One предоставляет минималистичный API, позволяющий создавать плавные, аппаратно-ускоренные перемещения.

Ключевая идея — изменение координат элемента в ответ на действия пользователя с использованием animate и style.


Обработка событий указателя

Для реализации перетаскивания используется набор событий:

  • pointerdown — начало взаимодействия
  • pointermove — перемещение
  • pointerup — завершение

Пример базовой структуры:

const element = document.querySelector('.draggable');

let isDragging = false;
let startX = 0;
let startY = 0;

element.addEventListener('pointerdown', (event) => {
  isDragging = true;
  startX = event.clientX;
  startY = event.clientY;

  element.setPointerCapture(event.pointerId);
});

element.addEventListener('pointermove', (event) => {
  if (!isDragging) return;

  const dx = event.clientX - startX;
  const dy = event.clientY - startY;

  element.style.transform = `translate(${dx}px, ${dy}px)`;
});

element.addEventListener('pointerup', () => {
  isDragging = false;
});

Интеграция с animate()

Для повышения плавности движения и добавления инерции применяется функция animate:

import { animate } from "motion";

element.addEventListener('pointerup', () => {
  isDragging = false;

  animate(element, {
    transform: "translate(0px, 0px)"
  }, {
    duration: 0.5,
    easing: "ease-out"
  });
});

В данном случае элемент возвращается в исходное положение с анимацией.


Сохранение текущего положения

Чтобы избежать “скачков” при повторном перетаскивании, необходимо сохранять текущее смещение:

let x = 0;
let y = 0;

element.addEventListener('pointermove', (event) => {
  if (!isDragging) return;

  const dx = event.clientX - startX;
  const dy = event.clientY - startY;

  element.style.transform = `translate(${x + dx}px, ${y + dy}px)`;
});

element.addEventListener('pointerup', (event) => {
  isDragging = false;

  x += event.clientX - startX;
  y += event.clientY - startY;
});

Ограничение области перемещения

Часто требуется ограничить перемещение границами контейнера:

const container = document.querySelector('.container');

element.addEventListener('pointermove', (event) => {
  if (!isDragging) return;

  const rect = container.getBoundingClientRect();

  let dx = event.clientX - startX + x;
  let dy = event.clientY - startY + y;

  dx = Math.max(0, Math.min(dx, rect.width - element.offsetWidth));
  dy = Math.max(0, Math.min(dy, rect.height - element.offsetHeight));

  element.style.transform = `translate(${dx}px, ${dy}px)`;
});

Добавление инерции

Инерция делает взаимодействие более “живым”. Для этого вычисляется скорость движения:

let velocityX = 0;
let velocityY = 0;
let lastTime = 0;

element.addEventListener('pointermove', (event) => {
  if (!isDragging) return;

  const now = performance.now();
  const dt = now - lastTime;

  velocityX = (event.clientX - startX) / dt;
  velocityY = (event.clientY - startY) / dt;

  lastTime = now;
});

Применение после отпускания:

element.addEventListener('pointerup', () => {
  animate(element, {
    transform: `translate(${x + velocityX * 200}px, ${y + velocityY * 200}px)`
  }, {
    duration: 0.8,
    easing: "ease-out"
  });
});

Drag с пружинной физикой

Motion One поддерживает пружинные анимации:

animate(element, {
  transform: "translate(0px, 0px)"
}, {
  type: "spring",
  stiffness: 200,
  damping: 20
});

Параметры:

  • stiffness — жесткость (скорость возврата)
  • damping — затухание (устранение колебаний)

Перетаскивание нескольких элементов

Каждый элемент должен иметь собственное состояние:

document.querySelectorAll('.draggable').forEach((element) => {
  let x = 0;
  let y = 0;
  let isDragging = false;

  element.addEventListener('pointerdown', (event) => {
    isDragging = true;
    startX = event.clientX;
    startY = event.clientY;
  });

  element.addEventListener('pointermove', (event) => {
    if (!isDragging) return;

    const dx = event.clientX - startX;
    const dy = event.clientY - startY;

    element.style.transform = `translate(${x + dx}px, ${y + dy}px)`;
  });

  element.addEventListener('pointerup', (event) => {
    isDragging = false;
    x += event.clientX - startX;
    y += event.clientY - startY;
  });
});

Drop-зоны

Для реализации “сброса” элемента в определённую область используется проверка пересечения:

const dropZone = document.querySelector('.dropzone');

element.addEventListener('pointerup', () => {
  const elRect = element.getBoundingClientRect();
  const zoneRect = dropZone.getBoundingClientRect();

  const isInside =
    elRect.left < zoneRect.right &&
    elRect.right > zoneRect.left &&
    elRect.top < zoneRect.bottom &&
    elRect.bottom > zoneRect.top;

  if (isInside) {
    animate(element, {
      transform: `translate(${zoneRect.left}px, ${zoneRect.top}px)`
    });
  }
});

Оптимизация производительности

Ключевые принципы:

  • Использование transform вместо top/left
  • Минимизация перерисовок (reflow)
  • Использование will-change: transform в CSS
  • Избежание тяжёлых вычислений в pointermove
.draggable {
  will-change: transform;
  touch-action: none;
}

Поддержка сенсорных устройств

События pointer уже унифицированы и работают с:

  • мышью
  • стилусом
  • сенсорными экранами

Важно отключить стандартные жесты браузера:

.draggable {
  touch-action: none;
}

Связывание с состоянием приложения

Для сложных интерфейсов состояние перетаскивания может храниться отдельно:

const state = {
  items: []
};

При перемещении:

state.items[index].x = x;
state.items[index].y = y;

Это позволяет:

  • синхронизировать UI
  • сохранять позиции
  • реализовывать undo/redo

Комбинирование с другими анимациями

Motion One позволяет совмещать drag с другими эффектами:

  • масштабирование при захвате
  • изменение прозрачности
  • поворот
element.addEventListener('pointerdown', () => {
  animate(element, {
    scale: 1.1
  }, { duration: 0.2 });
});

element.addEventListener('pointerup', () => {
  animate(element, {
    scale: 1
  }, { duration: 0.2 });
});

Архитектурные подходы

Императивный подход

Прямое управление DOM:

  • быстрее
  • проще
  • меньше абстракций

Декларативный подход

Через состояние:

  • лучше масштабируется
  • удобен в React/Vue
  • требует синхронизации

Типичные ошибки

1. Потеря pointer capture Без setPointerCapture элемент может “терять” курсор.

2. Резкие скачки координат Причина — отсутствие сохранения текущего положения.

3. Низкая производительность Использование top/left вместо transform.

4. Игнорирование touch-action Приводит к конфликтам со скроллом.


Расширенные сценарии

Перетаскивание с осевыми ограничениями

element.style.transform = `translateX(${x + dx}px)`;

Сетка (snap to grid)

const grid = 50;

const snappedX = Math.round((x + dx) / grid) * grid;
const snappedY = Math.round((y + dy) / grid) * grid;

Drag с клонированием

Создание копии элемента при перетаскивании:

const clone = element.cloneNode(true);
document.body.appendChild(clone);

Взаимодействие с layout-анимациями

Motion One может автоматически анимировать изменения layout:

import { animate, layout } from "motion";

Это позволяет реализовать:

  • сортировку списков
  • drag для reorder
  • адаптивные интерфейсы

Подход к масштабируемым системам

При разработке сложных интерфейсов:

  • разделение логики drag и визуализации
  • использование модулей
  • централизованное состояние
  • обработка коллизий

Такая архитектура позволяет строить:

  • канбан-доски
  • редакторы интерфейсов
  • интерактивные панели

Практический паттерн: Drag Controller

Выделение логики в отдельный контроллер:

function createDraggable(element) {
  let x = 0;
  let y = 0;
  let isDragging = false;

  element.addEventListener('pointerdown', (event) => {
    isDragging = true;
    startX = event.clientX;
    startY = event.clientY;
  });

  element.addEventListener('pointermove', (event) => {
    if (!isDragging) return;

    const dx = event.clientX - startX;
    const dy = event.clientY - startY;

    element.style.transform = `translate(${x + dx}px, ${y + dy}px)`;
  });

  element.addEventListener('pointerup', (event) => {
    isDragging = false;
    x += event.clientX - startX;
    y += event.clientY - startY;
  });
}

Использование:

createDraggable(document.querySelector('.box'));

Связь с UX

Качественный drag-and-drop определяется:

  • отзывчивостью (latency < 16ms)
  • предсказуемостью
  • физической правдоподобностью
  • визуальной обратной связью

Motion One предоставляет инструменты для достижения этих характеристик через:

  • аппаратное ускорение
  • гибкие easing-функции
  • поддержку пружинной физики