How to Click a Tab Button with Playwright
Use Playwright roles and accessible names to click tabs reliably, handle buttons and iframes, and verify the panel changed.
Use the role and accessible name that the page exposes:
await page.getByRole('tab', { name: 'Settings' }).click();
If the control is exposed as a button, use:
await page.getByRole('button', { name: 'Settings' }).click();
Then assert the state that proves the correct panel became active. A click completing only proves that Playwright performed an action; it does not prove that the intended tab switched.
1. Complete TypeScript example
Install Playwright, create a test file, and run it with the Playwright test runner:
import { test, expect } from '@playwright/test';
test('switches to the Settings tab', async ({ page }) => {
await page.goto('https://example.com/account');
const settingsTab = page.getByRole('tab', { name: 'Settings' });
await settingsTab.click();
await expect(settingsTab).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('tabpanel', { name: 'Settings' })).toBeVisible();
});
The URL in a real test should be the page under test. Playwright’s getByRole() locator matches the accessibility role and accessible name, and Locator.click() performs the click action. See the Playwright locator guide and Locator.click() API.
2. Decide whether the control is a tab or a button
Match the role exposed by the accessibility tree, not the visual styling. A semantic tabs widget commonly has a role="tablist", clickable children with role="tab", and associated role="tabpanel" content. A native <button> or an ARIA button should be located with the button role.
| What the page exposes | Locator | Typical verification |
|---|---|---|
| Semantic tab | getByRole('tab', { name }) |
aria-selected="true" and visible tabpanel |
| Native or ARIA button | getByRole('button', { name }) |
Visible content, changed heading, URL, or application state |
Inspect the DOM and accessibility tree when unsure. The same text can appear in several controls, so provide an accessible name and narrow the locator to the intended tablist when necessary.
Scope to a tablist
const accountTabs = page.getByRole('tablist', { name: 'Account sections' });
await accountTabs.getByRole('tab', { name: 'Settings' }).click();
await expect(accountTabs.getByRole('tab', { name: 'Settings' }))
.toHaveAttribute('aria-selected', 'true');
Use exact names when labels overlap
await page.getByRole('tab', { name: 'Settings', exact: true }).click();
Prefer a stable, user-facing name. Avoid selecting by a generated CSS class or DOM position when a role and name are available.
3. Verify that the tab switched
Choose an assertion tied to the application’s observable state. Common choices are:
Assert the selected state
const tab = page.getByRole('tab', { name: 'Settings' });
await tab.click();
await expect(tab).toHaveAttribute('aria-selected', 'true');
Assert the panel
await page.getByRole('tab', { name: 'Settings' }).click();
await expect(page.getByRole('tabpanel', { name: 'Settings' })).toBeVisible();
Assert content unique to the panel
await page.getByRole('tab', { name: 'Settings' }).click();
await expect(page.getByText('Notification preferences')).toBeVisible();
If the page does not expose aria-selected or an accessible panel name, assert a stable heading, field, URL change, or application-owned test id. The assertion should distinguish the new state from the old state.
4. Tabs inside an iframe
Locators on the main page cannot reach elements inside a frame. Scope through frameLocator():
const widget = page.frameLocator('iframe[title="Account settings"]');
await widget.getByRole('tab', { name: 'Settings' }).click();
await expect(widget.getByRole('tabpanel', { name: 'Settings' })).toBeVisible();
Use a stable iframe selector such as an accessible title, owned test id, or stable name. If the frame is attached dynamically, wait for the frame element or the tab inside it to be available before acting.
5. Common tab implementations and locator patterns
Native button tabs
<button type="button" aria-selected="false">Settings</button>
await page.getByRole('button', { name: 'Settings' }).click();
ARIA tabs
<div role="tablist">
<button role="tab" aria-selected="false" aria-controls="settings-panel">Settings</button>
</div>
<section id="settings-panel" role="tabpanel" aria-label="Settings">...</section>
const settings = page.getByRole('tab', { name: 'Settings' });
await settings.click();
await expect(settings).toHaveAttribute('aria-selected', 'true');
Several tabs with the same label
const billing = page.getByRole('tablist', { name: 'Plans' })
.getByRole('tab', { name: 'Billing', exact: true });
await billing.click();
Fallback to an intentional test id
await page.getByTestId('settings-tab').click();
await expect(page.getByTestId('settings-panel')).toBeVisible();
Use a test id when the application has no usable semantic role or accessible name. Keep the id stable and owned by the application rather than selecting an incidental class or parent-child position.
6. Waiting, animation, and asynchronous panels
Playwright waits for actionability before clicking, including visibility, stability, and the ability to receive pointer events. After the click, let an assertion wait for the panel’s state instead of adding an arbitrary delay.
await page.getByRole('tab', { name: 'Reports' }).click();
await expect(page.getByRole('tabpanel', { name: 'Reports' })).toBeVisible();
await expect(page.getByRole('tabpanel', { name: 'Reports' }))
.toContainText('Latest report');
For a panel that loads data, wait for a meaningful loading indicator to disappear or for the final content to appear. Use page.waitForTimeout() only when reproducing a timing-specific behavior; fixed sleeps make tests slower and less reliable.
7. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| “Locator resolved to 0 elements” | The role, name, frame, or page state is wrong. | Inspect the accessibility tree and DOM; wait for the widget; scope through the correct frame. |
| Strict mode violation | More than one control matches. | Add an accessible name, exact: true, or scope to the intended tablist. |
| Click intercepted | An overlay, consent banner, or animation covers the control. | Wait for the overlay to close, handle the consent flow, or use a locator for the visible control. Avoid forcing the click unless the test specifically covers an obstructed interaction. |
| Click succeeds but panel assertion fails | The wrong role was chosen, the app updates asynchronously, or the panel has no matching accessible name. | Verify the exposed role, assert the actual selected state, and wait for unique panel content. |
| Works locally, fails in CI | Different viewport, timing, authentication, or responsive markup. | Use role-based locators, set the required context explicitly, and assert state rather than coordinates. |
| Tab is inside an iframe | Main-page locators do not cross frame boundaries. | Use page.frameLocator(...) and locate the tab within that frame. |
| Text changes by locale | The accessible name is translated. | Set the test locale or use a stable test id while retaining a state assertion. |
8. Reliability and maintenance checklist
- Use
getByRole()with an accessible name first. - Match
tabversusbuttonto the exposed semantics. - Scope repeated controls to their tablist, dialog, or frame.
- Assert
aria-selected, panel visibility, or unique panel content after clicking. - Prefer Playwright’s web-first assertions over fixed sleeps.
- Keep a test id as a deliberate fallback when semantics are unavailable.
- Run the test at the viewport, locale, authentication state, and device profile your users require.
9. Or skip the browser setup
If the goal is a static screenshot of a tabbed page rather than an interaction test, ScreenshotNeo can capture the page with one request. Use custom JavaScript to click the tab before capture, or use a selector and wait option through the API. The API supports full-page and element captures, custom CSS and JavaScript, clicks, waits, device presets, cookies, headers, blocking rules, caching, PDFs, and bulk capture. See the ScreenshotNeo documentation for parameter names and response details.
cURL
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
Node.js
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const image = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', image));
ScreenshotNeo accepts consent banners as a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each step can be turned off. Bot checks, CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. The Free plan includes 1,000 shots per month without a card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
10. Performance, reliability, and cost notes
Role locators and web-first assertions keep Playwright tests aligned with user-visible behavior and avoid unnecessary retries. Narrowing a locator to a tablist or frame also prevents strict-mode failures as pages grow. For slow panels, wait for the panel’s final state rather than using a global timeout.
ScreenshotNeo charges only for clean shots. Cache hits are free, and you can choose a cache TTL. For large jobs, asynchronous capture with signed webhooks and bulk requests of up to 100 URLs can reduce client-side waiting. Use the usage API to monitor consumption, and inspect verdict and billing headers when diagnosing a response.
FAQ
Should I use getByRole('tab') or getByRole('button')?
Use the role exposed by the page’s accessibility tree. Visual appearance does not determine the role.
Is click() enough to test a tab?
No. Follow it with an assertion on the selected state, visible tabpanel, or unique panel content.
How do I click a tab in an iframe?
Use page.frameLocator('iframe-selector').getByRole(...), then assert the panel inside the same frame.
What if the tab has no accessible name?
Fix the application semantics when possible. Otherwise use an intentional stable test id and verify the resulting panel state.
Can ScreenshotNeo test that a tab works?
Playwright is the right tool for interaction assertions. ScreenshotNeo is useful when you need a rendered image or PDF after preparing the page with custom JavaScript.


