diff --git a/docs/api/browser/interactivity.md b/docs/api/browser/interactivity.md
index 8d2697b687cd..b24441b80d46 100644
--- a/docs/api/browser/interactivity.md
+++ b/docs/api/browser/interactivity.md
@@ -162,6 +162,58 @@ References:
- [WebdriverIO `browser.action` API](https://webdriver.io/docs/api/browser/action/): implemented via actions api with `move` plus three `down + up + pause` events in a row
- [testing-library `tripleClick` API](https://testing-library.com/docs/user-event/convenience/#tripleClick)
+## userEvent.wheel 4.1.0 {#userevent-wheel}
+
+```ts
+function wheel(
+ element: Element | Locator,
+ options: UserEventWheelOptions,
+): Promise
+```
+
+Triggers a [`wheel` event](https://developer.mozilla.org/en-US/docs/Web/API/Element/wheel_event) on an element.
+
+You can specify the scroll amount using either `delta` for precise pixel-based control, or `direction` for simpler directional scrolling (`up`, `down`, `left`, `right`). When you need to trigger multiple wheel events, use the `times` option rather than calling the method multiple times for better performance.
+
+```ts
+import { page, userEvent } from 'vitest/browser'
+
+test('scroll using delta values', async () => {
+ const tablist = page.getByRole('tablist')
+
+ // Scroll right by 100 pixels
+ await userEvent.wheel(tablist, { delta: { x: 100 } })
+
+ // Scroll down by 50 pixels
+ await userEvent.wheel(tablist, { delta: { y: 50 } })
+
+ // Scroll diagonally 2 times
+ await userEvent.wheel(tablist, { delta: { x: 50, y: 100 }, times: 2 })
+})
+
+test('scroll using direction', async () => {
+ const tablist = page.getByRole('tablist')
+
+ // Scroll right 5 times
+ await userEvent.wheel(tablist, { direction: 'right', times: 5 })
+
+ // Scroll left once
+ await userEvent.wheel(tablist, { direction: 'left' })
+})
+```
+
+Wheel events can also be triggered directly from [locators](/api/browser/locators#wheel):
+
+```ts
+import { page } from 'vitest/browser'
+
+await page.getByRole('tablist').wheel({ direction: 'right' })
+```
+
+::: warning
+This method is intended for testing UI that explicitly listens to `wheel` events (e.g., custom zoom controls, horizontal tab scrolling, canvas interactions). If you need to scroll the page to bring an element into view, rely on the built-in automatic scrolling functionality provided by other `userEvent` methods or [locator actions](/api/browser/locators#methods) instead.
+:::
+
## userEvent.fill
```ts
diff --git a/docs/api/browser/locators.md b/docs/api/browser/locators.md
index 44e17f28715c..e3cc3fce453e 100644
--- a/docs/api/browser/locators.md
+++ b/docs/api/browser/locators.md
@@ -648,6 +648,23 @@ await page.getByRole('img', { name: 'Rose' }).tripleClick()
- [See more at `userEvent.tripleClick`](/api/browser/interactivity#userevent-tripleclick)
+### wheel 4.1.0 {#wheel}
+
+```ts
+function wheel(options: UserEventWheelOptions): Promise
+```
+
+Triggers a [`wheel` event](https://developer.mozilla.org/en-US/docs/Web/API/Element/wheel_event) on an element. You can use the options to choose a general scroll `direction` or a precise `delta` value.
+
+```ts
+import { page } from 'vitest/browser'
+
+// Scroll right
+await page.getByRole('tablist').wheel({ direction: 'right' })
+```
+
+- [See more at `userEvent.wheel`](/api/browser/interactivity#userevent-wheel)
+
### clear
```ts
diff --git a/packages/browser-playwright/src/commands/index.ts b/packages/browser-playwright/src/commands/index.ts
index f143414771dd..a5d1759ede52 100644
--- a/packages/browser-playwright/src/commands/index.ts
+++ b/packages/browser-playwright/src/commands/index.ts
@@ -16,12 +16,14 @@ import {
} from './trace'
import { type } from './type'
import { upload } from './upload'
+import { wheel } from './wheel'
export default {
__vitest_upload: upload as typeof upload,
__vitest_click: click as typeof click,
__vitest_dblClick: dblClick as typeof dblClick,
__vitest_tripleClick: tripleClick as typeof tripleClick,
+ __vitest_wheel: wheel as typeof wheel,
__vitest_takeScreenshot: takeScreenshot as typeof takeScreenshot,
__vitest_type: type as typeof type,
__vitest_clear: clear as typeof clear,
diff --git a/packages/browser-playwright/src/commands/wheel.ts b/packages/browser-playwright/src/commands/wheel.ts
new file mode 100644
index 000000000000..f63acc96f890
--- /dev/null
+++ b/packages/browser-playwright/src/commands/wheel.ts
@@ -0,0 +1,21 @@
+import type { Locator, UserEventWheelDeltaOptions } from 'vitest/browser'
+import type { UserEventCommand } from './utils'
+import { hover } from './hover'
+
+type WheelCommand = (element: Locator | Element, options: UserEventWheelDeltaOptions) => Promise
+
+export const wheel: UserEventCommand = async (
+ context,
+ selector,
+ options,
+) => {
+ await hover(context, selector)
+
+ const times = options.times ?? 1
+ const deltaX = options.delta.x ?? 0
+ const deltaY = options.delta.y ?? 0
+
+ for (let count = 0; count < times; count += 1) {
+ await context.page.mouse.wheel(deltaX, deltaY)
+ }
+}
diff --git a/packages/browser-webdriverio/src/commands/index.ts b/packages/browser-webdriverio/src/commands/index.ts
index a589c0265fb6..3fbb5b7f90be 100644
--- a/packages/browser-webdriverio/src/commands/index.ts
+++ b/packages/browser-webdriverio/src/commands/index.ts
@@ -10,12 +10,14 @@ import { tab } from './tab'
import { type } from './type'
import { upload } from './upload'
import { viewport } from './viewport'
+import { wheel } from './wheel'
export default {
__vitest_upload: upload as typeof upload,
__vitest_click: click as typeof click,
__vitest_dblClick: dblClick as typeof dblClick,
__vitest_tripleClick: tripleClick as typeof tripleClick,
+ __vitest_wheel: wheel as typeof wheel,
__vitest_takeScreenshot: takeScreenshot as typeof takeScreenshot,
__vitest_type: type as typeof type,
__vitest_clear: clear as typeof clear,
diff --git a/packages/browser-webdriverio/src/commands/wheel.ts b/packages/browser-webdriverio/src/commands/wheel.ts
new file mode 100644
index 000000000000..9a06417e88c0
--- /dev/null
+++ b/packages/browser-webdriverio/src/commands/wheel.ts
@@ -0,0 +1,28 @@
+import type { Locator, UserEventWheelDeltaOptions } from 'vitest/browser'
+import type { UserEventCommand } from './utils'
+
+type WheelCommand = (element: Locator | Element, options: UserEventWheelDeltaOptions) => Promise
+
+export const wheel: UserEventCommand = async (
+ context,
+ selector,
+ options,
+) => {
+ const browser = context.browser
+ const times = options.times ?? 1
+ const deltaX = options.delta.x ?? 0
+ const deltaY = options.delta.y ?? 0
+
+ let action = browser.action('wheel')
+ const wheelOptions: Parameters[0] = {
+ deltaX,
+ deltaY,
+ origin: browser.$(selector),
+ }
+
+ for (let count = 0; count < times; count += 1) {
+ action = action.scroll(wheelOptions)
+ }
+
+ await action.perform()
+}
diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts
index c4bf8104eb94..ef1c524e8462 100644
--- a/packages/browser/context.d.ts
+++ b/packages/browser/context.d.ts
@@ -207,6 +207,25 @@ export interface UserEvent {
* @see {@link https://testing-library.com/docs/user-event/convenience/#tripleclick} testing-library API
*/
tripleClick: (element: Element | Locator, options?: UserEventTripleClickOptions) => Promise
+ /**
+ * Triggers a {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/wheel_event|`wheel` event} on an element.
+ *
+ * @param element - The target element to receive wheel events.
+ * @param options - Scroll configuration using `delta` or `direction`.
+ * @returns A promise that resolves when all wheel events have been dispatched.
+ *
+ * @since 4.1.0
+ * @see {@link https://vitest.dev/api/browser/interactivity#userevent-wheel}
+ *
+ * @example
+ * // Scroll down by 100 pixels
+ * await userEvent.wheel(container, { delta: { y: 100 } })
+ *
+ * @example
+ * // Scroll up 5 times
+ * await userEvent.wheel(container, { direction: 'up', times: 5 })
+ */
+ wheel(element: Element | Locator, options: UserEventWheelOptions): Promise
/**
* Choose one or more values from a select element. Uses provider's API under the hood.
* If select doesn't have `multiple` attribute, only the first value will be selected.
@@ -345,6 +364,58 @@ export interface UserEventTripleClickOptions {}
export interface UserEventDragAndDropOptions {}
export interface UserEventUploadOptions {}
+/**
+ * Base options shared by all wheel event configurations.
+ *
+ * @since 4.1.0
+ */
+export interface UserEventWheelBaseOptions {
+ /**
+ * Number of wheel events to fire. Defaults to `1`.
+ *
+ * Useful for triggering multiple scroll steps in a single call.
+ */
+ times?: number
+}
+
+/**
+ * Wheel options using pixel-based `delta` values for precise scroll control.
+ *
+ * @since 4.1.0
+ */
+export interface UserEventWheelDeltaOptions extends UserEventWheelBaseOptions {
+ /**
+ * Precise scroll delta values in pixels. At least one axis must be specified.
+ *
+ * - Positive `y` scrolls down, negative `y` scrolls up.
+ * - Positive `x` scrolls right, negative `x` scrolls left.
+ */
+ delta: { x: number; y?: number } | { x?: number; y: number }
+ direction?: undefined
+}
+
+/**
+ * Wheel options using semantic `direction` values for simpler scroll control.
+ *
+ * @since 4.1.0
+ */
+export interface UserEventWheelDirectionOptions extends UserEventWheelBaseOptions {
+ /**
+ * Semantic scroll direction. Use this for readable tests when exact pixel values don't matter.
+ */
+ direction: 'up' | 'down' | 'left' | 'right'
+ delta?: undefined
+}
+
+/**
+ * Options for triggering wheel events.
+ *
+ * Specify scrolling using either `delta` for precise pixel values, or `direction` for semantic scrolling. These are mutually exclusive.
+ *
+ * @since 4.1.0
+ */
+export type UserEventWheelOptions = UserEventWheelDeltaOptions | UserEventWheelDirectionOptions
+
export interface LocatorOptions {
/**
* Whether to find an exact match: case-sensitive and whole-string. Default to false. Ignored when locating by a
@@ -489,6 +560,24 @@ export interface Locator extends LocatorSelectors {
* @see {@link https://vitest.dev/api/browser/interactivity#userevent-tripleclick}
*/
tripleClick(options?: UserEventTripleClickOptions): Promise
+ /**
+ * Triggers a {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/wheel_event|`wheel` event} on an element.
+ *
+ * @param options - Scroll configuration using `delta` or `direction`.
+ * @returns A promise that resolves when all wheel events have been dispatched.
+ *
+ * @since 4.1.0
+ * @see {@link https://vitest.dev/api/browser/interactivity#userevent-wheel}
+ *
+ * @example
+ * // Scroll down by 100 pixels
+ * await container.wheel({ delta: { y: 100 } })
+ *
+ * @example
+ * // Scroll up 5 times
+ * await container.wheel({ direction: 'up', times: 5 })
+ */
+ wheel(options: UserEventWheelOptions): Promise
/**
* Clears the input element content
* @see {@link https://vitest.dev/api/browser/interactivity#userevent-clear}
diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts
index 9103bd10fc5b..8229969ea444 100644
--- a/packages/browser/src/client/tester/context.ts
+++ b/packages/browser/src/client/tester/context.ts
@@ -9,6 +9,7 @@ import type {
Locator,
LocatorSelectors,
UserEvent,
+ UserEventWheelOptions,
} from 'vitest/browser'
import type { StringifyOptions } from 'vitest/internal/browser'
import type { IframeViewportEvent } from '../client'
@@ -16,7 +17,7 @@ import type { BrowserRunnerState } from '../utils'
import type { Locator as LocatorAPI } from './locators/index'
import { __INTERNAL, stringify } from 'vitest/internal/browser'
import { ensureAwaited, getBrowserState, getWorkerState } from '../utils'
-import { convertToSelector, processTimeoutOptions } from './tester-utils'
+import { convertToSelector, isLocator, processTimeoutOptions, resolveUserEventWheelOptions } from './tester-utils'
// this file should not import anything directly, only types and utils
@@ -69,6 +70,9 @@ export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent
tripleClick(element, options) {
return convertToLocator(element).tripleClick(options)
},
+ wheel(elementOrOptions: Element | Locator, options: UserEventWheelOptions) {
+ return convertToLocator(elementOrOptions).wheel(options)
+ },
selectOptions(element, value, options) {
return convertToLocator(element).selectOptions(value, options)
},
@@ -230,6 +234,31 @@ function createPreviewUserEvent(userEventBase: TestingLibraryUserEvent, options:
async paste() {
await userEvent.paste(clipboardData)
},
+ async wheel(element: Element | Locator, options: UserEventWheelOptions) {
+ const resolvedElement = isLocator(element) ? element.element() : element
+ const resolvedOptions = resolveUserEventWheelOptions(options)
+
+ const rect = resolvedElement.getBoundingClientRect()
+
+ const centerX = rect.left + rect.width / 2
+ const centerY = rect.top + rect.height / 2
+
+ const wheelEvent = new WheelEvent('wheel', {
+ clientX: centerX,
+ clientY: centerY,
+ deltaY: resolvedOptions.delta.y ?? 0,
+ deltaX: resolvedOptions.delta.x ?? 0,
+ deltaMode: 0,
+ bubbles: true,
+ cancelable: true,
+ })
+
+ const times = options.times ?? 1
+
+ for (let count = 0; count < times; count += 1) {
+ resolvedElement.dispatchEvent(wheelEvent)
+ }
+ },
}
for (const [name, fn] of Object.entries(vitestUserEvent)) {
diff --git a/packages/browser/src/client/tester/locators/index.ts b/packages/browser/src/client/tester/locators/index.ts
index 14a715c9064a..1f1c1566c740 100644
--- a/packages/browser/src/client/tester/locators/index.ts
+++ b/packages/browser/src/client/tester/locators/index.ts
@@ -10,6 +10,7 @@ import type {
UserEventHoverOptions,
UserEventSelectOptions,
UserEventUploadOptions,
+ UserEventWheelOptions,
} from 'vitest/browser'
import {
asLocator,
@@ -25,7 +26,7 @@ import {
import { page, server, utils } from 'vitest/browser'
import { __INTERNAL } from 'vitest/internal/browser'
import { ensureAwaited, getBrowserState } from '../../utils'
-import { escapeForTextSelector, isLocator } from '../tester-utils'
+import { escapeForTextSelector, isLocator, resolveUserEventWheelOptions } from '../tester-utils'
export { convertElementToCssSelector, getIframeScale, processTimeoutOptions } from '../tester-utils'
export {
@@ -86,6 +87,23 @@ export abstract class Locator {
return this.triggerCommand('__vitest_tripleClick', this.selector, options)
}
+ public wheel(options: UserEventWheelOptions): Promise {
+ return ensureAwaited(async () => {
+ await this.triggerCommand('__vitest_wheel', this.selector, resolveUserEventWheelOptions(options))
+
+ const browser = getBrowserState().config.browser.name
+
+ // looks like on Chromium the scroll event gets dispatched a frame later
+ if (browser === 'chromium' || browser === 'chrome') {
+ return new Promise((resolve) => {
+ requestAnimationFrame(() => {
+ resolve()
+ })
+ })
+ }
+ })
+ }
+
public clear(options?: UserEventClearOptions): Promise {
return this.triggerCommand('__vitest_clear', this.selector, options)
}
diff --git a/packages/browser/src/client/tester/tester-utils.ts b/packages/browser/src/client/tester/tester-utils.ts
index c22fef6c4c7c..e69eac071140 100644
--- a/packages/browser/src/client/tester/tester-utils.ts
+++ b/packages/browser/src/client/tester/tester-utils.ts
@@ -1,4 +1,4 @@
-import type { Locator } from 'vitest/browser'
+import type { Locator, UserEventWheelDeltaOptions, UserEventWheelOptions } from 'vitest/browser'
import type { BrowserRPC } from '../client'
import { getBrowserState, getWorkerState } from '../utils'
@@ -217,3 +217,41 @@ const kLocator = Symbol.for('$$vitest:locator')
export function isLocator(element: unknown): element is Locator {
return (!!element && typeof element === 'object' && kLocator in element)
}
+
+const DEFAULT_WHEEL_DELTA = 100
+
+export function resolveUserEventWheelOptions(options: UserEventWheelOptions): UserEventWheelDeltaOptions {
+ let delta: UserEventWheelDeltaOptions['delta']
+
+ if (options.delta) {
+ delta = options.delta
+ }
+ else {
+ switch (options.direction) {
+ case 'up': {
+ delta = { y: -DEFAULT_WHEEL_DELTA }
+ break
+ }
+
+ case 'down': {
+ delta = { y: DEFAULT_WHEEL_DELTA }
+ break
+ }
+
+ case 'left': {
+ delta = { x: -DEFAULT_WHEEL_DELTA }
+ break
+ }
+
+ case 'right': {
+ delta = { x: DEFAULT_WHEEL_DELTA }
+ break
+ }
+ }
+ }
+
+ return {
+ delta,
+ times: options.times,
+ }
+}
diff --git a/test/browser/fixtures/user-event/wheel.test.ts b/test/browser/fixtures/user-event/wheel.test.ts
new file mode 100644
index 000000000000..950d9e4af5b8
--- /dev/null
+++ b/test/browser/fixtures/user-event/wheel.test.ts
@@ -0,0 +1,85 @@
+import { describe, test, vi } from 'vitest'
+import { userEvent, page } from 'vitest/browser'
+
+describe.for([
+ 'userEvent',
+ 'locator'
+] as const)('`%s`', (testType) => {
+ test.for([
+ ['up', { deltaX: 0, deltaY: -100 }],
+ ['down', { deltaX: 0, deltaY: 100 }],
+ ['left', { deltaX: -100, deltaY: 0 }],
+ ['right', { deltaX: 100, deltaY: 0 }]
+ ] as const)('scrolls %s with default delta of 100', async ([direction, { deltaX, deltaY }], { expect }) => {
+ document.body.innerHTML = `
+
+
+
+ `;
+
+ const wheel = vi.fn<(event: WheelEvent) => void>()
+ document.body.querySelector('button').addEventListener('wheel', wheel, { passive: true })
+
+ const options = { direction }
+ const selector = page.getByRole("button")
+
+ await (testType === 'userEvent' ? userEvent.wheel(selector, options) : selector.wheel(options))
+
+ expect(wheel).toHaveBeenCalledOnce()
+ expect(wheel.mock.calls[0][0].deltaX).toBe(deltaX)
+ expect(wheel.mock.calls[0][0].deltaY).toBe(deltaY)
+ })
+
+ test.for([
+ { deltaX: 0, deltaY: -50 },
+ { deltaX: 0, deltaY: 50 },
+ { deltaX: -50, deltaY: 0 },
+ { deltaX: 50, deltaY: 0 }
+ ] as const)('scrolls with custom delta values %o', async ({ deltaX, deltaY }, { expect }) => {
+ document.body.innerHTML = `
+
+
+
+ `;
+
+ const wheel = vi.fn<(event: WheelEvent) => void>()
+ document.body.querySelector('button').addEventListener('wheel', wheel, { passive: true })
+
+ const options = {
+ delta: {
+ x: deltaX,
+ y: deltaY,
+ },
+ }
+ const selector = page.getByRole("button")
+
+ await (testType === 'userEvent' ? userEvent.wheel(selector, options) : selector.wheel(options))
+
+ expect(wheel).toHaveBeenCalledOnce()
+ expect(wheel.mock.calls[0][0].deltaX).toBe(deltaX)
+ expect(wheel.mock.calls[0][0].deltaY).toBe(deltaY)
+ })
+
+ test("fires wheel event multiple times when `times` option is set", { retry: 5 }, async ({ expect }) => {
+ document.body.innerHTML = `
+
+
+
+ `;
+
+ const wheel = vi.fn<(event: WheelEvent) => void>()
+ document.body.querySelector('button').addEventListener('wheel', wheel, { passive: true })
+
+ const options = { direction: 'down', times: 5 } as const
+ const selector = page.getByRole("button")
+
+ await (testType === 'userEvent' ? userEvent.wheel(selector, options) : selector.wheel(options))
+
+ expect(wheel).toHaveBeenCalledTimes(5)
+
+ for (const call of wheel.mock.calls) {
+ expect(call[0].deltaX).toBe(0)
+ expect(call[0].deltaY).toBe(100)
+ }
+ })
+})