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.