Drag & Drop Workflows
Drag and drop is the interaction where a test most often looks correct and silently is not. The browser emits mousedown, a stream of mousemove events, and mouseup; modern frameworks translate that into their own HTML5 dragstart, dragover, and drop lifecycle, frequently re-dispatching synthetic events the naive automation never produces. This guide, part of Advanced Interactions & Test Assertions, covers both the high-level locator.dragTo() path that handles most real cases and the manual page.mouse fallback for canvas renderers and custom libraries—and, just as important, how to assert that a drop actually landed.
The single discipline that makes drag tests reliable: a drop is complete when a DOM mutation confirms it, never when a timer expires. A target class flipping to active-drop, a card appearing in a new column, a dataTransfer payload reaching the handler—those are the signals. The absence of an error is not.
Why drag and drop breaks automation
A real user drag is a continuous physical motion that the browser samples into discrete pointer events. Frameworks layered on top—native HTML5 DnD, dnd-kit, react-beautiful-dnd, SortableJS—each interpret those events differently. Some require a dragover on the target before drop will register; some debounce reordering behind an animation frame; some read dataTransfer and ignore pointer position entirely. A test that fires a single mouseup on the target with nothing in between satisfies none of them.
Playwright addresses this at two levels. locator.dragTo() dispatches the complete, correctly ordered pointer sequence and waits for actionability on both source and target, which satisfies the large majority of DnD libraries. When a library reads raw coordinates from a <canvas> or a custom event bus, you reconstruct the motion yourself with page.mouse, computing positions from live bounding boxes. Both approaches rely on the locator resolution and assertion discipline established across Advanced Interactions & Test Assertions.
Reliable locator-based drag with dragTo()
locator.dragTo(target) is the default and should be your first attempt for every scenario. It resolves both locators in strict mode—throwing if either matches more than one element, which catches ambiguous .draggable selectors before they cause a misfire—then performs the full hover, press, move, and release sequence as one atomic action.
import { test, expect } from '@playwright/test';
test('move a card across kanban columns', async ({ page }) => {
await page.goto('/kanban');
// .first() keeps strict mode happy when many cards share a class.
const card = page.locator('.card', { hasText: 'Ship release' });
const doneColumn = page.getByRole('list', { name: 'Done' });
await expect(card).toBeVisible(); // both ends must be actionable
await expect(doneColumn).toBeVisible();
await card.dragTo(doneColumn);
// Completion signal is the card's new home, not elapsed time.
await expect(doneColumn.getByText('Ship release')).toBeVisible();
});
The deep walkthrough on simulating HTML5 drag and drop in Playwright covers the cases where a framework needs an explicit dragover dwell or a synthesized DataTransfer, and how to dispatch those events directly when dragTo() alone will not trigger the library.
Handling dynamic drop zones
Many interfaces highlight a drop zone while a drag hovers and only commit on release. Assert that transient feedback with a retrying matcher so you prove the zone recognized the drag:
import { test, expect } from '@playwright/test';
test('drop zone highlights then accepts the item', async ({ page }) => {
await page.goto('/uploader');
const item = page.getByRole('listitem', { name: 'invoice.pdf' });
const zone = page.getByTestId('drop-zone');
await item.dragTo(zone);
// Retries until the framework applies the active state class.
await expect(zone).toHaveClass(/active-drop/, { timeout: 5000 });
await expect(zone.getByText('invoice.pdf')).toBeVisible();
});
Pair the highlight assertion with locator.waitFor({ state: 'visible' }) on any element the framework renders asynchronously after the drop, so CSS transitions and deferred renders never race the assertion.
Manual page.mouse fallback for canvas and custom renderers
When the target is a <canvas> whiteboard or a library that reads pointer coordinates rather than DOM drop targets, dragTo() cannot help—there is no element to drop onto. Reconstruct the motion from runtime bounding boxes, never hardcoded offsets, and interpolate the move with steps so frameworks that sample mousemove see a realistic path.
import { test, expect } from '@playwright/test';
test('drag a shape on a canvas editor', async ({ page }) => {
await page.goto('/canvas-editor');
const source = page.locator('#shape');
const target = page.locator('#anchor');
await expect(source).toBeVisible();
const from = await source.boundingBox();
const to = await target.boundingBox();
if (!from || !to) throw new Error('bounding boxes unavailable');
// Press at the source center.
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
await page.mouse.down();
// Interpolated move so coordinate-sampling renderers register transit.
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 15 });
await page.mouse.up();
await expect(page.getByText('Shape moved')).toBeVisible({ timeout: 5000 });
});
Recompute boxes immediately before the drag; a value captured earlier goes stale after any scroll or layout shift. Wrap the sequence in test.step('drag shape', …) so a failure in the trace names the exact operation.
Multi-element chains and dataTransfer validation
Reordering several items means sequential drags, not parallel ones—overlapping pointer streams confuse every DnD library. Iterate with for…of and await each dragTo() so the framework settles its state between moves. Reserve Promise.allSettled() for genuinely independent, non-overlapping targets.
For interfaces that carry structured data, the truth is in the dataTransfer payload, not the pixels. Extract it inside the page and assert against a schema:
import { test, expect } from '@playwright/test';
test('drop carries the expected dataTransfer payload', async ({ page }) => {
await page.goto('/board');
// Capture the payload the drop handler actually received.
const dropped = page.evaluate(() => new Promise<string>((resolve) => {
document.querySelector('#bin')!.addEventListener(
'drop',
(e) => resolve((e as DragEvent).dataTransfer!.getData('text/plain')),
{ once: true },
);
}));
await page.locator('#token').dragTo(page.locator('#bin'));
expect(await dropped).toContain('token-42');
});
When the drop also fires a backend request—saving a new order, for instance—combine this payload check with the response wait from Network Interception Basics for end-to-end coverage. And when a drag deposits a file onto a canvas, the dataTransfer injection patterns in File Uploads & Downloads apply.
Cross-browser normalization
Chromium, Firefox, and WebKit dispatch pointer and drag events with subtle differences—dwell timing before a drop registers, how dragover repeats, momentum on release. locator.dragTo() normalizes most of this, which is the strongest argument for preferring it over manual mouse code. When you must go manual, the interpolated steps move is what keeps WebKit and Firefox in step with Chromium. Forms that revalidate after a drag-driven change should follow the assertion patterns in Form Automation & Input Handling so the post-drop UI state is confirmed on every engine.
Frequently Asked Questions
Why does my dragTo() succeed but the item does not move?
The pointer sequence fired, but the framework needed something extra—often a dragover dwell on the target or a populated DataTransfer object that the high-level call does not synthesize. Assert the post-drop DOM state to confirm the failure, then dispatch the missing HTML5 drag events explicitly as shown in the dedicated walkthrough.
Should I ever use hardcoded coordinates for a drag?
No. Hardcoded offsets break the moment the viewport scales or the layout shifts. Always read positions from locator.boundingBox() at runtime and compute centers from the returned box, recomputing immediately before the drag.
How do I drag many items reliably?
Run the drags sequentially with a for…of loop, awaiting each dragTo() so the framework processes one reorder before the next begins. Parallel drags overlap pointer events and corrupt the resulting order; use parallelism only for fully independent drop targets.