Playwright click looks like the simplest action in the API: find an element, call click(). Most of the time it is. But when a click fails, it is usually because of something you can't see: an overlay on top, a button that isn't enabled yet, or a locator that matches three elements. Understanding what click() waits for is the difference between a stable test and a flaky one.
Clicks and waits are closely linked, so this guide pairs with the Playwright wait guide. Below: locator.click() with getByRole, the actionability checks, every click option in Playwright 1.62, double-click, hover menus, iframes, new tabs, and the common click errors with real fixes. Every example is JavaScript, uses a public practice site, and passed on Playwright 1.62.1.
Quick answer: await page.getByRole('button', { name: 'Add Element' }).click();. Right-click: { button: 'right' }. Double-click: dblclick(). Ctrl/Cmd-click: { modifiers: ['ControlOrMeta'] }. Don't add force: true until you know why the normal click failed.
How to Click in Playwright
To click in Playwright, find the element with a locator and call locator.click(). Playwright waits until the element is visible, stable, enabled and not covered, scrolls it into view, then clicks its center.
Use a locator, ideally getByRole, and call click() on it. Role locators find elements the way a user does: "the button called Add Element", "the link called Get started". See the getByRole guide and the locators guide for choosing locators.
const { test, expect } = require('@playwright/test'); test('click a button by role', async ({ page }) => { await page.goto('https://the-internet.herokuapp.com/add_remove_elements/'); await page.getByRole('button', { name: 'Add Element' }).click(); await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible(); }); test('click three times with clickCount', async ({ page }) => { await page.goto('https://the-internet.herokuapp.com/add_remove_elements/'); await page.getByRole('button', { name: 'Add Element' }).click({ clickCount: 3 }); await expect(page.getByRole('button', { name: 'Delete' })).toHaveCount(3); }); test('click a link and land on the next page', async ({ page }) => { await page.goto('https://playwright.dev/'); await page.getByRole('link', { name: 'Get started' }).click(); await expect(page).toHaveURL(/\/docs\/intro/); await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); });
Notice there is no wait after the link click. After a click that starts a navigation, toHaveURL and toBeVisible retry until the new page is there. You don't need waitForNavigation or a sleep.
What click() Waits For (Actionability Checks)
Before clicking, Playwright checks that the element is ready. According to the 1.62 API docs, locator.click():
- Waits for the actionability checks: exactly one element matches, and it is visible, stable (not animating), enabled, and receives events (nothing else is on top).
- Scrolls the element into view if needed.
- Clicks with the mouse in the center of the element, or at
position. - Waits for any navigation the click started to succeed or fail.
If any check fails, Playwright retries until the action timeout. That is why this test needs no explicit wait, even though the "Add" button doesn't exist for about three seconds and the text box starts disabled:
const { test, expect } = require('@playwright/test'); test('click waits for the button to come back', async ({ page }) => { await page.goto('https://the-internet.herokuapp.com/dynamic_controls'); await page.getByRole('button', { name: 'Remove' }).click(); // For ~3 seconds there is no "Add" button. click() waits for it. await page.getByRole('button', { name: 'Add' }).click(); await expect(page.locator('#message')).toHaveText("It's back!"); await expect(page.getByRole('checkbox')).toBeVisible(); }); test('click waits for a disabled input to be enabled', async ({ page }) => { await page.goto('https://the-internet.herokuapp.com/dynamic_controls'); await page.getByRole('button', { name: 'Enable' }).click(); await page.getByRole('textbox').click(); // waits until enabled await page.getByRole('textbox').fill('Typed after the wait'); await expect(page.getByRole('textbox')).toHaveValue('Typed after the wait'); });
Auto-waiting covers the element. It does not cover everything else (a network call, a redirect). For those, see waitForResponse and waitForURL.
Playwright Click Options
These are all the options locator.click() accepts in Playwright 1.62.1, taken from its type definitions:
| Option | What it does |
|---|---|
button | 'left' (default), 'right' or 'middle' |
clickCount | Number of clicks; default 1 |
delay | Milliseconds between mousedown and mouseup; default 0 |
modifiers | Keys held during the click: 'Alt', 'Control', 'ControlOrMeta', 'Meta', 'Shift' |
position | { x, y } relative to the top-left of the element's padding box |
force | Skip the actionability checks; default false (see below) |
trial | Run the checks but don't click |
timeout | Max time in ms for this click; 0 means no timeout |
scroll | 'auto' (default) or 'none': fail instead of scrolling if the element is outside the viewport |
steps | Number of mousemove events sent on the way to the element; default 1 |
signal | An AbortSignal to cancel the click |
noWaitAfter | Deprecated. Skip waiting for a navigation the click started. The docs say it will default to true in the future |
Here are the options you'll actually use, each in a passing test:
const { test, expect } = require('@playwright/test'); test('right-click opens the context menu handler', async ({ page }) => { await page.goto('https://the-internet.herokuapp.com/context_menu'); page.once('dialog', async dialog => { expect(dialog.message()).toBe('You selected a context menu'); await dialog.accept(); }); await page.locator('#hot-spot').click({ button: 'right' }); }); test('click at a position inside the element', async ({ page }) => { await page.goto('https://the-internet.herokuapp.com/context_menu'); let message = ''; page.once('dialog', dialog => { message = dialog.message(); return dialog.accept(); }); // 20px right and 20px down from the top-left of the box await page.locator('#hot-spot').click({ button: 'right', position: { x: 20, y: 20 } }); expect(message).toBe('You selected a context menu'); }); test('double-click to edit a todo', async ({ page }) => { await page.goto('https://demo.playwright.dev/todomvc'); const newTodo = page.getByPlaceholder('What needs to be done?'); await newTodo.fill('Buy milk'); await newTodo.press('Enter'); await page.getByTestId('todo-title').dblclick(); const editor = page.getByRole('textbox', { name: 'Edit' }); await editor.fill('Buy oat milk'); await editor.press('Enter'); await expect(page.getByTestId('todo-title')).toHaveText('Buy oat milk'); }); test('Ctrl/Cmd + click opens the link in a new tab', async ({ page, context }) => { await page.goto('https://the-internet.herokuapp.com/'); const newPagePromise = context.waitForEvent('page'); await page.getByRole('link', { name: 'A/B Testing' }).click({ modifiers: ['ControlOrMeta'] }); const newPage = await newPagePromise; await expect(newPage).toHaveURL(/abtest/); await expect(page).toHaveURL('https://the-internet.herokuapp.com/'); // original tab stays put }); test('trial click: check it is clickable without clicking', async ({ page }) => { await page.goto('https://the-internet.herokuapp.com/add_remove_elements/'); await page.getByRole('button', { name: 'Add Element' }).click({ trial: true }); await expect(page.getByRole('button', { name: 'Delete' })).toHaveCount(0); });
Things worth knowing from running these:
- Right-click and alerts. The context menu page shows an
alert()on right-click. Registerpage.once('dialog', ...)before the click. If you instead startpage.waitForEvent('dialog')and await the click first, the click never finishes: the open alert blocked it until the test timed out. ControlOrMetais Control on Windows and Linux and Meta (Cmd) on macOS, so the same test works everywhere. Ctrl/Cmd-click on a link opened it in a new page and left the original tab where it was.trial: trueis handy when you want to wait until something is clickable without changing anything. The Add button was checked but no Delete button appeared.scroll: 'none'really does fail: on a link below the fold, the log repeated "element is outside of the viewport" until the timeout.
Double-Click and Hover Then Click
Use locator.dblclick() for double-clicks. It is shown in the options test above: TodoMVC starts editing a todo on double-click. dblclick() accepts the same options as click() except clickCount.
Hover menus need a hover() first. On the hovers page, the "View profile" links are hidden with CSS until you hover the picture. On TodoMVC, the delete button only appears when you hover the todo:
const { test, expect } = require('@playwright/test'); test('hover to reveal a link, then click it', async ({ page }) => { await page.goto('https://the-internet.herokuapp.com/hovers'); const firstUser = page.locator('.figure').first(); await firstUser.hover(); await firstUser.getByRole('link', { name: 'View profile' }).click(); await expect(page).toHaveURL(/\/users\/1$/); }); test('hover a todo to reveal its delete button', async ({ page }) => { await page.goto('https://demo.playwright.dev/todomvc'); const newTodo = page.getByPlaceholder('What needs to be done?'); await newTodo.fill('Remove me'); await newTodo.press('Enter'); const item = page.getByTestId('todo-item'); await item.hover(); await item.getByRole('button', { name: 'Delete' }).click(); await expect(item).toHaveCount(0); });
Scoping the link to firstUser also avoids a strict mode error, since there are three "View profile" links on the page.
Click works locally, fails in CI? That is almost never the click. It is the wait around it: an overlay still fading out, data not loaded yet, a waitForTimeout that was long enough on your laptop. See the waits behind flaky Playwright tests and how to fix them →
Click in an iframe and Clicks That Open a New Tab
Inside an iframe
A normal page locator can't see inside an iframe. Use page.frameLocator() to step into the frame, then click as usual. The iframe page hosts a TinyMCE editor:
const { test, expect } = require('@playwright/test'); test('click inside an iframe', async ({ page }) => { await page.goto('https://the-internet.herokuapp.com/iframe'); const editor = page.frameLocator('#mce_0_ifr'); await editor.locator('body').click(); await expect(editor.locator('body')).toContainText('Your content goes here.'); });
A link that opens a new tab
Links with target="_blank" open a popup. Start waiting for the popup event before the click (the same promise-first pattern used for network waits), then work with the new page:
const { test, expect } = require('@playwright/test'); test('click a link that opens a new tab', async ({ page }) => { await page.goto('https://the-internet.herokuapp.com/windows'); const popupPromise = page.waitForEvent('popup'); // start waiting first await page.getByRole('link', { name: 'Click Here' }).click(); const newTab = await popupPromise; await expect(newTab).toHaveURL(/\/windows\/new$/); await expect(newTab.getByRole('heading', { name: 'New Window' })).toBeVisible(); });
Common Playwright Click Errors and How to Fix Them
"element is not visible"
The element exists but is hidden, often until hover. With a CSS locator, the log says so:
TimeoutError: locator.click: Timeout 3000ms exceeded.
Call log:
- waiting for locator('.destroy')
- locator resolved to <button class="destroy" aria-label="Delete"></button>
- attempting click action
2 × waiting for element to be visible, enabled and stable
- element is not visible
With getByRole, the log only says waiting for getByRole('button', { name: 'Delete' }), because role locators ignore hidden elements, so nothing matches at all. Either way, the fix is to do what a user does: hover first, or open the menu or tab that reveals the element.
"intercepts pointer events"
Something is on top of your element: a modal, cookie banner, toast or spinner overlay. On the entry ad page, a modal appears shortly after load:
TimeoutError: locator.click: Timeout 3000ms exceeded.
Call log:
- waiting for getByRole('link', { name: 'click here' })
- locator resolved to <a href="" id="restart-ad">click here</a>
- attempting click action
2 × waiting for element to be visible, enabled and stable
- element is visible, enabled and stable
- scrolling into view if needed
- done scrolling
- <div class="underlay"></div> from <div id="modal">…</div> subtree intercepts pointer events
- retrying click action
The log names the element in the way. Close it, or wait for it to disappear with await expect(overlay).toBeHidden(), then click. The first test in the file below does exactly that.
"strict mode violation"
The locator matches more than one element:
Error: locator.click: Error: strict mode violation: getByRole('button', { name: 'Delete' }) resolved to 3 elements:
1) <button class="added-manually" onclick="deleteElement()">Delete</button> aka getByRole('button', { name: 'Delete' }).first()
2) <button class="added-manually" onclick="deleteElement()">Delete</button> aka getByRole('button', { name: 'Delete' }).nth(1)
3) <button class="added-manually" onclick="deleteElement()">Delete</button> aka getByRole('button', { name: 'Delete' }).nth(2)
Make the locator specific: scope it inside a parent (row.getByRole(...)), use filter({ hasText }), or, when the elements really are identical, pick one with first() or nth().
const { test, expect } = require('@playwright/test'); test('fix "intercepts pointer events": close the overlay first', async ({ page }) => { await page.goto('https://the-internet.herokuapp.com/entry_ad'); const modal = page.locator('#modal'); await expect(modal).toBeVisible(); await modal.getByText('Close').click(); await expect(modal).toBeHidden(); // Now nothing covers the page await page.getByRole('heading', { name: 'Entry Ad' }).click(); }); test('fix strict mode violation: pick one element', async ({ page }) => { await page.goto('https://the-internet.herokuapp.com/add_remove_elements/'); const add = page.getByRole('button', { name: 'Add Element' }); await add.click(); await add.click(); const deleteButtons = page.getByRole('button', { name: 'Delete' }); await deleteButtons.first().click(); await expect(deleteButtons).toHaveCount(1); }); test('force: true sends the click to whatever is on top', async ({ page }) => { await page.goto('https://the-internet.herokuapp.com/entry_ad'); await expect(page.locator('#modal')).toBeVisible(); let navigations = 0; page.on('framenavigated', () => navigations++); // The "click here" link reloads the page. With force, the overlay gets the click instead. await page.getByRole('link', { name: 'click here' }).click({ force: true }); await page.waitForTimeout(1500); // demo only: give a reload time to happen expect(navigations).toBe(0); }); test('dispatchEvent fires a click with no actionability checks', async ({ page }) => { await page.goto('https://demo.playwright.dev/todomvc'); const newTodo = page.getByPlaceholder('What needs to be done?'); await newTodo.fill('Hidden delete button'); await newTodo.press('Enter'); // .destroy is display:none until hover. A real click() would wait for it. await page.locator('.destroy').dispatchEvent('click'); await expect(page.getByTestId('todo-item')).toHaveCount(0); });
Timeout with no clear reason
If the call log shows the element "visible, enabled and stable" over and over, or never resolves, record a trace and look at the DOM snapshot at the moment of the click. The trace viewer, debugging guide and timeout errors guide cover this.
Why force: true Is a Smell (and When to Use dispatchEvent)
force: true turns off the actionability checks. It looks like a quick fix for the errors above. It isn't:
- Covered elements: the mouse still clicks at the element's coordinates, so whatever is on top receives the click. In the
forcetest above, the "click here" link should reload the page. Withforce: truethe click "succeeded" without error, but the overlay got it and no navigation happened. The test went green while clicking the wrong thing. - Hidden elements:
forcedoesn't help. On the hidden TodoMVC delete button it failed straight away withError: locator.click: Element is not visible. - It hides real bugs. If a user can't click the button because a banner covers it, your test should fail.
locator.dispatchEvent('click') goes further: it fires a click event straight on the element, with no mouse and no checks, "regardless of the visibility state of the element" as the docs put it. The last test in the file above deletes a todo through its hidden delete button this way. Use it only when you deliberately want to bypass the UI, for example to trigger a handler on an element that is never meant to be clicked directly. For a test of what a user can do, use click() and fix whatever stopped it.
A good rule: if you need force or dispatchEvent, write a comment explaining why. If you can't explain it, there is a wait or a locator to fix instead. The flaky tests guide has more patterns.
Frequently Asked Questions
How do I click a button in Playwright?
Find it with a locator and call click(): await page.getByRole('button', { name: 'Save' }).click(). Playwright waits for the button to be visible, enabled, stable and not covered by another element before it clicks.
How do I right-click in Playwright?
Pass the button option: await locator.click({ button: 'right' }). For a middle click use button: 'middle'. If the right-click opens a JavaScript alert, register page.once('dialog', ...) before the click.
How do I double-click in Playwright?
Use locator.dblclick(). It sends two clicks and a dblclick event, which is what apps like TodoMVC listen for to start editing. click({ clickCount: 2 }) also sends two clicks, but dblclick() is clearer.
Why does Playwright say the element intercepts pointer events?
Another element, such as a modal, cookie banner or loading overlay, is on top of the one you want to click. Playwright retries until it times out. Close or wait for the overlay to disappear, for example with await expect(overlay).toBeHidden(), then click.
Should I use force: true in Playwright?
Rarely. force skips the actionability checks, but the mouse still clicks at the element's coordinates, so if something covers it the covering element gets the click. It also fails on hidden elements. Fix the reason the check failed instead.
What is the difference between click() and dispatchEvent('click')?
click() moves the mouse and clicks like a user, after actionability checks. dispatchEvent('click') fires a click event directly on the element with no checks, even if it is hidden. Use it only when you deliberately need to bypass the UI.
Asim Noaman
Senior QA Automation Engineer & AI Testing Specialist
With years of hands-on experience building test automation frameworks for production applications, Asim specializes in combining traditional QA methodologies with cutting-edge AI tools. He has helped teams adopt Playwright and AI-driven testing workflows to ship faster with fewer bugs.
Flaky Tests Start Here
Your Clicks Aren't Flaky. Your Waits Are.
When a click fails only sometimes, the button is rarely the problem. It is an overlay, a re-render, or a test that moved on before the app was ready. The page below shows the waits and click patterns behind most flaky Playwright tests and how the Playwright + Claude AI course teaches you to fix them, in JavaScript.
- Locators, actionability and auto-waiting, explained properly
- Debugging failed clicks with traces and UI Mode
- Claude AI + Playwright MCP Server to find and fix flaky tests
- 4.5★ on Udemy, 510+ students, 30-day money-back guarantee
Ready now? Enroll on Udemy · Know your waits? Take the practice tests