Drag & Drop Physics

Drag-and-drop is one of the hardest interactions to get right in terms of animation — there are at least 5 states to handle separately: pickup, dragging, hover over target, drop, and cancel. Each state needs different visual feedback.

Drag the ⠿ icon to reorder

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

The five states of drag

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

                            [leave target]

idle: the normal state.

pickup: the user starts dragging — the element “lifts up”, casts a shadow, scales up slightly.

dragging: the element follows the cursor/finger.

hover target: moving over a drop zone — the target highlights.

drop: releasing — the element snaps to its final position.

cancel: escape or drop into an invalid area — the element springs back to its original position.

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;
}

Slight scale up + small rotate + stronger shadow = the element “lifts off the plane”.

The rotate (1–2 degrees) is an important detail: a real object lifted up tends to tilt slightly because it isn’t balanced.

Placeholder

When an element is dragged out of position, a placeholder holds the empty space — avoiding 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;
}

The placeholder has the same size as the original element but is invisible. As the element is dragged over other positions, the placeholder moves to preview the drop position.

Placeholder movement (sort animation)

When dragging over an item in a list, that item slides out to make room for the 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 is just an example — in practice use the height of the dragged item.

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

When released, the element needs to animate to its final position — not teleport.

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

  // FLIP technique: animate from the current position to the 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 at the correct position in the DOM
    targetContainer.insertBefore(el, targetPosition);
  }, { once: true });
}

The FLIP technique (First, Last, Invert, Play): measure the position before and after, then animate the difference.

Cancel — spring back to the old position

If the drop is invalid:

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

  // Spring back with a slight overshoot
  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) creates a small overshoot — the element “bounces slightly” as it returns to its old spot, feeling like it was rejected and snapped back.

Touch vs pointer events

Use the Pointer Events API instead of separate mouse or touch events — it handles both.

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

// Important: capture the pointer to keep receiving events even when moving outside the element
el.setPointerCapture(event.pointerId);

setPointerCapture ensures the element keeps receiving pointermove even as the finger moves far away — without losing track.

References: Spring Parameters, Gesture Feedback Motion, Ease vs Spring.