D
P
0

Web Development

Card Stuck in Its `:hover` State on a Playwright Mobile Viewport? That's a Phantom Hover From the Virtual Mouse, Not a Site Bug

July 4, 2026·4 min read
Card Stuck in Its `:hover` State on a Playwright Mobile Viewport? That's a Phantom Hover From the Virtual Mouse, Not a Site Bug

I was verifying a site at a mobile viewport through Playwright before a release. Screenshot after screenshot looked fine, until one card in a grid stopped me cold: its overlay was visible, its title underlined. That is unmistakably a hover state. On a touch viewport. Touch screens have no cursor, so :hover should never be able to engage there. It looked like the classic mobile sticky-hover bug, and I was halfway through writing up the report, CSS patch included.

Fortunately I paused first. Before "fixing" anything, I tried to reproduce it on an actual phone: same page, same card, scroll to it, tap around it. No stuck overlay. No underline. The card behaved perfectly on real hardware. So the bug only existed inside Playwright, and that is a huge tell: when a "bug" only appears inside the tool, the prime suspect is the tool itself.

Why this happens

I scrolled back through the steps the automation had run before the screenshot was taken. There it was: a few steps earlier, a click() on an element. In Playwright, click() is not just a synthetic event. It moves the virtual mouse to that element's coordinates, presses the button, and finishes. The part I had never internalized: the virtual mouse is left parked there. It never moves away on its own. There is no human hand nudging the cursor off to the side after a click.

test.use({ ...devices["Pixel 7"] });
 
test("grid looks clean on mobile", async ({ page }) => {
  await page.goto("/work");
 
  // this click parks the virtual mouse at the button's coordinates
  await page.getByRole("button", { name: "Show all" }).click();
 
  // the grid reflows, a card slides under the parked cursor,
  // and CSS :hover engages -- on a "touch" viewport
  await expect(page).toHaveScreenshot("grid-mobile.png");
});

After that click, the grid reflowed and a card slid directly under the parked cursor. CSS :hover lit up and stayed lit, because the cursor genuinely never left. A phantom hover.

The most treacherous part: I had assumed that emulating a mobile viewport would make the mouse disappear. It does not. Playwright's touch emulation changes the screen size, the user-agent, and the touch events, but the emulated mouse pointer still exists and still has a position. A real phone has no resting cursor idling at a fixed point; an automated browser does. The state I was looking at was physically impossible on an actual device.

The fix

The fix belonged in the test harness, not the site. Before any visual assertion, move the pointer to a neutral corner so nothing on the page is being hovered:

// clear any phantom hover before asserting visual state
await page.mouse.move(0, 0);
await expect(page).toHaveScreenshot("grid-mobile.png");

I re-ran with that one line in place. Overlay gone, underline gone, card back to normal. There was no site bug at all. Alternatively, when the flow allows it, reload the page before screenshotting; a freshly loaded page has never been touched by the pointer, so it starts free of leftover state.

Belt and suspenders: guard it in the CSS

Even though the site was proven innocent, one hardening step is still worth shipping: wrap every hover-only style in a hover: hover media query, so browsers whose primary input is touch never apply them in the first place:

/* hover styles cannot engage on touch-primary devices */
@media (hover: hover) {
  .card:hover .card-overlay {
    opacity: 1;
  }
  .card:hover .card-title {
    text-decoration: underline;
  }
}

This does more than close the phantom-hover hole in automation. It also fixes genuine sticky-hover on real touch screens, because some mobile browsers do engage :hover after a tap and leave it stuck until the next one. One guard, two problems solved.

The takeaway

What almost happened here scares me more than the bug would have: I nearly shipped a "fix" for a problem that never existed, based on the testimony of a tool that contaminated its own crime scene. Now, before filing any bug found through automation, I run this short checklist:

  • Ask first: what state did the tool add? Cursor position, keyboard focus, prefers-reduced-motion emulation and its siblings.
  • page.mouse.move(0, 0) before every visual assertion, or reload the page to start from a clean slate.
  • Wrap hover-only styles in @media (hover: hover).
  • Reproduce on a real device before writing a single line of the fix.

An automated browser is a useful witness, but it is not a neutral one. It brings its own cursor, its own focus, and its own assumptions to every page it opens. Since this incident, my first question when a tool shows me an anomaly is no longer "why is the site broken" but "what fingerprints did my tool just leave behind?"