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.
The practical consequence is that two entirely different systems can sit behind the same visual gesture, and they fail in different ways. A widget built on the HTML5 API sets draggable="true", listens for dragstart, and reads or writes event.dataTransfer — the browser itself owns the drag session, including the ghost image and the cursor. A widget built on pointer events (the pattern used by most modern sortable and kanban libraries) never touches the drag-and-drop API at all: it subscribes to pointerdown, tracks every pointermove, translates the element with CSS transforms, and commits on pointerup. Identifying which system you are facing before you write a line of test code decides the strategy, because the one that works for each is the one the other silently ignores.
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();
});
The manual pointer equivalent
The same board rendered by a pointer-driven sortable library will not respond to that call at all — the library never registered a dragstart handler. Reproducing the gesture means issuing the press, the interpolated motion, and the release yourself, reading the geometry of both endpoints with boundingBox() so the coordinates track the real layout rather than hard-coded pixels.
import { test, expect } from '@playwright/test';
test('card reorders in a pointer-driven sortable list', async ({ page }) => {
await page.goto('/board');
const card = page.getByRole('listitem', { name: 'Ship release notes' });
const doneColumn = page.getByRole('list', { name: 'Done' });
// Read live geometry; never hard-code coordinates, they drift per viewport.
const from = await card.boundingBox();
const to = await doneColumn.boundingBox();
if (!from || !to) throw new Error('endpoint not rendered');
// Press on the centre of the card so the library binds its pointer session.
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
await page.mouse.down();
// A short nudge first: this is what crosses the library's drag threshold.
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2 + 12, { steps: 5 });
// Then travel to the target in interpolated increments, not one jump.
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 20 });
// Settle over the drop zone so its hover/placeholder logic commits.
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2 + 4, { steps: 4 });
await page.mouse.up();
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. Role-scoped queries survive the class-name churn these libraries produce, which is why getByRole beats CSS selectors in modern apps. - Wrap the gesture in one reusable helper. Drag logic is the kind of detail that should live once behind a board or list object rather than being copied into every spec — the same argument made in Structuring Large Projects with Page Object Model. A single
moveCard(name, column)method keeps the step count, the offsets, and the settle move in one place, so tuning them later is a one-file change instead of a sweep.
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.
The drag passes headed but fails in headless CI
Headless runs use a different default viewport and no window manager, so a target that was on screen locally may sit below the fold on the agent — and an off-screen coordinate produces a press that lands on nothing. Call scrollIntoViewIfNeeded() on both endpoints, take the bounding box after scrolling, and disable CSS transitions during the run so the placeholder animation cannot move the drop zone between the measurement and the release. If it still alternates between pass and fail, treat it as a genuine race and work through Detecting and Fixing Flaky Playwright Tests rather than raising the timeout. Where the list re-renders asynchronously after the drop, the settle patterns in Waiting Strategies for Dynamic React Components apply directly, and the retry and timeout budgets in Configuring Retries and Timeouts for Stable CI keep a slow agent from turning a correct gesture into a red build.
Verification
Confirm the gesture worked four 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; stepping the same spec through Playwright Inspector and UI Mode lets you pause between the move and the release and inspect the placeholder the library has inserted.
Fourth, prove the negative: temporarily reduce the steps count to 1 and confirm the test now fails. A gesture that still passes without interpolated motion is being satisfied by something other than the drag you think you are exercising — often an unrelated click handler — and that assertion will not catch a real regression. Keep video and screenshot capture enabled on failure, as described in Capturing Screenshots and Video on Failure, so the first CI failure arrives with a recording of where the pointer actually went instead of a bare timeout message.
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.
Does the same drag code work across Chromium, Firefox, and WebKit?
The input pipeline is the same in all three, so a gesture built from real press, move, and release calls is portable. Differences show up in timing rather than behaviour: WebKit tends to need a slightly larger initial nudge before a library registers the drag, and Firefox commits the drop marginally later. Tune the steps count rather than adding fixed sleeps, and check the result on every engine using the approach in Running Chromium vs Firefox vs WebKit in Playwright.