FRAMEWORK DESIGNINTERMEDIATE

Playwright Component Object Model (COM): Build Reusable UI Components for Scalable Test Automation

Learn how to design a Playwright Component Object Model (COM) to build reusable UI components like headers, modals, tables, and forms — a scalable evolution of the Page Object Model used by enterprise SDET teams.

iff Solution Academy July 28, 2026 15 min read Updated July 28, 2026
Playwright Component Object Model COM Page Object Model Framework Design TypeScript Reusable Components Enterprise Framework

Introduction

Modern web applications are built from reusable UI components — headers, navigation bars, modals, tables, forms, date pickers, dropdowns. The same header appears on 40 pages. The same confirmation modal appears in 20 flows. The same data table pattern repeats across every list screen.

If you model these with the traditional Page Object Model (POM), you end up duplicating the header, modal, and table locators inside every page class. The moment a designer adds a new button to the header, you touch 40 files. The Component Object Model (COM) fixes this by treating each reusable UI block as its own class — scoped to a root locator — that any Page Object can compose.

In this tutorial you'll learn what COM is, when to use it, how it differs from POM, and how to design production-grade Header, Modal, Table, and Form components in Playwright with TypeScript.

What is the Component Object Model?

The Component Object Model is a design pattern where each reusable UI region of your application is modeled as a small, self-contained class. Each component receives a root locator, and every locator inside the component is scoped to that root. This means the same component can appear multiple times on a page (e.g. multiple rows, multiple cards) without any locator collisions.

text
Page (LoginPage, DashboardPage, CheckoutPage)
   │
   ├── HeaderComponent   (scoped to <header>)
   ├── ModalComponent    (scoped to [role="dialog"])
   ├── TableComponent    (scoped to <table>)
   └── FormComponent     (scoped to <form>)

COM vs POM

POM models pages. COM models reusable UI blocks inside pages. They are complementary, not competing — enterprise frameworks use both.

  • POM: one class per screen (LoginPage, DashboardPage).
  • COM: one class per reusable UI block (Header, Sidebar, Modal, Table).
  • Pages compose components — a DashboardPage owns a HeaderComponent and a TableComponent.
  • COM eliminates duplicated locators across 20-40 pages that share the same UI regions.

When Should You Use COM?

  • The same UI block (header, footer, modal, table, card) repeats on many pages.
  • Your Page Objects have grown to 500+ lines and mix page-specific and shared logic.
  • Multiple engineers keep re-implementing the same modal / table interactions.
  • Design system changes force you to touch dozens of page files.

Project Structure

text
playwright-framework/
│
├── components/
│   ├── BaseComponent.ts
│   ├── HeaderComponent.ts
│   ├── ModalComponent.ts
│   ├── TableComponent.ts
│   └── FormComponent.ts
│
├── pages/
│   ├── BasePage.ts
│   ├── DashboardPage.ts
│   └── CheckoutPage.ts
│
├── fixtures/
├── tests/
└── utils/

Building a Base Component

Every component extends a BaseComponent that stores its root locator and exposes a scoped locator() helper. This is the single most important idea in COM — never use page.locator() inside a component; always scope through the root.

components/BaseComponent.ts
import { Locator, Page } from '@playwright/test';

export abstract class BaseComponent {
  protected readonly page: Page;
  protected readonly root: Locator;

  constructor(page: Page, root: Locator) {
    this.page = page;
    this.root = root;
  }

  protected locator(selector: string): Locator {
    return this.root.locator(selector);
  }

  async isVisible(): Promise<boolean> {
    return this.root.isVisible();
  }
}

Header Component

The header is the classic COM candidate — it appears on nearly every page, contains navigation, search, and the user menu, and is a nightmare to duplicate across 40 Page Objects.

components/HeaderComponent.ts
import { Page } from '@playwright/test';
import { BaseComponent } from './BaseComponent';

export class HeaderComponent extends BaseComponent {
  constructor(page: Page) {
    super(page, page.locator('header'));
  }

  private readonly logo = this.locator('[data-testid="logo"]');
  private readonly searchInput = this.locator('[data-testid="search"]');
  private readonly userMenu = this.locator('[data-testid="user-menu"]');
  private readonly logoutBtn = this.locator('[data-testid="logout"]');

  async search(query: string): Promise<void> {
    await this.searchInput.fill(query);
    await this.searchInput.press('Enter');
  }

  async logout(): Promise<void> {
    await this.userMenu.click();
    await this.logoutBtn.click();
  }

  async goHome(): Promise<void> {
    await this.logo.click();
  }
}

Modals reuse the same open/close/confirm/cancel pattern everywhere. Scope to [role="dialog"] and you can drive any modal in the app.

components/ModalComponent.ts
import { Page, expect } from '@playwright/test';
import { BaseComponent } from './BaseComponent';

export class ModalComponent extends BaseComponent {
  constructor(page: Page) {
    super(page, page.locator('[role="dialog"]'));
  }

  private readonly title = this.locator('[data-testid="modal-title"]');
  private readonly confirmBtn = this.locator('[data-testid="confirm"]');
  private readonly cancelBtn = this.locator('[data-testid="cancel"]');
  private readonly closeBtn = this.locator('[aria-label="Close"]');

  async waitForOpen(): Promise<void> {
    await expect(this.root).toBeVisible();
  }

  async getTitle(): Promise<string> {
    return (await this.title.textContent())?.trim() ?? '';
  }

  async confirm(): Promise<void> { await this.confirmBtn.click(); }
  async cancel(): Promise<void> { await this.cancelBtn.click(); }
  async close(): Promise<void> { await this.closeBtn.click(); }
}

Table Component

Data tables are the highest-value component to model. Every list screen shares the same read-row / sort-column / filter interactions.

components/TableComponent.ts
import { Locator, Page } from '@playwright/test';
import { BaseComponent } from './BaseComponent';

export class TableComponent extends BaseComponent {
  constructor(page: Page, root: Locator) {
    super(page, root);
  }

  private readonly rows = this.locator('tbody tr');
  private readonly headers = this.locator('thead th');

  async getRowCount(): Promise<number> {
    return this.rows.count();
  }

  async getCell(row: number, column: number): Promise<string> {
    const cell = this.rows.nth(row).locator('td').nth(column);
    return (await cell.textContent())?.trim() ?? '';
  }

  async sortBy(columnName: string): Promise<void> {
    await this.headers.filter({ hasText: columnName }).click();
  }

  async findRowByText(text: string): Promise<Locator> {
    return this.rows.filter({ hasText: text });
  }
}

Form Component

Forms are ideal for COM because every form shares fill / submit / reset patterns. Extend this base FormComponent for LoginForm, CheckoutForm, ProfileForm, etc.

components/FormComponent.ts
import { Locator, Page } from '@playwright/test';
import { BaseComponent } from './BaseComponent';

export class FormComponent extends BaseComponent {
  constructor(page: Page, root: Locator) {
    super(page, root);
  }

  async fillField(label: string, value: string): Promise<void> {
    await this.root.getByLabel(label).fill(value);
  }

  async selectOption(label: string, value: string): Promise<void> {
    await this.root.getByLabel(label).selectOption(value);
  }

  async submit(): Promise<void> {
    await this.root.getByRole('button', { name: /submit|save|continue/i }).click();
  }

  async reset(): Promise<void> {
    await this.root.getByRole('button', { name: /reset|clear/i }).click();
  }
}

Composing Pages with Components

Pages own components as fields. The page's job shrinks to page-specific logic; anything shared moves into a component.

pages/DashboardPage.ts
import { Page } from '@playwright/test';
import { BasePage } from './BasePage';
import { HeaderComponent } from '../components/HeaderComponent';
import { ModalComponent } from '../components/ModalComponent';
import { TableComponent } from '../components/TableComponent';

export class DashboardPage extends BasePage {
  readonly header: HeaderComponent;
  readonly modal: ModalComponent;
  readonly ordersTable: TableComponent;

  constructor(page: Page) {
    super(page);
    this.header = new HeaderComponent(page);
    this.modal = new ModalComponent(page);
    this.ordersTable = new TableComponent(page, page.locator('[data-testid="orders-table"]'));
  }

  async open(): Promise<void> {
    await this.navigate('/dashboard');
  }
}

Using Components in Tests

Tests read almost like plain English — no locator noise, no duplication, no framework internals.

tests/dashboard.spec.ts
import { test, expect } from '@playwright/test';
import { DashboardPage } from '../pages/DashboardPage';

test('user can search and open an order', async ({ page }) => {
  const dashboard = new DashboardPage(page);
  await dashboard.open();

  await dashboard.header.search('ORD-1042');
  expect(await dashboard.ordersTable.getRowCount()).toBeGreaterThan(0);

  const row = await dashboard.ordersTable.findRowByText('ORD-1042');
  await row.click();

  await dashboard.modal.waitForOpen();
  expect(await dashboard.modal.getTitle()).toContain('Order ORD-1042');
});

Best Practices

  • Every component MUST scope through a root locator. Never call page.locator() inside a component.
  • Keep components small — one component per UI concern (Header, Modal, Table, Form).
  • Return Locators from finders (findRowByText) so callers can chain assertions.
  • Use data-testid attributes for stable selectors across releases.
  • Extend a BaseComponent so every component gets the same scoping and helper API.
  • Compose components on pages; don't inherit pages from components.

Common Mistakes

  • Using page.locator() inside a component — breaks scoping when the component appears twice.
  • Putting page-specific logic (e.g. "open dashboard") inside a Header component.
  • Making components stateful — components should be thin wrappers around locators.
  • Creating a giant "CommonComponent" that owns everything. Split by UI concern.
  • Forgetting to await async methods when composing multiple component actions.

Frequently Asked Questions

Does COM replace POM?

No. COM complements POM. Pages still exist and represent screens; components represent reusable UI blocks inside those screens.

When is COM worth the effort?

Once you have 10+ pages that share headers, modals, tables, or forms. For a 3-page smoke suite, plain POM is enough.

How do I handle multiple instances of the same component?

Instantiate the component with a different root locator each time — e.g. new TableComponent(page, page.locator('#orders')) and new TableComponent(page, page.locator('#invoices')).

Conclusion

The Component Object Model is the natural next step after POM. It removes duplication, matches how modern applications are actually built (as components), and lets a small QA team maintain hundreds of tests across dozens of pages without drowning in locator changes. Start by extracting your Header, Modal, and Table into components — you'll feel the maintenance benefits within the first sprint.

  • Playwright Page Object Model (POM)
  • Playwright Base Page Design
  • Custom Locator Helper Functions
  • Enterprise Playwright Framework Architecture
  • Playwright Fixtures Explained
  • Playwright Best Practices

Keywords: Playwright Component Object Model, Playwright COM, COM vs POM, reusable UI components Playwright, Playwright framework design, Playwright TypeScript, enterprise Playwright framework, scalable test automation, Playwright best practices.

Get Playwright tutorials in your inbox

Weekly tips, real-world examples, and framework patterns – no spam, unsubscribe anytime.

Playwright Framework Series

View all →
  1. 1Getting Started with Playwright: Installation, Setup, and Your First Test
  2. 2Playwright Locators: The Complete Guide with Real-World Examples
  3. 3Playwright Actions: Complete Guide to Click, Fill, Hover, Keyboard, Mouse & File Upload
  4. 4Playwright Assertions: The Complete Guide with Real-World Examples
  5. 5Playwright Auto Waiting: The Complete Guide with Real-World Examples
  6. 6Playwright Fixtures: The Complete Guide with Real-World Examples
  7. 7Playwright Browser Context & Multiple Tabs: The Complete Guide with Real-World Examples
  8. 8Playwright Authentication & Session Management: Complete Guide with Enterprise Examples
  9. 9Playwright Network Interception & API Mocking: Complete Guide with Real-World Examples
  10. 10Playwright Page Object Model (POM): The Complete Guide with Enterprise Examples
  11. 11Page Object Model with Playwright: A Practical Guide
  12. 12How to Build a Robust Professional Playwright Framework from Scratch (Step-by-Step)
  13. 13Building an Enterprise Playwright Framework from Scratch

API Testing Series

View all →
  1. 1Playwright API Testing: The Complete Guide with Real-World Examples
  2. 2Part 1 : Playwright API Testing Tutorial: Build Enterprise-Level API Automation Framework
  3. 3Part 2: Playwright API Testing Tutorial: Setting Up Project & Sending Your First API Request
  4. 4Part 3: Mastering CRUD Operations in Playwright API Testing
  5. 5Part 4: Authentication in Playwright API Testing (Bearer Token, JWT, API Key & Reusable Fixtures)
  6. 6Part 5: Building an Enterprise-Level Playwright API Automation Framework
  7. 7Part 6: API Models, Schema Validation & Test Data Management in Playwright
  8. 8Part 7: Custom Fixtures, Hooks, Logging & Parallel Execution in Playwright API Testing
  9. 9Part 8: Building a Production-Ready Playwright API Framework (Environment Management, CI/CD, Reporting & Best Practices)
  10. 10Part 9: Advanced Playwright API Testing Techniques for Enterprise Automation
  11. 11Part 10: Building a Complete Enterprise Playwright API Automation Framework (Final Part)
  12. 12API Testing with Playwright: Request Context, Auth, and Assertions

Recommended Next Articles