A header appears on every page. A sidebar appears on every dashboard page. A confirmation modal pops up on multiple actions. Do you duplicate these locators in every page object? No. You extract them into components. Components are small, focused classes that represent a reusable piece of UI.
import { type Locator, type Page, expect } from '@playwright/test';
export class HeaderComponent {
readonly page: Page;
readonly logo: Locator;
readonly homeLink: Locator;
readonly profileLink: Locator;
readonly logoutButton: Locator;
readonly notificationBadge: Locator;
readonly searchInput: Locator;
constructor(page: Page) {
this.page = page;
// Scope all locators to the header element
const header = page.getByRole('banner');
this.logo = header.getByRole('img', { name: /logo/i });
this.homeLink = header.getByRole('link', { name: 'Home' });
this.profileLink = header.getByRole('link', { name: 'Profile' });
this.logoutButton = header.getByRole('button', { name: 'Logout' });
this.notificationBadge = header.getByTestId('notification-badge');
this.searchInput = header.getByPlaceholder('Search...');
}
async navigateHome() {
await this.homeLink.click();
}
async navigateToProfile() {
await this.profileLink.click();
}
async logout() {
await this.logoutButton.click();
}
async search(term: string) {
await this.searchInput.fill(term);
await this.searchInput.press('Enter');
}
async getNotificationCount(): Promise<string> {
return (await this.notificationBadge.textContent()) ?? '0';
}
}import { type Locator, type Page } from '@playwright/test';
export class SidebarComponent {
readonly page: Page;
readonly dashboardLink: Locator;
readonly transferLink: Locator;
readonly statementsLink: Locator;
readonly settingsLink: Locator;
readonly menuItems: Locator;
constructor(page: Page) {
this.page = page;
const sidebar = page.getByRole('navigation', { name: 'Sidebar' });
this.dashboardLink = sidebar.getByRole('link', { name: 'Dashboard' });
this.transferLink = sidebar.getByRole('link', { name: 'Transfer' });
this.statementsLink = sidebar.getByRole('link', { name: 'Statements' });
this.settingsLink = sidebar.getByRole('link', { name: 'Settings' });
this.menuItems = sidebar.getByRole('link');
}
async navigateTo(linkName: string) {
await this.page.getByRole('navigation', { name: 'Sidebar' })
.getByRole('link', { name: linkName }).click();
}
async getMenuItemCount(): Promise<number> {
return this.menuItems.count();
}
}import { type Locator, type Page, expect } from '@playwright/test';
export class ModalComponent {
readonly page: Page;
readonly dialog: Locator;
readonly title: Locator;
readonly body: Locator;
readonly confirmButton: Locator;
readonly cancelButton: Locator;
readonly closeButton: Locator;
constructor(page: Page) {
this.page = page;
this.dialog = page.getByRole('dialog');
this.title = this.dialog.getByRole('heading');
this.body = this.dialog.locator('.modal-body');
this.confirmButton = this.dialog.getByRole('button', { name: 'Confirm' });
this.cancelButton = this.dialog.getByRole('button', { name: 'Cancel' });
this.closeButton = this.dialog.getByRole('button', { name: 'Close' });
}
async expectOpen() {
await expect(this.dialog).toBeVisible();
}
async expectClosed() {
await expect(this.dialog).not.toBeVisible();
}
async confirm() {
await this.confirmButton.click();
}
async cancel() {
await this.cancelButton.click();
}
async close() {
await this.closeButton.click();
}
async getTitle(): Promise<string> {
return (await this.title.textContent()) ?? '';
}
}import { type Locator, type Page, expect } from '@playwright/test';
import { BasePage } from './base.page';
import { HeaderComponent } from './components/header.component';
import { SidebarComponent } from './components/sidebar.component';
import { ModalComponent } from './components/modal.component';
export class DashboardPage extends BasePage {
// Compose with components
readonly header: HeaderComponent;
readonly sidebar: SidebarComponent;
readonly confirmModal: ModalComponent;
// Page-specific locators
readonly balanceDisplay: Locator;
readonly transferButton: Locator;
constructor(page: Page) {
super(page);
// Initialize components
this.header = new HeaderComponent(page);
this.sidebar = new SidebarComponent(page);
this.confirmModal = new ModalComponent(page);
// Page-specific locators
this.balanceDisplay = page.getByTestId('balance');
this.transferButton = page.getByRole('button', { name: 'Transfer' });
}
}
// Usage in tests:
// await dashboardPage.header.logout();
// await dashboardPage.sidebar.navigateTo('Statements');
// await dashboardPage.confirmModal.confirm();Scope component locators to their container element. In HeaderComponent, all locators are scoped to page.getByRole('banner'). This prevents accidentally matching a "Logout" button in the sidebar instead of the header. Scoping makes components reliable.
| Component Type | When to Extract | Example |
|---|---|---|
| Header | Appears on every authenticated page | HeaderComponent -- logo, nav links, logout |
| Sidebar | Appears on multiple pages in a section | SidebarComponent -- navigation menu |
| Modal | Same confirm/cancel pattern across pages | ModalComponent -- confirm, cancel, close |
| Table | Same table structure on multiple pages | DataTableComponent -- sort, filter, paginate |
| Form | Similar form fields used in multiple contexts | AddressFormComponent -- street, city, zip |
Do not extract a component for something that appears on only one page. A login form is used only on the login page -- it belongs in LoginPage, not in a separate LoginFormComponent. Extract components only when reuse is real, not theoretical.
Q: How do you handle UI components that appear on multiple pages?
A: I extract them into component classes. A HeaderComponent for the header, SidebarComponent for the sidebar, ModalComponent for confirmation dialogs. Each component scopes its locators to its container element -- the header component only looks inside the banner element. Page objects compose these components as properties: this.header = new HeaderComponent(page). Tests access them through the page object: dashboardPage.header.logout(). This way, the header is defined once and reused across DashboardPage, TransferPage, SettingsPage, and any other page that has a header.
Key Point: Extract repeated UI sections into component classes. Scope locators to the container element. Page objects compose components as properties. Extract only when reuse is real.