Opening Hook
Drag-and-drop interactions are everywhere in modern web applications—from Kanban boards where users organize tasks, to sortable lists in admin panels, to file uploads. Yet testing drag and drop in Playwright & Browser Automation remains tricky for many teams. The challenge isn't just simulating the mouse movements; it's handling animations, timing, and validating the final order in a way that's reliable and maintainable.
Why Drag and Drop Testing Matters
We've all seen a Kanban board freeze mid-drag or a sortable list appear to sort but fail to persist. In production, drag-and-drop bugs often slip through because manual testing catches them easily, but automated tests don't. As browser automation becomes the backbone of our regression suites, we need to treat drag-and-drop interactions as first-class citizens. Whether you're testing a project management tool, an e-commerce checkout flow with draggable items, or an admin dashboard with reorderable columns, mastering this technique saves your team from shipping broken UX.
Using locator.dragTo(target)
Playwright provides a straightforward API for drag-and-drop: locator.dragTo(target). This method handles the entire drag sequence—mousedown, mousemove, and mouseup—in a single call. It's the simplest way to automate drag and drop for most real-world scenarios.
Consider a Kanban board with tasks organized by status. A task lives in a "To Do" column and must move to "In Progress." Here's how we'd test it:
import { test, expect } from '@playwright/test';
test('move task from To Do to In Progress', async ({ page }) => {
await page.goto('http://localhost:3000/kanban');
// Wait for the board to load
await page.waitForSelector('[data-testid="task-card"]');
// Get the first task in the "To Do" column
const taskCard = page.locator('[data-testid="task-card"]').first();
// Get the "In Progress" column drop target
const inProgressColumn = page.locator('[data-testid="column-in-progress"]');
// Perform the drag and drop
await taskCard.dragTo(inProgressColumn);
// Wait a moment for animations to settle
await page.waitForTimeout(500);
// Assert the task is now in the In Progress column
const taskInProgress = inProgressColumn.locator('[data-testid="task-card"]', { has: taskCard });
await expect(taskInProgress).toBeVisible();
});
The dragTo() method is reliable for most UI libraries: React Beautiful DnD, Dnd Kit, Sortable.js, and even custom implementations. Playwright internally uses the WebDriver spec for drag and drop, which most modern frameworks respect.
One caveat: some frameworks require specific mouse events or custom data attributes. If dragTo() doesn't work, the issue is usually that the library expects additional event details (like dataTransfer) that pure mouse events don't provide. That's where manual mouse steps come in.
Manual Mouse Steps for Custom Libraries
Not every drag-and-drop library responds to Playwright's built-in dragTo(). Some older implementations or highly custom solutions require us to orchestrate the mouse events ourselves: mousedown, moveTo, and mouseup. This gives us fine-grained control over timing and event sequencing.
Imagine your team built a custom list reordering widget that relies on specific mouse event coordinates and timing. Here's how we'd test it with manual mouse steps:
test('reorder items in custom sortable list', async ({ page }) => {
await page.goto('http://localhost:3000/sortable-list');
// Get the source and target items
const sourceItem = page.locator('[data-testid="list-item"]').nth(0);
const targetItem = page.locator('[data-testid="list-item"]').nth(2);
// Get bounding boxes for precise coordinates
const sourceBbox = await sourceItem.boundingBox();
const targetBbox = await targetItem.boundingBox();
if (!sourceBbox || !targetBbox) {
throw new Error('Could not get bounding boxes');
}
// Start from the center of the source item
const startX = sourceBbox.x + sourceBbox.width / 2;
const startY = sourceBbox.y + sourceBbox.height / 2;
// End at the center of the target item
const endX = targetBbox.x + targetBbox.width / 2;
const endY = targetBbox.y + targetBbox.height / 2;
// Perform manual drag sequence
await page.mouse.move(startX, startY);
await page.mouse.down();
// Simulate movement with intermediate steps
const steps = 10;
for (let i = 1; i <= steps; i++) {
const currentX = startX + (endX - startX) * (i / steps);
const currentY = startY + (endY - startY) * (i / steps);
await page.mouse.move(currentX, currentY);
await page.waitForTimeout(20); // Small delay between moves
}
await page.mouse.up();
});
This approach gives us control over movement speed, step granularity, and timing. Some libraries detect drag velocity and snap behavior differently based on how fast the mouse moves. By adjusting the steps constant and the delay between moves, we can simulate different drag speeds.
Another consideration: if your custom library uses the HTML5 Drag and Drop API (with dataTransfer), you'll need to inject a custom handler or use a library-specific approach. Playwright can't directly set dataTransfer properties, but you can dispatch events directly from your test:
test('drag and drop with HTML5 API', async ({ page }) => {
await page.goto('http://localhost:3000/drag-drop');
// Dispatch dragstart, dragover, drop, and dragend events manually
await page.evaluate(() => {
const source = document.querySelector('[data-testid="draggable"]');
const target = document.querySelector('[data-testid="droppable"]');
const dragStartEvent = new DragEvent('dragstart', {
bubbles: true,
cancelable: true,
dataTransfer: new DataTransfer(),
});
source?.dispatchEvent(dragStartEvent);
const dragOverEvent = new DragEvent('dragover', {
bubbles: true,
cancelable: true,
});
target?.dispatchEvent(dragOverEvent);
const dropEvent = new DragEvent('drop', {
bubbles: true,
cancelable: true,
dataTransfer: dragStartEvent.dataTransfer,
});
target?.dispatchEvent(dropEvent);
const dragEndEvent = new DragEvent('dragend', {
bubbles: true,
cancelable: true,
});
source?.dispatchEvent(dragEndEvent);
});
});
Use this approach cautiously—it bypasses the browser's native drag-and-drop mechanics. It's a last resort when no other method works, but it does help verify that your application logic responds to the correct events.
Assert Final Order
Dragging the element is only half the battle. We must verify the final state. After a drag operation, the DOM order, the visual position, and any backend state all matter. A common mistake is to assert immediately without waiting for animations or async updates.
Here's a robust approach to asserting final order on a reordered list:
test('verify final order after drag operations', async ({ page }) => {
await page.goto('http://localhost:3000/task-list');
// Perform drag and drop
const firstTask = page.locator('[data-testid="task"]').nth(0);
const secondTask = page.locator('[data-testid="task"]').nth(1);
await firstTask.dragTo(secondTask);
// Wait for animations
await page.waitForTimeout(500);
// Or use a more robust selector-based wait
await page.waitForFunction(() => {
const tasks = Array.from(
document.querySelectorAll('[data-testid="task"]')
);
const firstText = tasks[0]?.textContent;
const secondText = tasks[1]?.textContent;
// Verify the swap actually happened
return firstText === 'Task 2' && secondText === 'Task 1';
});
// Assert using locator array
const tasks = page.locator('[data-testid="task"]');
await expect(tasks.nth(0)).toContainText('Task 2');
await expect(tasks.nth(1)).toContainText('Task 1');
await expect(tasks.nth(2)).toContainText('Task 3');
});
If your application persists order to a backend, add an extra assertion against the API or database state:
test('verify drag and drop persists to backend', async ({ page }) => {
await page.goto('http://localhost:3000/kanban');
// Capture network requests
const responsePromise = page.waitForResponse(
response => response.url().includes('/api/tasks/reorder') && response.status() === 200
);
// Perform drag
const task = page.locator('[data-testid="task"]').first();
const column = page.locator('[data-testid="column-in-progress"]');
await task.dragTo(column);
// Wait for the backend request
const response = await responsePromise;
const body = await response.json();
// Verify the API received the correct update
expect(body.taskId).toBe('task-1');
expect(body.newColumnId).toBe('in-progress');
});
This multi-layered assertion catches UI-only bugs (where the DOM updates but state doesn't) and backend bugs (where the API isn't called or fails silently).
Watch for Animations
Drag-and-drop UIs almost always include animations. A task smoothly slides into its new position, a list item bounces as it reorders, or a column highlights as you hover. These animations add polish but also create timing challenges in automated tests. If we assert before the animation completes, we might see stale DOM state.
There are several strategies to handle animations:
Strategy 1: Wait for Animation Completion
Use CSS animation events or a small timeout. For most modern frameworks, 500ms is a safe bet:
await page.waitForTimeout(500);
// Now assert
Alternatively, listen for animation events:
await page.evaluate(() => {
return new Promise(resolve => {
const element = document.querySelector('[data-testid="task"]');
element?.addEventListener('animationend', resolve, { once: true });
});
});
Strategy 2: Disable Animations During Tests
Many teams inject CSS to disable animations in test mode. This is the most reliable approach:
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:3000/kanban');
// Disable animations
await page.addStyleTag({
content: `
* {
animation-duration: 0s !important;
transition-duration: 0s !important;
}
`,
});
});
This eliminates timing uncertainty. The trade-off is that you're no longer testing animation behavior itself, but that's often a worthwhile trade for test stability.
Strategy 3: Visual Regression Testing
For drag-and-drop UIs where animation is critical, combine functional tests with visual regression checks:
test('drag task and verify visual state', async ({ page }) => {
await page.goto('http://localhost:3000/kanban');
const task = page.locator('[data-testid="task"]').first();
const column = page.locator('[data-testid="column-in-progress"]');
await task.dragTo(column);
// Wait for animation
await page.waitForTimeout(500);
// Capture screenshot
await expect(page).toHaveScreenshot('kanban-task-moved.png');
});
Playwright's visual comparisons catch subtle layout shifts, color changes, and positioning issues that pure functional assertions might miss.
Common Mistakes
- Asserting too early: Not waiting for animations or async updates. Always add a
waitForTimeoutor usewaitForFunctionto poll the DOM state. - Forgetting to wait for elements: Starting a drag before the UI is fully loaded. Use
waitForSelectororwaitForLoadStatebefore dragging. - Using exact pixel coordinates: Viewport sizes vary; always calculate coordinates from bounding boxes rather than hardcoding them.
- Not handling flaky network requests: If drag-and-drop persists to the backend, wait for the network response before asserting, or mock the API in tests.
- Testing animation behavior without actually testing animations: Disabling animations for speed is fine, but you should have at least one visual test that captures the animation in action.
- Ignoring multi-step drags: Some UIs require dragging over intermediate elements (e.g., dragging past column headers). Manual mouse steps handle this better than simple
dragTo().
Key Takeaways
- Use
locator.dragTo(target)as your default—it handles most modern frameworks and is simple to maintain. - Fall back to manual mouse steps (
mousedown,moveTo,mouseup) whendragTo()doesn't work, giving you fine-grained control over timing and coordinates. - Always wait for animations to complete before asserting. Use
waitForTimeout, CSS animation events, or disable animations in test mode. - Assert the final order using DOM queries, visual regression, and (if applicable) backend API responses. Multiple layers of verification catch bugs that a single assertion might miss.
- Calculate drag coordinates from bounding boxes, not hardcoded pixel values, to ensure tests work across different viewport sizes and device types.
- Use
waitForResponseto verify that drag-and-drop operations actually persist to the backend, catching silent failures in the API layer.






Comments
Loading comments…