Drag and drop that doesn’t feel cheap
Drag and drop is the UI pattern most products get wrong. The native HTML drag event was designed for desktop file managers in 1997 and still feels that way. Ghost images. No touch support. A drop zone that only lights up sometimes. A cursor that doesn’t match what’s happening.
A good drag feels like picking up a physical object and putting it down. Pick up, make room, settle. Try it:
Pointer drag for mouse and touch. Tab to focus, space to pick up, arrows to move, escape to drop. Same operation, two input models.
No HTML drag events. No library. Pointer events, React state, four CSS properties. It works on mouse, touch, and keyboard.
Pick up, make room, settle
Every good drag interaction has three beats. Each one is a design decision that a weak implementation skips.
- Pick up. The moment the user commits. The item lifts off the list: scale up 1.5%, shadow grows, border tightens. The rest of the UI dims 5%. You’re holding something now.
- Make room. As you move, the items around you slide out of the way to show where you’ll land. Not a “drop zone” indicator (a dotted outline), an actual gap. The list is rearranging live.
- Settle. On release, the item drops into its slot with a spring. Not a snap (too rigid), not a linear slide (too slow). A cubic-bezier ease-out on a 300ms timing feels right.
Libraries make all three easy but opaque. Rolling your own with pointer events forces you to make the call on each one, and the call is a design call.
Why not the native API
The HTML5 drag-and-drop API has three problems that are hard to work around:
- No touch.
dragstartis a mouse event. On touch devices, nothing fires. You end up polyfilling the entire API, at which point you’re better off rolling it yourself. - The ghost image. A screenshot of the element that follows your cursor, rendered by the OS. You can’t style it, you can’t animate it, and it looks like a Windows 95 file icon. The only workaround is
setDragImage()with a transparent pixel and a custom overlay, which is what real drag libraries do. - Drop zones need explicit wiring.
dragover+preventDefault+drop, and the cursor only changes if you return the right value from the right handler. Half the time it doesn’t work and you can’t tell why.
Pointer events don’t have any of these problems. pointerdown + pointermove + pointerup work identically on mouse, touch, and stylus. You control every pixel of the dragged element because it’s a real DOM node, not a system screenshot.
The numbers that make it feel physical
Most drag interactions are within 10% of feeling great. These are the numbers I reach for:
- Lift scale: 1.015 to 1.02. More than that and it looks cartoonish.
- Shadow elevation: 0 8px 24px rgba(0,0,0,0.12). Strong enough to read as “lifted,” soft enough to not feel harsh.
- Settle duration: 300ms,
cubic-bezier(0.22, 1, 0.36, 1). A snappy ease-out that looks like a spring without being one. - Make-room duration: same as settle. Consistency matters more than the specific value.
- No transition on the dragged item during drag. Just
transformtied to pointer delta. Any easing during drag is latency.
The hardest number to get right is the one everyone skips: the threshold before the drag starts. A pointer down + 2 pixels of move should trigger the lift. Less and clicks become drags. More and you feel slack before the item moves.
Keyboard equivalents are not optional
A drag-and-drop list that only works with a mouse is broken. Roughly 10% of users rely on keyboard navigation full or part time, and reordering a list is exactly the sort of task where screen readers need an obvious model.
The pattern that works:
- Tab focuses items.
- Space or Enter picks up the focused item.
- Arrow keys move it up or down.
- Space or Enter again drops it.
- Escape cancels.
Announce state changes with aria-live. “Moved to position 3 of 5.” A screen-reader user drags by keyboard and hears exactly what happened.
The demo above has this wired. Focus an item, hit space, arrow, space to drop. Same operation as the pointer drag, different input model.
The three edges that break it
- Scroll during drag. If the list is taller than the viewport, the user drags to an edge and expects the list to scroll. Implement an auto-scroll: within 40px of the edge, advance the scroll position by a small amount per frame, accelerating toward the edge.
- Touch scroll conflicts.
touch-action: noneon the draggable item, applied only while dragging. If you set it globally, the user can’t scroll the rest of the page. - Pointer capture lost. If the pointer moves off the window, the drag ends silently.
setPointerCaptureon the target,pointercancelandpointerupboth handled, and fall back to dropping at the last valid position.
It’s about the other items
The trick to drag-and-drop is that most of the work isn’t on the item you’re dragging. It’s on everything else. The list is rearranging live, making room for where the dragged item will land. That’s what makes it feel like a physical object: the world responds.
If your list has a “move up” / “move down” pattern of arrow buttons on each row, you don’t have drag and drop. You have reordering controls. Sometimes that’s the right answer. But if the user ever needs to reorder more than three items in a row, drag is worth the effort.