The most surprising thing about Playwright’s drag and drop is that it doesn’t actually simulate a "drag" action in the way a human does; it’s a precise sequence of coordinate-based mouse events.

Let’s see it in action. Imagine we have a simple Kanban board where we want to move a task from "To Do" to "In Progress."

<!DOCTYPE html>
<html>
<head>
<title>Drag and Drop Example</title>
<style>
  .column {
    width: 200px;
    min-height: 150px;
    border: 1px solid #ccc;
    margin: 10px;
    padding: 10px;
    float: left;
    background-color: #f9f9f9;
  }
  .task {
    background-color: #fff;
    border: 1px solid #aaa;
    padding: 10px;
    margin-bottom: 10px;
    cursor: grab;
  }
</style>
</head>
<body>

<div class="column" id="todo">
  <h2>To Do</h2>
  <div class="task" draggable="true" id="task-1">Task 1</div>
  <div class="task" draggable="true" id="task-2">Task 2</div>
</div>

<div class="column" id="inprogress">
  <h2>In Progress</h2>
</div>

<script>
  const tasks = document.querySelectorAll('.task');
  const columns = document.querySelectorAll('.column');

  tasks.forEach(task => {
    task.addEventListener('dragstart', (e) => {
      e.dataTransfer.setData('text/plain', e.target.id);
      setTimeout(() => {
        e.target.style.display = 'none';
      }, 0);
    });

    task.addEventListener('dragend', (e) => {
      e.target.style.display = 'block';
    });
  });

  columns.forEach(column => {
    column.addEventListener('dragover', (e) => {
      e.preventDefault(); // Necessary to allow dropping
    });

    column.addEventListener('drop', (e) => {
      e.preventDefault();
      const taskId = e.dataTransfer.getData('text/plain');
      const taskElement = document.getElementById(taskId);
      if (taskElement) {
        e.currentTarget.appendChild(taskElement);
      }
    });
  });
</script>

</body>
</html>

In a Playwright test, we’d interact with this using the dragto method.

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.set_content("""
    <!DOCTYPE html>
    <html>
    <head>
    <title>Drag and Drop Example</title>
    <style>
      .column {
        width: 200px;
        min-height: 150px;
        border: 1px solid #ccc;
        margin: 10px;
        padding: 10px;
        float: left;
        background-color: #f9f9f9;
      }
      .task {
        background-color: #fff;
        border: 1px solid #aaa;
        padding: 10px;
        margin-bottom: 10px;
        cursor: grab;
      }
    </style>
    </head>
    <body>

    <div class="column" id="todo">
      <h2>To Do</h2>
      <div class="task" draggable="true" id="task-1">Task 1</div>
      <div class="task" draggable="true" id="task-2">Task 2</div>
    </div>

    <div class="column" id="inprogress">
      <h2>In Progress</h2>
    </div>

    <script>
      const tasks = document.querySelectorAll('.task');
      const columns = document.querySelectorAll('.column');

      tasks.forEach(task => {
        task.addEventListener('dragstart', (e) => {
          e.dataTransfer.setData('text/plain', e.target.id);
          setTimeout(() => {
            e.target.style.display = 'none';
          }, 0);
        });

        task.addEventListener('dragend', (e) => {
          e.target.style.display = 'block';
        });
      });

      columns.forEach(column => {
        column.addEventListener('dragover', (e) => {
          e.preventDefault(); // Necessary to allow dropping
        });

        column.addEventListener('drop', (e) => {
          e.preventDefault();
          const taskId = e.dataTransfer.getData('text/plain');
          const taskElement = document.getElementById(taskId);
          if (taskElement) {
            e.currentTarget.appendChild(taskElement);
          }
        });
      });
    </script>

    </body>
    </html>
    """)

    # Select the element to drag
    task_to_move = page.locator("#task-1")

    # Select the target element to drop onto
    target_column = page.locator("#inprogress")

    # Perform the drag and drop
    task_to_move.drag_to(target_column)

    # Verify the task has moved
    assert target_column.locator("#task-1").is_visible()
    assert page.locator("#todo").locator("#task-1").is_hidden() # The JS hides it on dragstart, then it's appended

    browser.close()

The drag_to method is a convenience wrapper. Under the hood, it performs a sequence of low-level mouse events: mousedown on the source element, mousemove to the target element’s center, and mouseup on the target. The key is that it calculates the exact coordinates needed for these events, abstracting away the complexities of DOM element dimensions and positions.

The problem drag_to solves is the inherent difficulty in reliably automating complex, user-driven interactions like drag and drop. Traditional methods often involve simulating individual mouse events with page.mouse.down(), page.mouse.move(), page.mouse.up(), which requires precise calculation of element offsets and target coordinates. This is brittle because UI changes, element sizing, or scrolling can easily break these calculations. drag_to handles all of that for you.

Internally, drag_to first finds the center of the source element and initiates a mousedown event there. Then, it calculates the center of the target element. It sends a series of mousemove events to move the "cursor" from the source’s center to the target’s center. Finally, it triggers a mouseup event at the target’s center. This sequence mimics the browser’s native drag-and-drop API behavior, allowing the page’s JavaScript event handlers (like dragstart, dragover, drop) to fire as they would during a manual interaction.

The drag_to method has optional arguments for target_x and target_y which allow you to specify an offset from the target element’s center. This is crucial when you need to drop an item not just anywhere within the target container, but at a very specific location, like before or after another element in a list. For instance, if you wanted to drop task-1 before task-2 in the "In Progress" column, you’d first need to locate task-2 and then use its coordinates, potentially with an offset, as the target for drag_to.

The most powerful, and often overlooked, aspect of drag_to is its ability to handle elements that might not be directly visible or fully within the viewport during the initial mousedown. Playwright automatically scrolls the page to ensure both the source and target elements are in view before attempting the drag operation, making your tests more robust against scrolling-dependent UI layouts.

The next challenge you’ll likely encounter is handling drag-and-drop operations involving multiple, cascading drag-and-drop zones or when the drop target’s position changes dynamically based on where the drag is happening.

Want structured learning?

Take the full Playwright course →