Drag-and-drop is not one interaction.
It's six. A choreography that runs on every frame from press to release. When a drag feels right — Linear's lists, Figma's canvas, Things 3's reorder — it's because all six stages have been tuned with intent. When it feels wrong, it's almost always one stage being skipped.
This is a visual tour through each stage, built on hand-rolled Pointer Event primitives. No dnd-kit, no react-dnd — every demo runs on ~50 lines of code that you can read inline. The piece is the library.
- Stage 01
Intent
The browser doesn't know you mean to drag yet. Distance and time thresholds gate the start.
- Stage 02
Pickup
Pointer captured. The dragged thing leaves its slot. A ghost is rendered.
- Stage 03
Tracking
Every frame, the ghost follows the pointer. Cursor offset preserves where you grabbed.
- Stage 04
Hit-test
Every frame, the runtime asks: which drop zone is under the pointer right now?
- Stage 05
Preview
The drop zone announces itself — a divider line, a highlighted region, a snap point.
- Stage 06
Commit
Pointer released. The new position becomes the real position. Maybe animated, maybe instant.
The pointer is lying to you
Before any drag, you have to decide what counts as input. Mouse, touch, and pen all look different to the browser — unless you use the one API designed to unify them.
The instinct is to reach for mousedown, mousemove, mouseup. They're old, they're everywhere, every tutorial uses them. They're also wrong for drag.
Two failure modes show up immediately. The first: mouse events don't fire on touch devices. The second, less obvious: if the user drags the pointer outside the window — over a different app, off the screen — mouse events stop firing entirely. Your element is now stuck mid-drag with no way to recover.
Pointer Events fix both. One unified surface for mouse, touch, and pen, plus setPointerCapture which routes every event back to the original target until the pointer is released. Try it.
draggable="true", dataTransfer) exists. Nobody serious uses it. The drag image is unstylable, it doesn't work on touch without polyfills, and the event model is hostile to React. Mention it once, move on.Intent — when does a drag actually start?
A naive implementation starts dragging the moment you press down. Then every click becomes an accidental drag, every text selection becomes a jump cut. Real drag waits to be sure.
Two thresholds gate the start of a drag: distance (pointer must move at least N pixels) and time (pointer must be held at least M milliseconds). They exist because the alternative — drag fires on pointerdown — destroys two interactions: click, and text selection.
Different products tune these for different jobs. A list app like Linear uses a small distance and zero hold — you want responsiveness because accidental clicks on a row are cheap. A canvas tool like Figma is similar. A mobile-first reorder uses a long hold because there's no other way to disambiguate scroll from drag.
Drag versus selection
Press-and-move has two interpretations: the user is dragging something, or the user is selecting text. The browser defaults to text selection. The app has to teach it otherwise.
Both gestures begin identically — pointerdown, then movement. The browser cannot know which is intended until the app declares its preference. By default it treats press-and-move as text selection. The drag only wins if you explicitly take the gesture away from the browser.
Two mechanisms do that. setPointerCapture, called on pointerdown, hands the entire gesture to your element — every subsequent move and release event routes there, and the browser stops doing anything else with the pointer. That alone ends the selection conflict. user-select: none is the belt-and-suspenders backup: even if capture ever fails or is released early, CSS instructs the browser that this element's text is not selectable at all. touch-action: none does the equivalent on touch, preventing the browser from interpreting a slow press-and-move as scroll.
The demo below makes this visible. The broken list has a drag handler but skips setPointerCapture and user-select: none. It uses a 50 px threshold — generous enough to give you time to see what happens in the gap: the browser runs its text-selection logic and starts highlighting. The drag only kicks in after you've already lost the gesture. The correct list calls setPointerCapture immediately on pointerdown, closing the window before the browser can compete. Threshold is 4 px and drag starts at the first meaningful movement.
- Design the onboarding flow
- Run usability sessions
- Review component library PR
- Write up research findings
- Design the onboarding flow
- Run usability sessions
- Review component library PR
- Write up research findings
user-select: none on the whole page or body by default. It breaks copy-paste for everything — error messages, labels, documentation text. Scope it to the draggable element or apply it to body only for the duration of an active drag.The ghost
The thing under your cursor while you drag isn't the original row — it's a ghost. Three ways to render it, three different feelings, three different performance profiles.
What the user sees as "the thing I'm dragging" is a visual proxy. The original row might stay put (in a portal strategy), might move with the pointer (in an in-place strategy), or might be invisible while the browser draws its own ghost (in a native strategy).
Each choice locks in trade-offs. Get this one wrong and you spend the rest of the project fighting bounding-box math or jank.
- OAuth callback returning 500Bug
- Wire up the new onboarding stepFeature
- Rename calm-mode → focus-modeChore
- Audit the design tokensTech debt
Transform the original
Cheapest. The dragged row is the dragged ghost — we translate it with CSS transforms. No portal, no clone. Trade-off: the row vacates its slot, so the list collapses unless you reserve space.
Hit-testing the drop zone
Every frame of a drag, the runtime answers one question: which drop zone is the pointer over right now? The answer's harder than it looks, and the naive answer doesn't scale.
Three ways to answer it. document.elementFromPoint(x, y) asks the browser — which is correct, handles overlapping z-indexes, but triggers a layout flush every call. Bounding-box intersection scans an array of cached rects — O(n) per frame, but no layout. A spatial index (R-tree, quadtree) is O(log n), worth it past a few hundred targets.
The right choice depends on your worst case. A reorderable list of ten items? Use whatever. A Figma canvas with eight thousand objects? You're maintaining a quadtree whether you like it or not.
The commit — where does it land?
The pickup, the ghost, the hit-test are all setup. The moment that matters is the one between pointerup and the new layout. Two kinds of commit dominate the design world: list reorder, and canvas drop.
List reorder is mostly a single line of math. If the pointer's Y is above the midpoint of the hovered row, insert before. Otherwise, insert after. Treating the row itself as the drop target — instead of its top or bottom half — is the bug that makes lists feel sticky and wrong.
- Draft the calm-software outlineToday
- Reply to recruiter at LinearToday
- Review Maya's PRToday
- Re-record the demo for the lab postTomorrow
- Migrate the old prototypes off VercelLater
- Cancel the unused analytics seatLater
Canvas drop is even simpler in theory and harder in feel. The placed position is (pointer − cursorOffset), where cursorOffset is the distance from the object's top-left to where you grabbed it. Forget the offset, and dropped objects jump to the cursor — every Figma user has felt the difference in a worse tool.
The fallbacks people forget
A drag-and-drop interaction that only works for sighted people on a trackpad is, for at least one in five users, not an interaction at all.
Keyboard reorder is the original sin. The pattern is Space to pick up, arrows to move, Space to drop, Escape to cancel — set by react-beautiful-dnd in 2017 and adopted by every serious app since. It works because it maps the four mental moments of a drag to four keystrokes, and announces every state change to screen readers via aria-live.
The other three: long-press on touch (otherwise scroll wins), prefers-reduced-motion (skip the inertia and ease curves), and a visible focus ring on the picked-up state so keyboard users can tell something has happened.
- Design the onboarding flowpos 1
- Run usability sessionspos 2
- Review component library PRpos 3
- Write up research findingspos 4
- Sync with engineering on timelinespos 5
Crossing the boundary
Every drag so far has been within a single app. But files, images, and links travel across application boundaries — from Finder to Chrome, from your desktop to a web form. This uses a completely different protocol.
When you drag a file from the OS into a browser tab, the browser never receives pointer events — it gets OS-level drag events. The only API that can hear them is the HTML5 Drag and Drop API: dragenter, dragover, dragleave, drop.
This is the one place in this piece where we endorse it. You have no choice — the OS controls the gesture, Pointer Events are completely silent. event.dataTransfer.files gives you the dropped files on the drop event.
Two non-obvious rules govern cross-app drop. First, you must call e.preventDefault() on every dragover event — without it, the browser treats the drop as a navigation (opens the file) and your handler never fires. Second, dragleave is treacherous: it fires whenever the pointer moves over a child element, which makes the drop zone appear to flicker off. The standard fix is a drag-entry counter that increments on dragenter and decrements on dragleave.
Drag a file from Finder or your desktop
waiting…
dragenter and dragover you can read file names and types from dataTransfer.items, but not the file contents. Content is only accessible on drop. This prevents a malicious page from reading your files just because you hovered a link over it.This is also the foundation of every upload UI you've used — Figma's import, Notion's file attachment, GitHub's drag-to-attach. They're all the same four event listeners, roughly 20 lines of code. The quality difference between them comes entirely from the drop zone feedback: how quickly it activates, whether it handles multiple files, what it does when you drop something it can't use.
Who does what well.
Once you know the six stages, you can't unsee them. Open any app, drag anything, and watch which stages got attention and which got shipped.
Linear
Portal-clone ghost, midpoint insert math, full keyboard parity. Reorder feels like physical objects because every threshold is tuned: 5px distance, zero hold delay, 60fps ghost with a custom shadow and 1.5° tilt.
By Sid Bhattacharjee · Part of a series on foundational software concepts