How would you handle drag events and optimize their performance in the browser?
Drag events fire dozens of times per second per pointer. Three principles: (1) keep the work per event tiny — only mutate transform via requestAnimationFrame, never trigger layout in the handler; (2) use CSS transforms (translate3d) over top/left for compositor-only updates; (3) decouple visual updates from state updates — debounce or batch the React state set, keep the dragging visual purely in CSS. Use the modern Pointer Events + setPointerCapture for unified mouse/touch/pen.
Drag-and-drop performance lives or dies on what you do inside the per-event handler. With pointermove firing 60–120 times per second per pointer, even small overhead becomes visible jank.
Use Pointer Events, not mouse + touch
Pointer Events unify mouse, touch, and pen with one API and one event lifecycle:
el.addEventListener('pointerdown', onDown);
el.addEventListener('pointermove', onMove);
el.addEventListener('pointerup', onUp);setPointerCapture(e.pointerId) on pointerdown routes all subsequent events to the same element even if the cursor leaves it — no need for window-level listeners.
The cheap-handler principle
Inside pointermove:
- ❌ Don't read layout (
offsetWidth,getBoundingClientRectunless cached). - ❌ Don't
setStateper event (forces React render). - ❌ Don't trigger style changes that hit layout.
- ✅ Compute next
(x, y), write to a ref, request an animation frame.
const target = useRef(null);
const pos = useRef({ x: 0, y: 0, startX: 0, startY: 0 });
const raf = useRef(0);
function onMove(e) {
pos.current.x = e.clientX - pos.current.startX;
pos.current.y = e.clientY - pos.current.startY;
if (!raf.current) {
raf.current = requestAnimationFrame(apply);
}
}
function apply() {
raf.current = 0;
if (target.current) {
target.current.style.transform =
`translate3d(${pos.current.x}px, ${pos.current.y}px, 0)`;
}
}This pattern — RAF-coalesce + direct DOM write — uncouples the event rate from the paint rate. The browser only repaints once per frame regardless of how many pointermoves fire.
Use transform, not top/left
transform: translate3d(x, y, 0) is compositor-only — no layout, no paint of the dragged element's pixels. top/left triggers full layout + paint, which is 10x+ more expensive on complex pages.
will-change: transform on the draggable element promotes it to its own compositor layer:
.draggable { will-change: transform; }Add will-change only during drag and remove it after — keeping it on permanently uses GPU memory unnecessarily.
Visual state ≠ React state
If you also need React state (current position for other components, drop target highlight), update it on drop, not on every move. While dragging, the visual lives in raw DOM. On pointerup, commit final position to state:
function onUp() {
setPosition(pos.current); // single setState at end
target.current.style.willChange = '';
}If you need React state during drag (e.g., for hit testing against other components), throttle or use useTransition to mark it as non-urgent.
Hit testing
For drop zones, don't query elementFromPoint on every move — cache zone rects on drag start, intersect with cursor in the RAF callback.
const zonesRef = useRef([]);
function onDown() {
zonesRef.current = Array.from(document.querySelectorAll('.dropzone'))
.map(el => ({ el, rect: el.getBoundingClientRect() }));
}
function hitTest(x, y) {
return zonesRef.current.find(({ rect }) =>
x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom
);
}touch-action and scroll prevention
For touch dragging, prevent the browser's default scroll/refresh:
.draggable { touch-action: none; }This is much faster than preventDefault on every event.
Auto-scroll
When dragging near a container edge, scroll the container. Use the RAF loop you already have:
function apply() {
// … position update
const { y } = pos.current;
if (y > viewport.bottom - 50) scrollContainer.scrollBy(0, 5);
if (y < viewport.top + 50) scrollContainer.scrollBy(0, -5);
raf.current = requestAnimationFrame(apply); // keep looping
}Library reach
Hand-rolled drag is fine for simple cases. For sortable lists, multi-select, kanbans:
- dnd-kit — modern, hooks-based, a11y-aware.
- react-dnd — older, HOC-based.
- SortableJS / react-sortablejs — non-React option, very fast.
Accessibility
Mouse drag is half the story. Keyboard users need a way to move items (Up/Down to select, Space to grab, arrows to move, Enter to drop). Screen readers need announcements (aria-live). dnd-kit and similar libraries bake this in; rolling your own means re-implementing it.
Pitfalls
- React state per pointermove → 60+ re-renders per second → jank.
- Animating
top/leftinstead oftransform→ layout thrash. - Forgetting
setPointerCapture→ losing events when the cursor leaves the element. - Missing
touch-action: none→ browser fights you for the scroll gesture. will-changeleft on permanently → GPU memory wasted.- Computing layout (rects) inside the per-move handler → forced reflow.
Follow-up questions
- •Why use Pointer Events over mouse + touch separately?
- •How does setPointerCapture work and why is it useful?
- •What's the GPU cost of will-change and when does it backfire?
- •How do you make drag-and-drop accessible to keyboard users?
Common mistakes
- •setState on every pointermove — re-renders per frame.
- •Animating top/left instead of transform — full layout per frame.
- •Forgetting touch-action: none — touch dragging fights the browser's scroll.
- •Querying getBoundingClientRect inside the move handler instead of caching.
- •No requestAnimationFrame coalescing — running expensive work per event instead of per frame.
- •Rolling your own drag for a sortable list when dnd-kit handles a11y + edge cases.
Performance considerations
- •Done right, a drag interaction runs at 60fps (16ms/frame) even on mid-range mobile. The dominant cost is layout: avoid it. Compositor-only transforms are GPU-cheap. RAF coalescing caps work at the display rate. With these three primitives, even hundreds of draggables in the same view stay smooth.
Edge cases
- •Multi-touch: track multiple pointerIds; setPointerCapture per pointer.
- •Pen pressure / tilt available on Pointer Events (e.pressure, e.tiltX).
- •iOS Safari has unique behaviors around touch-action and overscroll — test there.
- •Drag inside a virtualized list (react-window): the dragged item may unmount as you scroll. Either pin it or use a portal.
- •RTL languages flip the math — base position calc on container layout, not viewport.
Real-world examples
- •Trello, Notion, Asana — sortable boards with hand-tuned drag for snappy feel.
- •Figma — drag uses Canvas/WebGL, not DOM, for extreme performance.
- •dnd-kit powers many modern React apps' DnD.