Drag & Drop Physics

Drag-and-drop là một trong những interaction phức tạp nhất để làm đúng về mặt animation — có ít nhất 5 state cần xử lý riêng biệt: pickup, dragging, hover over target, drop, và cancel. Mỗi state cần visual feedback khác nhau.

  • Motion principles Theory
  • Spring physics Research
  • Stagger animation Pattern
  • Gesture feedback Pattern
  • Page transitions Pattern

Kéo icon ⠿ để sắp xếp lại thứ tự

Năm state của drag

idle → [pickup] → dragging → [hover target] → [drop/cancel]

                            [leave target]

idle: trạng thái bình thường.

pickup: người dùng bắt đầu drag — element “nhấc lên”, tạo shadow, scale nhẹ.

dragging: element follow con trỏ/ngón tay.

hover target: di chuyển qua drop zone — target highlight.

drop: thả xuống — element snap về vị trí cuối.

cancel: escape hoặc drop vào vùng không hợp lệ — element spring về vị trí ban đầu.

Pickup animation

.dragging {
  transform: scale(1.04) rotate(1.5deg);
  box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
  cursor: grabbing;
  z-index: 1000;
  transition: transform 0.15s cubic-bezier(0.2, 0, 0, 1),
              box-shadow 0.15s ease-out;
}

Scale lên nhẹ + rotate nhỏ + shadow mạnh hơn = element “nhấc lên khỏi mặt phẳng”.

Rotate (1–2 độ) là chi tiết quan trọng: vật thật bị nhấc lên thường nghiêng nhẹ do không cân bằng.

Placeholder

Khi element được drag ra khỏi vị trí, placeholder giữ khoảng trống — tránh layout collapse.

function startDrag(el) {
  const placeholder = el.cloneNode(false);
  placeholder.style.opacity = '0';
  placeholder.style.pointerEvents = 'none';
  el.parentNode.insertBefore(placeholder, el.nextSibling);
  return placeholder;
}

Placeholder có cùng kích thước với element gốc nhưng invisible. Khi element drag qua các vị trí khác, placeholder di chuyển để preview vị trí drop.

Placeholder movement (sort animation)

Khi drag qua một item trong list, item đó slide ra nhường chỗ cho placeholder.

.list-item {
  transition: transform 0.2s cubic-bezier(0.2, 0, 0, 1);
}

.list-item.displaced-down  { transform: translateY(80px); }
.list-item.displaced-up    { transform: translateY(-80px); }

80px là ví dụ — thực tế dùng chiều cao của item bị drag.

function updateDisplacements(dragIndex, overIndex) {
  items.forEach((item, i) => {
    item.classList.remove('displaced-up', 'displaced-down');
    if (dragIndex < overIndex && i > dragIndex && i <= overIndex) {
      item.classList.add('displaced-up');
    } else if (dragIndex > overIndex && i >= overIndex && i < dragIndex) {
      item.classList.add('displaced-down');
    }
  });
}

Drop animation

Khi thả, element cần animate về vị trí cuối cùng — không teleport.

function dropElement(el, targetRect) {
  const currentRect = el.getBoundingClientRect();
  const dx = targetRect.left - currentRect.left;
  const dy = targetRect.top  - currentRect.top;

  // FLIP technique: animate từ current position về target
  el.style.transition = 'transform 0.3s cubic-bezier(0.2, 0, 0, 1)';
  el.style.transform = `translate(${dx}px, ${dy}px)`;

  el.addEventListener('transitionend', () => {
    el.style.transition = '';
    el.style.transform  = '';
    // Insert el vào đúng vị trí trong DOM
    targetContainer.insertBefore(el, targetPosition);
  }, { once: true });
}

Kỹ thuật FLIP (First, Last, Invert, Play): đo vị trí trước và sau, animate sự chênh lệch.

Cancel — spring về vị trí cũ

Nếu drop không hợp lệ:

function cancelDrag(el, originRect) {
  const currentRect = el.getBoundingClientRect();
  const dx = originRect.left - currentRect.left;
  const dy = originRect.top  - currentRect.top;

  // Spring về với overshoot nhẹ
  el.style.transition = 'transform 0.4s cubic-bezier(0.34, 1.2, 0.64, 1)';
  el.style.transform = `translate(${dx}px, ${dy}px) rotate(0deg) scale(1)`;

  el.addEventListener('transitionend', () => {
    el.style.transition = '';
    el.style.transform  = '';
    el.classList.remove('dragging');
  }, { once: true });
}

cubic-bezier(0.34, 1.2, 0.64, 1) tạo overshoot nhỏ — element “bật nhẹ” khi về chỗ cũ, cảm giác như bị từ chối và nhảy trở lại.

Touch vs pointer events

Dùng Pointer Events API thay vì mouse hoặc touch events riêng — xử lý cả hai.

el.addEventListener('pointerdown', startDrag);
document.addEventListener('pointermove', onDrag);
document.addEventListener('pointerup', endDrag);

// Quan trọng: capture pointer để tiếp tục nhận event kể cả khi di chuyển ra ngoài element
el.setPointerCapture(event.pointerId);

setPointerCapture đảm bảo element tiếp tục nhận pointermove dù ngón tay đi xa — không bị mất track.

Tham khảo: Spring Parameters, Gesture Feedback Motion, Ease vs Spring.