Playwright & Web Automation Hub

Playwright architecture, selector reliability, and advanced interaction patterns.

Simulating HTML5 Drag and Drop in Playwright

Drag-and-drop looks like a single gesture to a user, but to the browser it is a sequence of pointer and dragstart/dragover/drop events that must arrive in the right order, at the right coordinates, with real intermediate motion. Many automation attempts fail because a single dispatchEvent('drop') skips the intermediate steps that native HTML5 drag-and-drop implementations require. Playwright's locator.dragTo() covers the common case, while a manual mouse.down() / mouse.move() / mouse.up() sequence handles libraries that only react to genuine pointer movement. This page shows when each approach works, why synthetic events are silently ignored, and how to drive sortable lists reliably.

Drag and drop event sequence A drag gesture decomposed into hover, pointer down, intermediate moves, and pointer up, with two strategies that produce it. Source element draggable=true Drop target ondrop hover + down mouse.down() step moves mouse.move() release mouse.up() locator.dragTo() wraps this whole sequence into one call
Native drag-and-drop is a sequence, not an event. dragTo() emits the whole chain; manual mouse steps let you insert intermediate motion when a library needs it.

Root cause: synthetic drop events skip the gesture

A browser's native HTML5 drag-and-drop machinery only treats a sequence as a drag when it begins with a real pointer press on a draggable element, followed by movement that crosses the drag threshold, followed by a release over a valid target. Firing a lone drop event with dispatchEvent produces no dataTransfer payload and no dragenter/dragover lifecycle, so the application handler either receives an empty event or never runs. Pointer-based libraries such as sortable grids are stricter still: they ignore any motion that does not include intermediate mousemove/pointermove events, because they compute position from the live pointer stream. Playwright solves this by driving the real input pipeline. This is one of the harder gestures covered in Drag & Drop Workflows, which sits under Advanced Interactions & Test Assertions.

Minimal reproducible example

The test below moves a card from a source column to a target column. locator.dragTo() performs the hover, press, move, and release in one call, and the assertion checks that the card now lives inside the target.

import { test, expect } from '@playwright/test';

test('card moves from backlog to done via dragTo', async ({ page }) => {
  await page.goto('/board');

  // Resolve the two endpoints as locators so Playwright re-queries them
  // and waits for each to be visible, stable, and actionable.
  const card = page.getByRole('listitem', { name: 'Ship release notes' });
  const doneColumn = page.getByRole('list', { name: 'Done' });

  // dragTo() emits the full native sequence: hover source, mouse.down,
  // intermediate mouse.move toward the target, then mouse.up over it.
  await card.dragTo(doneColumn);

  // Assert on the resulting DOM state, not on any event we fired.
  await expect(doneColumn.getByRole('listitem', { name: 'Ship release notes' }))
    .toBeVisible();
});

Step-by-step fix

  1. Reach for locator.dragTo(target) first. It hovers the source, presses, moves to the target center, and releases — the complete native sequence. For standard HTML5 draggable elements and most drop zones this is all you need, and it auto-waits for both endpoints to be actionable.
  2. Pass sourcePosition / targetPosition when the center is wrong. If a card must be grabbed by a drag handle, or dropped at a precise insertion point in a list, supply { x, y } offsets so the press and release land where the library expects them.
  3. Fall back to manual mouse steps for pointer-driven libraries. Call await source.hover(), then page.mouse.down(), then one or more page.mouse.move(x, y, { steps: 10 }) calls, then page.mouse.up(). The intermediate moves with a steps count generate the mousemove stream that sortable libraries track.
  4. Move in increments, not one jump. A single mouse.move() to the destination often fails to cross a library's drag threshold. Use the steps option, or move to an intermediate point first, so the gesture reads as continuous motion.
  5. Hover the target before releasing. Add a mouse.move() over the drop zone (or a short waitForTimeout is a last resort) so the dragover/dragenter handlers register the target before mouse.up() fires the drop.
  6. Assert on the new DOM position. Verify the dragged element is now a child of the target container (getByRole scoped to the target), never that a particular event fired — the visible outcome is the contract.

Troubleshooting variants

dragTo() runs but nothing moves

The widget is a pointer-based library (for example a sortable list) that ignores the synthetic HTML5 drag sequence and reacts only to a live pointer stream. Replace dragTo() with the manual mouse.down() / mouse.move(..., { steps: 10 }) / mouse.up() sequence so genuine mousemove events are emitted between press and release.

The drop lands in the wrong slot of a sortable list

Sortable lists insert based on the pointer's position relative to each item's midpoint. Drop precisely by moving to the target item with targetPosition, or by issuing a mouse.move() to coordinates just above or below the neighbor where you want the item to land before releasing. Re-query the list after each move because indices shift as items reorder.

dataTransfer is empty in the application handler

The app reads files or text from event.dataTransfer, which the input-driven path does not populate. For file drops, dispatch a drop event with a constructed DataTransfer via dispatchEvent, or prefer the upload path described in Handling Multiple File Uploads in Playwright. For text payloads, set the data on the source's dragstart through an injected handler.

Verification

Confirm the gesture worked three ways. First, the assertion on the dragged element's new parent passes consistently across repeated runs (npx playwright test --repeat-each=10), proving the drop is not timing-dependent. Second, capture a trace with --trace on and scrub the recorded screenshots in the Playwright Trace Viewer to watch the element travel and land. Third, run headed with --headed --slow-mo=300 so you can see the press, the intermediate motion, and the release happen in order.

Frequently Asked Questions

When should I use dragTo() instead of manual mouse events?

Use locator.dragTo() for standard HTML5 draggable elements and ordinary drop zones — it emits the entire native sequence and auto-waits for both endpoints. Switch to manual mouse.down()/move()/up() only when a pointer-driven library ignores the synthetic sequence and needs a live stream of intermediate moves.

Why does my single mouse.move() to the target fail to trigger a drop?

Many drag libraries require continuous motion to cross a drag threshold and to compute the drop position. One large jump can skip that detection. Use the steps option on mouse.move(), or move through an intermediate point, so the browser emits a series of mousemove events.

How do I drop a file via drag and drop?

Native file drops carry data on event.dataTransfer, which input simulation does not fill. Construct a DataTransfer and dispatch a drop event through dispatchEvent, or use the dedicated file-input path with setInputFiles, which is more reliable for uploads.

Back to overview