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.
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
- Reach for
locator.dragTo(target)first. It hovers the source, presses, moves to the target center, and releases — the complete native sequence. For standard HTML5draggableelements and most drop zones this is all you need, and it auto-waits for both endpoints to be actionable. - Pass
sourcePosition/targetPositionwhen 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. - Fall back to manual mouse steps for pointer-driven libraries. Call
await source.hover(), thenpage.mouse.down(), then one or morepage.mouse.move(x, y, { steps: 10 })calls, thenpage.mouse.up(). The intermediate moves with astepscount generate themousemovestream that sortable libraries track. - Move in increments, not one jump. A single
mouse.move()to the destination often fails to cross a library's drag threshold. Use thestepsoption, or move to an intermediate point first, so the gesture reads as continuous motion. - Hover the target before releasing. Add a
mouse.move()over the drop zone (or a shortwaitForTimeoutis a last resort) so thedragover/dragenterhandlers register the target beforemouse.up()fires the drop. - Assert on the new DOM position. Verify the dragged element is now a child of the target container (
getByRolescoped 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.