Skip to main content

Automating Drag-and-Drop in React/MUI Apps with Playwright

Divya Manohar
Co-Founder and CEO, DevAssure

One of our customers recently faced an issue with automating drag-and-drop functionality in their React application built with Material-UI (MUI). While we provided a solution for them with DevAssure, I also wanted to share a general approach to handling drag-and-drop interactions in React/MUI apps using Playwright.

Automating drag-and-drop in modern web applications sounds straightforward. Playwright even provides a built-in dragTo method. But if you’ve ever tried it in a React/MUI (Material-UI) app, you’ve probably seen mysterious timeouts or errors like:

locator.dragTo: Timeout 30000ms exceeded
<div class="MuiBackdrop-root ..."> intercepts pointer events

I did a deep dive to understand why this happens and how to fix it and Here's what I found.

The Problem: MUI Overlays

Material-UI uses two common overlay components:

  • MuiBackdrop: a full-screen, often invisible element behind modals/popovers.
  • MuiPopover: the floating menu/panel itself.

Even when they look hidden or transparent, these elements are still in the DOM with pointer-events: auto. That means they intercept clicks, drags, and drops — preventing your drop target from ever receiving the event.

Playwright sees the element you’re dragging to, but the mouseup (or final part of the drag sequence) hits the invisible overlay instead of your canvas, resulting in the error.

What I Tried

Native dragTo

Fails with “element is not stable” or “backdrop intercepts pointer events”.

await page.getByText('Draggable Item').dragTo(page.getByText('Drop Target'));

Here's a snippet of the error message from Playwright:

Error: locator.dragTo: Test timeout of 30000ms exceeded.
Call log:
- waiting for getByText('Enters Segment', { exact: true }).first()
- locator resolved to <p class="MuiTypography-root MuiTypography-body1 css-5ow9z6">Enters Segment</p>
- attempting move and down action
2 × waiting for element to be visible and stable
- element is not stable
- retrying move and down action
- waiting 20ms
- waiting for element to be visible and stable
- element is not stable

I then tried using the pointer sequence manually.

Pointer sequence

const box = await source.boundingBox();
await page.mouse.move(box.x + box.width/2, box.y + box.height/2);
await page.mouse.down();
await page.mouse.move(targetX, targetY, { steps: 20 });
await page.mouse.up();

Still intercepted, because MUI’s backdrop swallows the final mouseup.

Decoding HTML5 Drag-and-Drop

Many modern UIs (especially canvas/flow builders) use the HTML5 Drag and Drop API rather than raw mouse events.

That API relies on a sequence of DOM events:

  • dragstart
  • dragenter
  • dragover
  • drop
  • dragend

Crucially, these events include a DataTransfer object. Without it, the UI ignores the drag.

That means simulating drag-and-drop is sometimes better done by dispatching HTML5 events directly, instead of moving the mouse.

info

DataTransfer is an object that holds the data being dragged during a drag-and-drop operation. It allows you to set and retrieve data in various formats (like text, URLs, files) and is essential for the HTML5 Drag and Drop API to function correctly.

You can learn more about it in the MDN Web Docs.

The Solution: Replicating the Drag-and-Drop Events

Here’s a minimal working example you can run inside the page:

const src = document.querySelector('#sourceElement');  // replace this with your draggable element's selector
const tgt = document.querySelector('#targetElement'); // replace this with your drop target's selector

if (!src || !tgt) throw new Error("Source or target not found");

const dt = new DataTransfer();
// Helper to fire events
function fire(el, type) {
const evt = new DragEvent(type, {
bubbles: true, // Whether the event bubbles up through the DOM or not
cancelable: true, // Whether the event can be canceled
dataTransfer: dt
});
el.dispatchEvent(evt);
}

fire(src, 'dragstart');
fire(tgt, 'dragenter');
for (let i = 0; i < 3; i++) fire(tgt, 'dragover'); // fire multiple dragover events to simulate movement
fire(tgt, 'drop');
fire(src, 'dragend');

This works because it speaks the same “language” your app is listening for: the HTML5 DnD lifecycle.

You can integrate this into your Playwright test like so:

await page.evaluate(({ srcSelector, tgtSelector }) => {
const src = document.querySelector(srcSelector);
const tgt = document.querySelector(tgtSelector);
if (!src || !tgt) throw new Error("Source or target not found");

const dt = new DataTransfer();
// Helper to fire events
function fire(el, type) {
const evt = new DragEvent(type, {
bubbles: true, // Whether the event bubbles up through the DOM or not
cancelable: true, // Whether the event can be canceled
dataTransfer: dt
});
el.dispatchEvent(evt);
}

fire(src, 'dragstart');
fire(tgt, 'dragenter');
for (let i = 0; i < 3; i++) fire(tgt, 'dragover'); // fire multiple dragover events to simulate movement
fire(tgt, 'drop');
fire(src, 'dragend');
}, { srcSelector: '#sourceElement', tgtSelector: '#targetElement' }); // replace with your selectors

This approach bypasses the backdrop issue entirely, since you’re not relying on mouse events that can be intercepted.

Neutralizing the Overlays

Sometimes the overlay still blocks the drop. A pragmatic fix: disable pointer events on MUI backdrops/popovers while you fire the events.

document.querySelectorAll('.MuiBackdrop-root, .MuiPopover-root')
.forEach(el => { el.style.pointerEvents = 'none'; });

With this, the overlay remains in place but no longer intercepts input. Your drop target can receive the event. You can combine this with the event firing code above for a robust solution.

Putting the pieces together

A reusable helper:

function dragAndDrop(sourceSelector, targetSelector) {
const src = document.querySelector(sourceSelector);
const tgt = document.querySelector(targetSelector);
if (!src || !tgt) throw new Error("Source or target not found");

// Disable overlays
document.querySelectorAll('.MuiBackdrop-root, .MuiPopover-root')
.forEach(el => { el.style.pointerEvents = 'none'; });

const dt = new DataTransfer();
const fire = (el, type) =>
el.dispatchEvent(new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: dt }));

fire(src, 'dragstart');
fire(tgt, 'dragenter');
for (let i = 0; i < 3; i++) fire(tgt, 'dragover');
fire(tgt, 'drop');
fire(src, 'dragend');
}

Then call it in Playwright:

await page.evaluate(dragAndDrop, '#sourceElement', '#targetElement'); // replace with your selectors

Lessons Learned

  • Invisible doesn’t mean inactive: MUI backdrops intercept pointer events even when hidden.
  • Playwright’s dragTo isn’t enough in such cases.
  • HTML5 drag/drop events with DataTransfer simulate what the app really expects.
  • Overlay neutralization is sometimes required for automation stability.
  • Selectors matter: prefer stable attributes (data-testid, roles) over generated CSS classes or nth-child.

Conclusion

Automating drag-and-drop in React/MUI apps can be tricky due to overlay components intercepting events. By understanding the HTML5 Drag and Drop API and simulating the correct sequence of events, you can achieve reliable automation with Playwright.

🚀 See how DevAssure accelerates test automation, improves coverage, and reduces QA effort.
Schedule a customized demo with our team today.

Schedule Demo

Frequently Asked Questions (FAQs)