Drag and Drop Recipes
When to use: You need to test drag-and-drop interactions -- sortable lists, kanban boards, file drop zones, or any element that can be repositioned by dragging.
Recipe 1: Native HTML5 Drag and Drop
Complete Example
TypeScript
import { test, expect } from "@playwright/test"
test("drags an item from one container to another", async ({ page }) => {
await page.goto("/drag-demo")
const sourceItem = page.getByText("Draggable Item")
const dropZone = page.locator("#drop-zone")
// Verify initial state
await expect(sourceItem).toBeVisible()
await expect(dropZone).not.toContainText("Draggable Item")
// Perform drag and drop
await sourceItem.dragTo(dropZone)
// Verify the item moved to the drop zone
await expect(dropZone).toContainText("Draggable Item")
})
test("drags between two drop zones using locators", async ({ page }) => {
await page.goto("/drag-demo")
const item = page.locator('[data-testid="item-1"]')
const zoneA = page.locator('[data-testid="zone-a"]')
const zoneB = page.locator('[data-testid="zone-b"]')
// Item starts in zone A
await expect(zoneA).toContainText("Item 1")
// Drag to zone B
await item.dragTo(zoneB)
// Item is now in zone B, not zone A
await expect(zoneB).toContainText("Item 1")
await expect(zoneA).not.toContainText("Item 1")
// Drag back to zone A
await zoneB.getByText("Item 1").dragTo(zoneA)
await expect(zoneA).toContainText("Item 1")
await expect(zoneB).not.toContainText("Item 1")
})
test("verifies drag visual feedback", async ({ page }) => {
await page.goto("/drag-demo")
const sourceItem = page.getByText("Draggable Item")
const dropZone = page.locator("#drop-zone")
// Start drag manually to check intermediate states
await sourceItem.hover()
await page.mouse.down()
// Move toward the drop zone
const dropBox = await dropZone.boundingBox()
await page.mouse.move(dropBox!.x + dropBox!.width / 2, dropBox!.y + dropBox!.height / 2)
// Verify the drop zone shows a visual indicator while hovering
await expect(dropZone).toHaveClass(/drag-over|highlight/)
// Complete the drop
await page.mouse.up()
// Verify drop zone returns to normal styling
await expect(dropZone).not.toHaveClass(/drag-over|highlight/)
await expect(dropZone).toContainText("Draggable Item")
})JavaScript
const { test, expect } = require("@playwright/test")
test("drags an item from one container to another", async ({ page }) => {
await page.goto("/drag-demo")
const sourceItem = page.getByText("Draggable Item")
const dropZone = page.locator("#drop-zone")
await expect(sourceItem).toBeVisible()
await expect(dropZone).not.toContainText("Draggable Item")
await sourceItem.dragTo(dropZone)
await expect(dropZone).toContainText("Draggable Item")
})
test("drags between two drop zones", async ({ page }) => {
await page.goto("/drag-demo")
const item = page.locator('[data-testid="item-1"]')
const zoneA = page.locator('[data-testid="zone-a"]')
const zoneB = page.locator('[data-testid="zone-b"]')
await expect(zoneA).toContainText("Item 1")
await item.dragTo(zoneB)
await expect(zoneB).toContainText("Item 1")
await expect(zoneA).not.toContainText("Item 1")
})Recipe 2: Sortable Lists (Reordering Items)
Complete Example
TypeScript
import { test, expect } from "@playwright/test"
test("reorders items in a sortable list", async ({ page }) => {
await page.goto("/tasks")
const list = page.getByRole("list", { name: "Task list" })
// Verify initial order
const initialItems = await list.getByRole("listitem").allTextContents()
expect(initialItems[0]).toContain("Task A")
expect(initialItems[1]).toContain("Task B")
expect(initialItems[2]).toContain("Task C")
// Drag Task C to the top (above Task A)
const taskC = list.getByRole("listitem").filter({ hasText: "Task C" })
const taskA = list.getByRole("listitem").filter({ hasText: "Task A" })
await taskC.dragTo(taskA)
// Verify new order
const reorderedItems = await list.getByRole("listitem").allTextContents()
expect(reorderedItems[0]).toContain("Task C")
expect(reorderedItems[1]).toContain("Task A")
expect(reorderedItems[2]).toContain("Task B")
})
test("reorders using drag handle", async ({ page }) => {
await page.goto("/tasks")
const list = page.getByRole("list", { name: "Task list" })
// Use the drag handle (grip icon) instead of the whole item
const dragHandle = list
.getByRole("listitem")
.filter({ hasText: "Task C" })
.getByRole("button", { name: /drag|reorder|grip/i })
const targetItem = list.getByRole("listitem").filter({ hasText: "Task A" })
await dragHandle.dragTo(targetItem)
const reorderedItems = await list.getByRole("listitem").allTextContents()
expect(reorderedItems[0]).toContain("Task C")
})
test("reorder persists after page reload", async ({ page }) => {
await page.goto("/tasks")
const list = page.getByRole("list", { name: "Task list" })
const taskC = list.getByRole("listitem").filter({ hasText: "Task C" })
const taskA = list.getByRole("listitem").filter({ hasText: "Task A" })
await taskC.dragTo(taskA)
// Wait for the save API call to complete
await page.waitForResponse(
(response) => response.url().includes("/api/tasks/reorder") && response.status() === 200
)
// Reload and verify persistence
await page.reload()
const items = await list.getByRole("listitem").allTextContents()
expect(items[0]).toContain("Task C")
expect(items[1]).toContain("Task A")
expect(items[2]).toContain("Task B")
})
test("reorders with precise mouse movements for libraries that need it", async ({ page }) => {
await page.goto("/tasks")
const list = page.getByRole("list", { name: "Task list" })
const sourceItem = list.getByRole("listitem").filter({ hasText: "Task C" })
const targetItem = list.getByRole("listitem").filter({ hasText: "Task A" })
const sourceBox = await sourceItem.boundingBox()
const targetBox = await targetItem.boundingBox()
// Some drag libraries (react-beautiful-dnd, dnd-kit) require specific mouse event sequences
await sourceItem.hover()
await page.mouse.down()
// Move in small steps -- some libraries require incremental movement to register
const steps = 10
for (let i = 1; i <= steps; i++) {
await page.mouse.move(
sourceBox!.x + sourceBox!.width / 2,
sourceBox!.y + (targetBox!.y - sourceBox!.y) * (i / steps),
{ steps: 1 }
)
}
await page.mouse.up()
const items = await list.getByRole("listitem").allTextContents()
expect(items[0]).toContain("Task C")
})JavaScript
const { test, expect } = require("@playwright/test")
test("reorders items in a sortable list", async ({ page }) => {
await page.goto("/tasks")
const list = page.getByRole("list", { name: "Task list" })
const initialItems = await list.getByRole("listitem").allTextContents()
expect(initialItems[0]).toContain("Task A")
expect(initialItems[1]).toContain("Task B")
expect(initialItems[2]).toContain("Task C")
const taskC = list.getByRole("listitem").filter({ hasText: "Task C" })
const taskA = list.getByRole("listitem").filter({ hasText: "Task A" })
await taskC.dragTo(taskA)
const reorderedItems = await list.getByRole("listitem").allTextContents()
expect(reorderedItems[0]).toContain("Task C")
expect(reorderedItems[1]).toContain("Task A")
expect(reorderedItems[2]).toContain("Task B")
})
test("reorders with precise mouse movements", async ({ page }) => {
await page.goto("/tasks")
const list = page.getByRole("list", { name: "Task list" })
const sourceItem = list.getByRole("listitem").filter({ hasText: "Task C" })
const targetItem = list.getByRole("listitem").filter({ hasText: "Task A" })
const sourceBox = await sourceItem.boundingBox()
const targetBox = await targetItem.boundingBox()
await sourceItem.hover()
await page.mouse.down()
const steps = 10
for (let i = 1; i <= steps; i++) {
await page.mouse.move(
sourceBox.x + sourceBox.width / 2,
sourceBox.y + (targetBox.y - sourceBox.y) * (i / steps),
{ steps: 1 }
)
}
await page.mouse.up()
const items = await list.getByRole("listitem").allTextContents()
expect(items[0]).toContain("Task C")
})Recipe 3: Kanban Board (Moving Between Columns)
Complete Example
TypeScript
import { test, expect } from "@playwright/test"
test("moves a card from Todo to In Progress", async ({ page }) => {
await page.goto("/board")
const todoColumn = page.locator('[data-column="todo"]')
const inProgressColumn = page.locator('[data-column="in-progress"]')
// Verify initial state
const card = todoColumn.getByText("Fix login bug")
await expect(card).toBeVisible()
const todoCountBefore = await todoColumn.getByRole("article").count()
const inProgressCountBefore = await inProgressColumn.getByRole("article").count()
// Drag the card to In Progress
await card.dragTo(inProgressColumn)
// Verify the card moved
await expect(inProgressColumn.getByText("Fix login bug")).toBeVisible()
await expect(todoColumn.getByText("Fix login bug")).not.toBeVisible()
// Verify counts updated
await expect(todoColumn.getByRole("article")).toHaveCount(todoCountBefore - 1)
await expect(inProgressColumn.getByRole("article")).toHaveCount(inProgressCountBefore + 1)
// Verify column header count badge updated
await expect(todoColumn.getByText(`(${todoCountBefore - 1})`)).toBeVisible()
await expect(inProgressColumn.getByText(`(${inProgressCountBefore + 1})`)).toBeVisible()
})
test("moves a card through all stages", async ({ page }) => {
await page.goto("/board")
const columns = {
todo: page.locator('[data-column="todo"]'),
inProgress: page.locator('[data-column="in-progress"]'),
review: page.locator('[data-column="review"]'),
done: page.locator('[data-column="done"]'),
}
// Todo -> In Progress
await columns.todo.getByText("Fix login bug").dragTo(columns.inProgress)
await expect(columns.inProgress.getByText("Fix login bug")).toBeVisible()
// In Progress -> Review
await columns.inProgress.getByText("Fix login bug").dragTo(columns.review)
await expect(columns.review.getByText("Fix login bug")).toBeVisible()
// Review -> Done
await columns.review.getByText("Fix login bug").dragTo(columns.done)
await expect(columns.done.getByText("Fix login bug")).toBeVisible()
// Verify the card is only in the Done column
await expect(columns.todo.getByText("Fix login bug")).not.toBeVisible()
await expect(columns.inProgress.getByText("Fix login bug")).not.toBeVisible()
await expect(columns.review.getByText("Fix login bug")).not.toBeVisible()
})
test("reorders cards within the same column", async ({ page }) => {
await page.goto("/board")
const todoColumn = page.locator('[data-column="todo"]')
const cardA = todoColumn.getByRole("article").filter({ hasText: "Task A" })
const cardC = todoColumn.getByRole("article").filter({ hasText: "Task C" })
// Drag Task C above Task A within the same column
await cardC.dragTo(cardA)
// Verify new order within the column
const cards = await todoColumn.getByRole("article").allTextContents()
expect(cards.indexOf("Task C")).toBeLessThan(cards.indexOf("Task A"))
})
test("kanban board state persists via API", async ({ page }) => {
await page.goto("/board")
const todoColumn = page.locator('[data-column="todo"]')
const inProgressColumn = page.locator('[data-column="in-progress"]')
// Wait for the PATCH/PUT to complete after the drag
const responsePromise = page.waitForResponse(
(r) => r.url().includes("/api/cards") && r.request().method() === "PATCH"
)
await todoColumn.getByText("Fix login bug").dragTo(inProgressColumn)
const response = await responsePromise
expect(response.status()).toBe(200)
const body = await response.json()
expect(body.column).toBe("in-progress")
// Reload to confirm persistence
await page.reload()
await expect(inProgressColumn.getByText("Fix login bug")).toBeVisible()
})JavaScript
const { test, expect } = require("@playwright/test")
test("moves a card from Todo to In Progress", async ({ page }) => {
await page.goto("/board")
const todoColumn = page.locator('[data-column="todo"]')
const inProgressColumn = page.locator('[data-column="in-progress"]')
const card = todoColumn.getByText("Fix login bug")
await expect(card).toBeVisible()
await card.dragTo(inProgressColumn)
await expect(inProgressColumn.getByText("Fix login bug")).toBeVisible()
await expect(todoColumn.getByText("Fix login bug")).not.toBeVisible()
})
test("moves a card through all stages", async ({ page }) => {
await page.goto("/board")
const columns = {
todo: page.locator('[data-column="todo"]'),
inProgress: page.locator('[data-column="in-progress"]'),
review: page.locator('[data-column="review"]'),
done: page.locator('[data-column="done"]'),
}
await columns.todo.getByText("Fix login bug").dragTo(columns.inProgress)
await expect(columns.inProgress.getByText("Fix login bug")).toBeVisible()
await columns.inProgress.getByText("Fix login bug").dragTo(columns.review)
await expect(columns.review.getByText("Fix login bug")).toBeVisible()
await columns.review.getByText("Fix login bug").dragTo(columns.done)
await expect(columns.done.getByText("Fix login bug")).toBeVisible()
})Recipe 4: File Drop Zone
Complete Example
TypeScript
import { test, expect } from "@playwright/test"
import path from "path"
test("uploads a file by dropping it on the drop zone", async ({ page }) => {
await page.goto("/upload")
const dropZone = page.locator('[data-testid="file-drop-zone"]')
// Verify initial state
await expect(dropZone).toContainText("Drag files here")
// Create a DataTransfer-like event using the file chooser approach
// Since Playwright cannot simulate native DnD file events from the OS,
// we use the underlying input[type=file] that drop zones rely on
const fileInput = page.locator('input[type="file"]')
await fileInput.setInputFiles(path.resolve(__dirname, "../fixtures/sample-document.pdf"))
// Verify file appears in the upload list
await expect(page.getByText("sample-document.pdf")).toBeVisible()
await expect(page.getByText(/\d+ KB/)).toBeVisible()
})
test("simulates drag-over visual feedback via JavaScript dispatch", async ({ page }) => {
await page.goto("/upload")
const dropZone = page.locator('[data-testid="file-drop-zone"]')
// Dispatch dragenter/dragover events to test visual feedback
await dropZone.dispatchEvent("dragenter", {
dataTransfer: { types: ["Files"] },
})
// Verify the drop zone shows the active/hover state
await expect(dropZone).toHaveClass(/drag-active|drop-highlight/)
await expect(dropZone).toContainText(/drop.*here|release.*upload/i)
// Dispatch dragleave to reset
await dropZone.dispatchEvent("dragleave")
await expect(dropZone).not.toHaveClass(/drag-active|drop-highlight/)
})
test("rejects invalid file types in drop zone", async ({ page }) => {
await page.goto("/upload")
const fileInput = page.locator('input[type="file"]')
// Try to upload a file type that is not allowed
await fileInput.setInputFiles({
name: "malware.exe",
mimeType: "application/x-msdownload",
buffer: Buffer.from("fake-content"),
})
await expect(page.getByRole("alert")).toContainText(/not allowed|invalid file type/i)
await expect(page.getByText("malware.exe")).not.toBeVisible()
})JavaScript
const { test, expect } = require("@playwright/test")
const path = require("path")
test("uploads a file by dropping it on the drop zone", async ({ page }) => {
await page.goto("/upload")
const dropZone = page.locator('[data-testid="file-drop-zone"]')
await expect(dropZone).toContainText("Drag files here")
const fileInput = page.locator('input[type="file"]')
await fileInput.setInputFiles(path.resolve(__dirname, "../fixtures/sample-document.pdf"))
await expect(page.getByText("sample-document.pdf")).toBeVisible()
})
test("rejects invalid file types in drop zone", async ({ page }) => {
await page.goto("/upload")
const fileInput = page.locator('input[type="file"]')
await fileInput.setInputFiles({
name: "malware.exe",
mimeType: "application/x-msdownload",
buffer: Buffer.from("fake-content"),
})
await expect(page.getByRole("alert")).toContainText(/not allowed|invalid file type/i)
})Recipe 5: Drag with Custom Preview / Ghost Image
Complete Example
TypeScript
import { test, expect } from "@playwright/test"
test("shows custom drag preview during drag operation", async ({ page }) => {
await page.goto("/board")
const card = page.locator('[data-testid="card-1"]')
const targetColumn = page.locator('[data-column="in-progress"]')
const cardBox = await card.boundingBox()
const targetBox = await targetColumn.boundingBox()
// Start dragging
await card.hover()
await page.mouse.down()
// Move partially toward the target
const midX = (cardBox!.x + targetBox!.x) / 2
const midY = (cardBox!.y + targetBox!.y) / 2
await page.mouse.move(midX, midY, { steps: 5 })
// Take a screenshot to visually verify the drag preview
// The custom preview element should be visible during drag
await expect(page.locator(".drag-preview")).toBeVisible()
// Verify the original card shows a placeholder/ghost
await expect(card).toHaveClass(/dragging|placeholder/)
// Complete the drag
await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + targetBox!.height / 2, {
steps: 5,
})
await page.mouse.up()
// Preview should disappear after drop
await expect(page.locator(".drag-preview")).not.toBeVisible()
})
test("drag preview shows item count for multi-select drag", async ({ page }) => {
await page.goto("/board")
// Select multiple cards
await page.locator('[data-testid="card-1"]').click()
await page.locator('[data-testid="card-2"]').click({ modifiers: ["Shift"] })
await page.locator('[data-testid="card-3"]').click({ modifiers: ["Shift"] })
// Start dragging one of the selected cards
const card = page.locator('[data-testid="card-1"]')
const targetColumn = page.locator('[data-column="done"]')
await card.hover()
await page.mouse.down()
const targetBox = await targetColumn.boundingBox()
await page.mouse.move(targetBox!.x + 50, targetBox!.y + 50, { steps: 5 })
// Verify the preview shows the count of selected items
await expect(page.locator(".drag-preview")).toContainText("3 items")
await page.mouse.up()
// All three cards should be in the target column
await expect(targetColumn.locator('[data-testid="card-1"]')).toBeVisible()
await expect(targetColumn.locator('[data-testid="card-2"]')).toBeVisible()
await expect(targetColumn.locator('[data-testid="card-3"]')).toBeVisible()
})JavaScript
const { test, expect } = require("@playwright/test")
test("shows custom drag preview during drag operation", async ({ page }) => {
await page.goto("/board")
const card = page.locator('[data-testid="card-1"]')
const targetColumn = page.locator('[data-column="in-progress"]')
const cardBox = await card.boundingBox()
const targetBox = await targetColumn.boundingBox()
await card.hover()
await page.mouse.down()
const midX = (cardBox.x + targetBox.x) / 2
const midY = (cardBox.y + targetBox.y) / 2
await page.mouse.move(midX, midY, { steps: 5 })
await expect(page.locator(".drag-preview")).toBeVisible()
await expect(card).toHaveClass(/dragging|placeholder/)
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, {
steps: 5,
})
await page.mouse.up()
await expect(page.locator(".drag-preview")).not.toBeVisible()
})Recipe 6: Testing Drag Position and Coordinates
Complete Example
TypeScript
import { test, expect } from "@playwright/test"
test("drags an element to a specific coordinate on a canvas", async ({ page }) => {
await page.goto("/canvas-editor")
const canvas = page.locator("#design-canvas")
const element = page.locator('[data-testid="shape-1"]')
// Get the initial position
const initialBox = await element.boundingBox()
expect(initialBox).toBeTruthy()
// Define target position (absolute coordinates within the canvas)
const canvasBox = await canvas.boundingBox()
const targetX = canvasBox!.x + 300
const targetY = canvasBox!.y + 200
// Drag to specific coordinates
await element.hover()
await page.mouse.down()
await page.mouse.move(targetX, targetY, { steps: 10 })
await page.mouse.up()
// Verify element moved to approximately the right position
const newBox = await element.boundingBox()
expect(newBox!.x).toBeCloseTo(targetX - newBox!.width / 2, -1)
expect(newBox!.y).toBeCloseTo(targetY - newBox!.height / 2, -1)
})
test("snaps element to grid when dropped", async ({ page }) => {
await page.goto("/canvas-editor")
const element = page.locator('[data-testid="shape-1"]')
const canvas = page.locator("#design-canvas")
const canvasBox = await canvas.boundingBox()
// Drag to a position that is not on the grid
await element.hover()
await page.mouse.down()
await page.mouse.move(canvasBox!.x + 147, canvasBox!.y + 83, { steps: 10 })
await page.mouse.up()
// With a 20px grid, position should snap to nearest grid point
const snappedBox = await element.boundingBox()
expect(snappedBox!.x % 20).toBeCloseTo(0, 0)
expect(snappedBox!.y % 20).toBeCloseTo(0, 0)
})
test("constrains drag within boundaries", async ({ page }) => {
await page.goto("/canvas-editor")
const element = page.locator('[data-testid="constrained-element"]')
const container = page.locator("#constraint-box")
const containerBox = await container.boundingBox()
// Try to drag far outside the container
await element.hover()
await page.mouse.down()
await page.mouse.move(containerBox!.x + containerBox!.width + 500, containerBox!.y - 200, {
steps: 10,
})
await page.mouse.up()
// Element should be clamped within the container
const elementBox = await element.boundingBox()
expect(elementBox!.x).toBeGreaterThanOrEqual(containerBox!.x)
expect(elementBox!.y).toBeGreaterThanOrEqual(containerBox!.y)
expect(elementBox!.x + elementBox!.width).toBeLessThanOrEqual(
containerBox!.x + containerBox!.width
)
expect(elementBox!.y + elementBox!.height).toBeLessThanOrEqual(
containerBox!.y + containerBox!.height
)
})
test("resizes an element by dragging its handle", async ({ page }) => {
await page.goto("/canvas-editor")
const element = page.locator('[data-testid="shape-1"]')
await element.click() // Select to show resize handles
const resizeHandle = element.locator(".resize-handle-se") // south-east corner
const handleBox = await resizeHandle.boundingBox()
const initialBox = await element.boundingBox()
// Drag the resize handle to make the element larger
await resizeHandle.hover()
await page.mouse.down()
await page.mouse.move(handleBox!.x + 100, handleBox!.y + 80, { steps: 5 })
await page.mouse.up()
const newBox = await element.boundingBox()
expect(newBox!.width).toBeCloseTo(initialBox!.width + 100, -1)
expect(newBox!.height).toBeCloseTo(initialBox!.height + 80, -1)
})JavaScript
const { test, expect } = require("@playwright/test")
test("drags an element to a specific coordinate on a canvas", async ({ page }) => {
await page.goto("/canvas-editor")
const canvas = page.locator("#design-canvas")
const element = page.locator('[data-testid="shape-1"]')
const canvasBox = await canvas.boundingBox()
const targetX = canvasBox.x + 300
const targetY = canvasBox.y + 200
await element.hover()
await page.mouse.down()
await page.mouse.move(targetX, targetY, { steps: 10 })
await page.mouse.up()
const newBox = await element.boundingBox()
expect(newBox.x).toBeCloseTo(targetX - newBox.width / 2, -1)
expect(newBox.y).toBeCloseTo(targetY - newBox.height / 2, -1)
})
test("constrains drag within boundaries", async ({ page }) => {
await page.goto("/canvas-editor")
const element = page.locator('[data-testid="constrained-element"]')
const container = page.locator("#constraint-box")
const containerBox = await container.boundingBox()
await element.hover()
await page.mouse.down()
await page.mouse.move(containerBox.x + containerBox.width + 500, containerBox.y - 200, {
steps: 10,
})
await page.mouse.up()
const elementBox = await element.boundingBox()
expect(elementBox.x).toBeGreaterThanOrEqual(containerBox.x)
expect(elementBox.y).toBeGreaterThanOrEqual(containerBox.y)
})Variations
Drag and Drop with Keyboard Accessibility
test("reorders items using keyboard", async ({ page }) => {
await page.goto("/tasks")
const list = page.getByRole("list", { name: "Task list" })
const taskC = list.getByRole("listitem").filter({ hasText: "Task C" })
// Focus the item
await taskC.focus()
// Use keyboard shortcut to pick up the item
await page.keyboard.press("Space")
// Move up twice
await page.keyboard.press("ArrowUp")
await page.keyboard.press("ArrowUp")
// Drop the item
await page.keyboard.press("Space")
const items = await list.getByRole("listitem").allTextContents()
expect(items[0]).toContain("Task C")
})Drag and Drop Between iframes
test("drags between main page and iframe", async ({ page }) => {
await page.goto("/editor")
const sourceItem = page.getByText("Widget A")
const iframe = page.frameLocator("#preview-frame")
const dropTarget = iframe.locator("#content-area")
// Cross-frame drag requires coordinates since dragTo does not work across frames
const sourceBox = await sourceItem.boundingBox()
const iframeElement = page.locator("#preview-frame")
const iframeBox = await iframeElement.boundingBox()
// Calculate target position within the iframe
const targetX = iframeBox!.x + 100
const targetY = iframeBox!.y + 100
await sourceItem.hover()
await page.mouse.down()
await page.mouse.move(targetX, targetY, { steps: 20 })
await page.mouse.up()
await expect(iframe.getByText("Widget A")).toBeVisible()
})Touch-Based Drag on Mobile
test("drags items on mobile via touch events", async ({ page }) => {
await page.goto("/tasks")
const list = page.getByRole("list", { name: "Task list" })
const sourceItem = list.getByRole("listitem").filter({ hasText: "Task C" })
const targetItem = list.getByRole("listitem").filter({ hasText: "Task A" })
const sourceBox = await sourceItem.boundingBox()
const targetBox = await targetItem.boundingBox()
// Simulate long-press then drag using touch
await page.touchscreen.tap(
sourceBox!.x + sourceBox!.width / 2,
sourceBox!.y + sourceBox!.height / 2
)
// Dispatch touchstart, touchmove, touchend for libraries that use touch events
await sourceItem.dispatchEvent("touchstart", {
touches: [{ clientX: sourceBox!.x + 10, clientY: sourceBox!.y + 10 }],
})
// Move in steps
for (let i = 1; i <= 5; i++) {
const y = sourceBox!.y + (targetBox!.y - sourceBox!.y) * (i / 5)
await sourceItem.dispatchEvent("touchmove", {
touches: [{ clientX: sourceBox!.x + 10, clientY: y }],
})
}
await sourceItem.dispatchEvent("touchend")
const items = await list.getByRole("listitem").allTextContents()
expect(items[0]).toContain("Task C")
})Tips
Use
dragTo()first, then fall back to manual mouse events. Playwright's built-indragTo()handles most native HTML5 drag and drop. Only usepage.mouse.down()/move()/up()sequences for custom drag libraries (react-beautiful-dnd, dnd-kit, SortableJS) that need specific event sequences.Add intermediate mouse move steps for drag libraries. Libraries like
react-beautiful-dndrequire multiplemousemoveevents with small increments to detect a drag. Use\{ steps: 10 \}or a manual loop. A single jump from source to target often fails silently.Always assert the final state, not just the drop event. After a drag-and-drop, verify that the DOM actually reflects the change -- item order, column contents, position coordinates. Visual feedback during drag is nice to test but the final persisted state matters most.
Use
boundingBox()for coordinate-based assertions. When testing canvas editors, grid layouts, or position-sensitive drops, capture the bounding box after the operation and compare against expected coordinates. UsetoBeCloseTo()for tolerance.Test undo after drag operations. If your app supports Ctrl+Z to undo a reorder or move, test that the drag operation is reversible. This catches state management bugs that only appear on undo.
Related
- Playwright Drag and Drop Docs
recipes/file-upload-download.md-- File drop zones specificallyfoundations/actions.md-- Mouse and keyboard interaction basicspatterns/page-objects.md-- Encapsulate complex drag flows