diff --git a/docs/api/browser/interactivity.md b/docs/api/browser/interactivity.md
index b24441b80..8b76e24e1 100644
--- a/docs/api/browser/interactivity.md
+++ b/docs/api/browser/interactivity.md
@@ -39,6 +39,25 @@ await originalUserEvent.keyboard('{/Shift}') // DID NOT release shift because th
This behaviour is more useful because we do not emulate the keyboard, we actually press the Shift, so keeping the original behaviour would cause unexpected issues when typing in the field.
:::
+::: warning
+With `playwright` and `webdriverio` providers, interactions are performed by the underlying browser driver. That means some interaction state, like pressed keys or pointer position and the resulting hover state, can persist between tests in the same file.
+
+Vitest resets unreleased keyboard state automatically before starting each test case, but pointer position and the resulting hover state are not reset automatically since resetting pointer position can be expensive.
+
+This applies both to `userEvent.*` calls and locator shortcuts like `locator.click()` or `locator.hover()`, because they use the same underlying interaction state.
+
+If your tests depend on a neutral hover state, reset it explicitly, for example in `beforeEach`:
+
+```ts
+import { beforeEach } from 'vitest'
+import { userEvent } from 'vitest/browser'
+
+beforeEach(async () => {
+ await userEvent.unhover(document.body)
+})
+```
+:::
+
## userEvent.click
```ts
diff --git a/test/browser/fixtures/user-event-hover/hover-persist.test.ts b/test/browser/fixtures/user-event-hover/hover-persist.test.ts
new file mode 100644
index 000000000..24ba0c76c
--- /dev/null
+++ b/test/browser/fixtures/user-event-hover/hover-persist.test.ts
@@ -0,0 +1,40 @@
+import { beforeAll, beforeEach, expect, test } from 'vitest'
+import { page, server, userEvent } from 'vitest/browser'
+
+beforeEach(async () => {
+ document.body.innerHTML = `
+
+`
+})
+
+function render() {
+ const container = document.createElement('div')
+ container.innerHTML = `
+
+ default
+
+`
+ document.body.appendChild(container)
+}
+
+test('click', async () => {
+ render()
+ await page.getByRole('button').click()
+ await expect.element(page.getByRole('button')).toHaveStyle('background-color: red')
+})
+
+// it keeps hover unless cursor position is reset manually
+test('after click', async () => {
+ render()
+ expect(page.getByRole('button').element()).toHaveStyle('background-color: blue')
+ await new Promise(r => requestAnimationFrame(r))
+ await new Promise(r => setTimeout(r, 200))
+ expect(page.getByRole('button').element()).toHaveStyle('background-color: red')
+})
diff --git a/test/browser/fixtures/user-event-hover/hover-reset.test.ts b/test/browser/fixtures/user-event-hover/hover-reset.test.ts
new file mode 100644
index 000000000..1e16e48ee
--- /dev/null
+++ b/test/browser/fixtures/user-event-hover/hover-reset.test.ts
@@ -0,0 +1,40 @@
+import { beforeEach, expect, test } from 'vitest'
+import { page, server, userEvent } from 'vitest/browser'
+
+beforeEach(async () => {
+ document.body.innerHTML = `
+
+`
+ await userEvent.unhover(document.body)
+})
+
+function render() {
+ const container = document.createElement('div')
+ container.innerHTML = `
+
+ hello
+
+`
+ document.body.appendChild(container)
+}
+
+test('click', async () => {
+ render()
+ await page.getByRole('button').click()
+ await expect.element(page.getByRole('button')).toHaveStyle('background-color: red')
+})
+
+test('after click', async () => {
+ render()
+ expect(page.getByRole('button').element()).toHaveStyle('background-color: blue')
+ await new Promise(r => requestAnimationFrame(r))
+ await new Promise(r => setTimeout(r, 200))
+ expect(page.getByRole('button').element()).toHaveStyle('background-color: blue')
+})
diff --git a/test/browser/fixtures/user-event-hover/vitest.config.ts b/test/browser/fixtures/user-event-hover/vitest.config.ts
new file mode 100644
index 000000000..1020fc9aa
--- /dev/null
+++ b/test/browser/fixtures/user-event-hover/vitest.config.ts
@@ -0,0 +1,19 @@
+import { fileURLToPath } from 'node:url'
+import { defineConfig } from 'vitest/config'
+import { provider, instances } from '../../settings'
+
+export default defineConfig({
+ cacheDir: fileURLToPath(new URL("./node_modules/.vite", import.meta.url)),
+ test: {
+ // TODO:
+ // playwright/firefox doesn't seem to have mouse state isolation per frames.
+ // for example, the following can fail by race conditions:
+ // BROWSER=firefox pnpm -C test/browser/ test-fixtures --root fixtures/user-event-hover --browser.headless --fileParallelism
+ fileParallelism: false,
+ browser: {
+ enabled: true,
+ provider,
+ instances,
+ },
+ },
+})
diff --git a/test/browser/specs/user-event-hover.test.ts b/test/browser/specs/user-event-hover.test.ts
new file mode 100644
index 000000000..2c9e5852d
--- /dev/null
+++ b/test/browser/specs/user-event-hover.test.ts
@@ -0,0 +1,25 @@
+import { expect, test } from 'vitest'
+import { instances, runBrowserTests } from './utils'
+
+test('user-event-hover', async () => {
+ const result = await runBrowserTests({
+ root: './fixtures/user-event-hover',
+ })
+ expect(result.stderr).toMatchInlineSnapshot(`""`)
+
+ const tree = result.errorTree({ project: true })
+ for (const { browser } of instances) {
+ expect.soft(tree[browser], browser).toMatchInlineSnapshot(`
+ {
+ "hover-persist.test.ts": {
+ "after click": "passed",
+ "click": "passed",
+ },
+ "hover-reset.test.ts": {
+ "after click": "passed",
+ "click": "passed",
+ },
+ }
+ `)
+ }
+})