Playwright architecture, selector reliability, and advanced interaction patterns.

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.

A second discipline follows from the first. Every drag test has two halves that fail for entirely different reasons: the transport (did the right sequence of events reach the right elements?) and the effect (did the application state change?). Conflating them produces the most common bug report in a drag suite—"the test is flaky"—when in fact the transport is deterministic and the effect assertion is racing an animation. Keep the two halves distinct in your head, and in your test code, and most drag flakiness stops being mysterious.

Drag lifecycle from grab to verified drop A drag moves through grab, transit, and drop phases, then resolves into a DOM assertion that confirms the transfer. Grab mousedown Transit mousemove Drop mouseup Assert toHaveClass active-drop locator.dragTo() runs grab → transit → drop atomically
dragTo() collapses grab, transit, and drop into one call; the test is only trustworthy once a DOM assertion confirms the drop landed.

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.

The deeper reason is that the web has two unrelated drag protocols living in the same page. The first is the native HTML5 drag-and-drop protocol, driven by the draggable="true" attribute, mediated by the browser's own drag manager, and expressed through dragstart / dragenter / dragover / drop / dragend events that carry a DataTransfer object. The second is a synthetic pointer protocol invented by libraries that wanted control the native API does not give them—custom drag previews, smooth reordering animations, touch parity, cancellation on Escape. Those libraries listen to pointerdown, pointermove, and pointerup (or their mouse equivalents) and never involve the browser's drag manager at all.

The two protocols look identical to a user and behave nothing alike under automation. A native HTML5 target ignores a perfect pointer stream because no dragstart was ever raised by the drag manager. A pointer-based library ignores a hand-dispatched drop event because it is not listening for one. Before writing a single line of test code, determine which protocol the component under test speaks; every later decision follows from that answer, and the diagnostic recipe further down this page tells you in under a minute.

Three further mechanics cause the residual failures once the protocol is right:

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.

What each drag implementation listens for A matrix comparing five drag implementations against the pointer stream, dragover dwell, dataTransfer payload, and whether dragTo alone succeeds. What each drag implementation actually listens for implementation pointer stream dragover dwell dragTo() alone native HTML5 DnD required required usually dnd-kit (pointer) required not used yes react-beautiful-dnd required not used yes SortableJS required optional usually canvas renderer sampled not used no needs page.mouse green outline = event the library requires
Read the row for your component before choosing an approach: only the canvas renderer genuinely needs the manual pointer path.

Prerequisites

Three things should be true before you write a drag test. First, the source and target must be reachable by resilient locators—role, label, or test id—because a drag amplifies the cost of an ambiguous selector: a mis-resolved source silently drags the wrong card into a real column. The role-first approach in getByRole & Accessibility Selectors applies directly.

Second, the page must be settled before the grab. Bounding boxes read during a layout animation are already wrong by the time the pointer moves, so gate the drag behind a visibility assertion on both ends rather than a navigation wait. The distinction between "the network is quiet" and "this element is in its final position" is covered in Handling Dynamic Content.

Third, decide what a successful drop looks like before writing the drag, and write that assertion first. If you cannot name the DOM change, the class flip, or the request that proves the drop happened, the test cannot be made reliable no matter which API you use.

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();
});

What dragTo() does under the hood matters when you are debugging it. It scrolls both elements into view, waits for each to be visible, stable, and to receive pointer events, hovers the source, presses, moves to the target in a small number of intermediate steps, and releases. Actionability applies to both ends, which is why a drop target hidden behind a sticky header produces a timeout on the target rather than a silent no-op — a considerably better failure than the one you get from raw mouse calls.

Two options change the geometry. sourcePosition picks the point inside the source that is grabbed, which matters whenever a card exposes a dedicated drag handle and ignores presses on its body. targetPosition picks the drop point inside the target, which matters for wide containers—a timeline track, a calendar row, a resizable pane—where where you drop decides the resulting value. Both are measured from the top-left of the element's box, not from the viewport, so they survive scrolling.

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

test('drops a clip at a specific offset on a timeline track', async ({ page }) => {
  await page.goto('/editor/timeline');

  const clip = page.getByRole('button', { name: 'Intro clip' });
  const track = page.getByTestId('track-2');

  await expect(clip).toBeVisible();
  await expect(track).toBeVisible();

  await clip.dragTo(track, {
    // Grab the 16px handle in the corner; the card body ignores presses.
    sourcePosition: { x: 8, y: 8 },
    // Drop 420px along the track, which the app converts to a timecode.
    targetPosition: { x: 420, y: 30 },
  });

  // Assert the derived value, not the pixel position — the pixel is an input.
  await expect(track.getByRole('button', { name: 'Intro clip' }))
    .toHaveAttribute('data-start', '00:00:07');
});

There is also a trial: true option. It runs every actionability check and then performs no action at all, which makes it a precise way to answer "is this drag even possible right now?" while diagnosing an overlay that intercepts pointer events. Use force: true only as a temporary probe: it skips the checks that would otherwise tell you the target was covered, and a suite that needs force to pass is a suite hiding a real bug.

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. Where the highlight is genuinely mid-drag state that disappears on release, you cannot assert it after dragTo() at all — the atomic call has already released the pointer. In that case, split the drag manually (press, move, assert the highlight, release) as shown in the next section, and treat the highlight as a diagnostic rather than a permanent assertion.

Rejected drops and invalid targets

An under-tested half of every drag feature is the drop that should not work: a card dragged onto a locked column, a file dropped onto a zone that rejects its MIME type, an item dragged back onto its own origin. These paths break more often than the happy path because they are usually implemented by an early return that nobody exercises. Assert them as explicitly as the success case — the item must still be in its original position, an error message must appear, and no network request must have fired.

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

test('a locked column rejects the drop and leaves state unchanged', async ({ page }) => {
  await page.goto('/kanban');

  const card = page.getByRole('listitem', { name: 'Ship release' });
  const backlog = page.getByRole('list', { name: 'Backlog' });
  const locked = page.getByRole('list', { name: 'Archived' });

  // Fail fast if a later refactor unlocks the column and invalidates the test.
  await expect(locked).toHaveAttribute('aria-disabled', 'true');

  await card.dragTo(locked);

  await expect(locked.getByText('Ship release')).toHaveCount(0);   // never landed
  await expect(backlog.getByText('Ship release')).toBeVisible();   // stayed home
  await expect(page.getByRole('alert')).toContainText('Archived is read-only');
});

Note toHaveCount(0) rather than a negated visibility check: it retries against an empty result set, which is the correct semantics for "this never appears" and avoids the false pass you get when the assertion runs before the framework has rendered anything at all.

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.

Four details separate a manual drag that works from one that works on your machine only. Steps count: steps: 1 teleports and defeats motion thresholds; anything above roughly 25 adds latency without adding realism. Ten to twenty is the usable band. A nudge before the real move: some libraries arm their drag only after the pointer has travelled a few pixels, so an initial mouse.move(x + 6, y + 6) immediately after mouse.down() reliably crosses that threshold. A dwell at the destination: renderers that commit on the next animation frame need the pointer to stay at the target for at least one frame before release, which a second mouse.move() to the same coordinates provides without an arbitrary sleep. Coordinate space: boundingBox() returns CSS pixels relative to the main frame's viewport, and page.mouse consumes the same space — but an element inside a scrolled container may report a box that is partly off-screen, so scroll it into view with scrollIntoViewIfNeeded() before measuring.

Manual pointer drag message sequence A sequence diagram showing the test code calling the mouse API, the renderer receiving each pointer event, and the final assertion confirming the mutation. test code page.mouse canvas renderer app state boundingBox() move to source centre down() then 6px nudge move(to, steps: 15) dwell one frame up() commit mutation expect(...).toBeVisible() retries until the mutation lands
The nudge and the dwell are the two messages a naive manual drag omits, and they are the two most common reasons a canvas drag never commits.

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.

The important refinement is to assert between the drags, not only at the end. A chain of five drags that ends in the wrong order tells you nothing about which drag went wrong; a chain that asserts the intermediate order after each move fails on the exact step that broke, and the trace then contains the DOM before and after that single operation.

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

const desiredOrder = ['Beta', 'Alpha', 'Gamma'];

test('reorders three cards one drag at a time', async ({ page }) => {
  await page.goto('/board');

  const list = page.getByRole('list', { name: 'Backlog' });

  for (const [index, label] of desiredOrder.entries()) {
    const card = list.getByRole('listitem', { name: label });
    const slot = list.getByTestId(`slot-${index}`);

    // test.step names the failing drag in the trace and the HTML report.
    await test.step(`drag ${label} into position ${index}`, async () => {
      await card.dragTo(slot);
      // Assert the invariant after every move, not just at the end.
      await expect(list.getByRole('listitem').nth(index)).toHaveText(label);
    });
  }

  // Array form checks the full order in one retrying assertion.
  await expect(list.getByRole('listitem')).toHaveText(desiredOrder);
});

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');
});

Register the listener promise before the drag, as above; a listener attached afterwards misses the one-shot event and the test hangs until the timeout with no useful message. When the payload is JSON rather than plain text, parse it in Node rather than in the page so a malformed string produces a readable diff instead of an opaque undefined. 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.

Keyboard-driven drag as a pointer-free path

Accessible drag implementations expose a second, entirely pointer-free route to the same state change: focus the handle, press Space to grab, move with the arrow keys, press Space again to commit or Escape to cancel. This route is worth testing for its own sake — it is a WCAG requirement that a drag operation has a single-pointer or keyboard alternative — and it is also the most stable regression test you can write for reordering logic, because it involves no coordinates, no thresholds, and no animation timing.

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

test('reorders the backlog with the keyboard only', async ({ page }) => {
  await page.goto('/backlog');

  const list = page.getByRole('list', { name: 'Backlog' });
  const handle = page.getByRole('button', { name: 'Reorder Fix login' });

  await handle.focus();
  await page.keyboard.press('Space');            // enter grabbed mode

  // Accessible implementations announce state through a live region.
  await expect(page.getByRole('status')).toContainText('grabbed');

  await page.keyboard.press('ArrowDown');
  await expect(page.getByRole('status')).toContainText('position 2 of 3');

  await page.keyboard.press('Space');            // commit the move
  await expect(page.getByRole('status')).toContainText('dropped');

  // The same invariant the pointer test asserts, reached without a pointer.
  await expect(list.getByRole('listitem').nth(1)).toContainText('Fix login');
});

Test the cancellation branch too: grab, arrow once, press Escape, and assert the original order is intact and focus has returned to the handle. Cancellation is where accessible drag implementations most often leak state — an item left visually displaced, or focus dumped onto <body> — and a two-line assertion catches both.

Keyboard drag state machine A state machine moving from idle to grabbed to moving, then branching to a committed drop or an escape that returns the item to its origin. A pointer-free path through the same reorder logic idle grabbed moving dropped cancelled Space Arrows Space commits Escape order restored and focus returns to the handle a live region announces every transition
Each transition is announced through a live region, which gives the keyboard test deterministic checkpoints the pointer test does not have.

Failure modes and debugging

Most drag failures reduce to one of five symptoms, and each has a distinct diagnosis.

The action passes but nothing moves. The transport reached the DOM and the library ignored it, almost always a protocol mismatch: pointer events against a native HTML5 target, or a dispatched drop against a pointer-based library. Confirm with the event recorder below.

The action times out on the source. Actionability failed before any pointer event fired. The source is covered, still animating, or resolved to a hidden duplicate. trial: true isolates this in one run.

The action times out on the target. The target scrolled out of view when the source was lifted — common in long lists where removing the dragged item shifts everything up. Scroll the target into view first, or drag onto a stable container rather than a specific sibling.

It works headed, fails headless. Timing, not rendering. A headed run is slower and accidentally supplies the dwell the library needs. Add the explicit dwell rather than a waitForTimeout().

It works locally, fails in CI on one engine. Engine-specific dwell and momentum differences, covered under cross-browser behaviour below.

The event recorder settles the first case in one run by showing precisely which events the target received:

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

test('records which drag events the drop target receives', async ({ page }) => {
  await page.goto('/board');

  const seen: string[] = [];

  // The binding must exist before the listeners reference it.
  await page.exposeFunction('recordDragEvent', (name: string) => { seen.push(name); });

  await page.evaluate(() => {
    const zone = document.querySelector('#bin')!;
    const names = ['pointerdown', 'dragstart', 'dragenter', 'dragover', 'drop', 'pointerup'];
    for (const name of names) {
      // Capture phase, so a stopPropagation() in the app still shows up here.
      zone.addEventListener(name, () => (window as never as Record<string, (n: string) => void>)
        .recordDragEvent(name), { capture: true });
    }
  });

  await page.locator('#token').dragTo(page.locator('#bin'));

  // expect.poll retries while the event list fills in asynchronously.
  await expect.poll(() => seen).toContain('drop');
  console.log(seen.join(' → '));  // e.g. pointerdown → dragenter → dragover → drop
});

An empty list means the events never reached the element — check the selector and any overlay. A list containing pointerdown but no dragstart means you are looking at a native HTML5 target that needs the explicit event dispatch described in the deep dive. A list ending at dragover with no drop means the handler called preventDefault() on the wrong event, or rejected the drag's data types.

Beyond the recorder, the Playwright Trace Viewer is the highest-yield tool here: its action snapshots give you the DOM immediately before and after the drag, so you can see whether the drag preview existed, where the pointer actually was, and what the target's classes were mid-flight. Recording video on failure, as described in Capturing Screenshots and Video on Test Failure, pays for itself on drag suites specifically, because a five-frame clip answers questions a stack trace never will.

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, and the explicit dwell before release matters most on WebKit, where the drag manager coalesces dragover more aggressively.

WebKit also declines to start a native HTML5 drag from a synthesized pointer press in some configurations, which is why a suite that is green on Chromium can fail wholesale on WebKit with no other change. When that happens, the reliable fix is not more mouse code but the explicit event dispatch path. The engine differences themselves are catalogued in Running Chromium vs Firefox vs WebKit in Playwright. 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.

CI/CD considerations

Drag suites are disproportionately sensitive to the environment, and three configuration choices remove most of that sensitivity before a single test runs.

Pin the viewport. Every bounding box, every targetPosition, and every threshold depends on layout, and a machine-dependent viewport turns a deterministic drag into a coin flip on a container that wraps at one width and not another. Disable animation where the framework honours it: reducedMotion: 'reduce' causes well-built libraries to skip FLIP transitions entirely, which removes the largest source of post-drop timing races. And retain traces on failure, because a drag failure is almost never diagnosable from the error message alone.

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  retries: process.env.CI ? 2 : 0,
  use: {
    ...devices['Desktop Chrome'],
    // Fixed geometry keeps boundingBox arithmetic reproducible across machines.
    viewport: { width: 1280, height: 900 },
    // Libraries that honour the media query skip their drop transitions.
    reducedMotion: 'reduce',
    trace: 'retain-on-failure',
    video: 'retain-on-failure',
  },
  // Drag assertions retry against animations, so give them room above the default.
  expect: { timeout: 7_000 },
});

Resist the temptation to raise retries for drag specs alone. A retried drag test hides exactly the class of bug users hit — the reorder that fails once in twenty attempts — so treat a drag test that only passes on retry as a defect report and triage it with the approach in Flaky Test Management. Where drag specs are genuinely slow, isolate them into their own project rather than inflating global timeouts, and shard them like any other slow suite using the patterns in CI/CD Integration.

Finally, put the drag itself behind a page object method rather than repeating pointer arithmetic across specs. board.moveCard('Ship release', 'Done') gives you one place to change when the component library is upgraded and its event protocol shifts, which is the single highest-leverage refactor in a mature drag suite; the structure to hang it on is in Page Object Model Design.

Deep dives in this guide

Simulating HTML5 Drag and Drop in Playwright walks through constructing a DataTransfer object in the page, dispatching dragstart, dragover, and drop in the correct order, and verifying the payload — the path to take whenever the event recorder shows a native HTML5 target that dragTo() alone cannot arm.

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.

How many steps should I pass to page.mouse.move()?

Between ten and twenty for almost every case. One step teleports the pointer and fails any library that requires a minimum travel distance before it arms a drag. Above roughly twenty-five the extra samples add wall-clock time to every run without changing the outcome, so treat the higher end as a diagnostic setting rather than a default.

Why does a drag pass headed but fail headless in CI?

A headed run is slower, so it accidentally supplies the dwell time the library needed before committing. The fix is to make that dwell explicit—repeat the move at the destination coordinates before releasing—rather than to add a fixed sleep, which papers over the timing on a fast machine and still fails on a loaded CI worker.

Can I verify reordering without simulating a pointer at all?

Yes, if the component ships the accessible keyboard alternative, which any WCAG-conformant implementation must. Focusing the handle and driving the reorder with Space and the arrow keys exercises the same application logic with none of the coordinate arithmetic or animation timing, which makes it the most stable regression test available for the ordering rules themselves.

Back to overview