PRACTICAL GUIDE / playwright page component object

Playwright Page Component Objects for Shared Navigation, Tables, and Modals

Design Playwright page component objects for shared navigation, tables, and modals with semantic locators, fixtures, composition, and focused assertions.

By The Testing AcademyUpdated July 11, 20269 min read
All field guides
In this guide12 sections
  1. Model UI Ownership, Not Screenshot Boundaries
  2. Root Every Component at a Stable Region
  3. Compose a Thin Page Shell
  4. Give Repeating Rows Their Own Identity
  5. Keep Modal Lifecycles Explicit
  6. Inject Components Through Focused Fixtures
  7. Place Assertions at the Right Layer
  8. Keep Components Stateless Across Renders
  9. Diagnose Architecture Failures
  10. Evaluate the Tradeoffs
  11. Operational Checklist
  12. Conclusion: Compose Around Stable Regions

What you will learn

  • Model UI Ownership, Not Screenshot Boundaries
  • Root Every Component at a Stable Region
  • Compose a Thin Page Shell
  • Give Repeating Rows Their Own Identity

A single page object that owns the header, filters, table, toast, modal, and every possible workflow becomes a second application hidden inside the test suite. The durable alternative is a Playwright page component object: a small model with one stable UI root, semantic locators beneath that root, and methods that express what a user does with that region.

Composition matters most when the same navigation bar appears on twenty pages, the same confirmation dialog serves several destructive actions, or a table has enough behavior to deserve its own vocabulary. Tests gain a readable API without losing Playwright's live locators, strictness, traces, or web-first assertions.

Model UI Ownership, Not Screenshot Boundaries

The official Playwright page object guide describes page objects as higher-level APIs that capture selectors and reusable operations. A component object applies that idea to part of a page. Its boundary should follow ownership in the product: global navigation, an order grid, a billing panel, or a dialog with a defined lifecycle.

A useful component has three properties. It has a root that uniquely identifies the region. Its child locators are relative to that root. Its public methods use product terms such as openAccountMenu, rowForOrder, or confirmDeletion. It should not know unrelated routes, seed databases, or decide what an entire test must assert.

Animated field map

Composed Playwright Component Objects

A page shell wires shared UI regions to semantic locators, domain actions, and assertions that remain close to the scenario.

  1. 01 / page shell

    Page Shell

    Own route-level navigation and compose the regions present on the page.

  2. 02 / shared component

    Shared Component

    Model one navigation, table, panel, or modal boundary.

  3. 03 / semantic locators

    Semantic Locators

    Resolve controls by role, label, text, or an explicit test contract.

  4. 04 / domain action

    Domain Action

    Expose an operation whose name reflects the user's intent.

  5. 05 / focused assertion

    Focused Assertion

    Verify the scenario outcome without hiding broad expectations in helpers.

Root Every Component at a Stable Region

Passing a Locator root into a component is stronger than passing the whole Page and searching globally. The root makes accidental cross-component matches harder and lets the same class model repeated instances. A dialog object can be constructed from a named dialog; an order row object can be constructed from one filtered row.

TypeScript
import { expect, type Locator, type Page } from "@playwright/test";

export class TopNavigation {
  constructor(private readonly root: Locator) {}

  async openAccountMenu(): Promise<void> {
    await this.root.getByRole("button", { name: "Account" }).click();
    await expect(this.root.getByRole("menu", { name: "Account" }))
      .toBeVisible();
  }

  async signOut(): Promise<void> {
    await this.openAccountMenu();
    await this.root.getByRole("menuitem", { name: "Sign out" }).click();
  }
}

export function topNavigation(page: Page): TopNavigation {
  return new TopNavigation(page.getByRole("navigation", { name: "Primary" }));
}

The visibility assertion belongs inside openAccountMenu because an open menu is the method's promised result. A test that signs out should still assert the route, signed-out banner, or session behavior that matters to its scenario.

Compose a Thin Page Shell

A page shell owns route-level entry and exposes the components present on that route. It should remain small enough that a reader can see the composition at a glance. Avoid inheriting every page from a large base class; explicit component fields make dependencies and reuse clearer.

TypeScript
import { type Page } from "@playwright/test";
import { ConfirmModal } from "./components/confirm-modal";
import { OrdersTable } from "./components/orders-table";
import { TopNavigation } from "./components/top-navigation";

export class OrdersPage {
  readonly navigation: TopNavigation;
  readonly orders: OrdersTable;
  readonly deleteDialog: ConfirmModal;

  constructor(private readonly page: Page) {
    this.navigation = new TopNavigation(
      page.getByRole("navigation", { name: "Primary" }),
    );
    this.orders = new OrdersTable(page.getByRole("grid", { name: "Orders" }));
    this.deleteDialog = new ConfirmModal(
      page.getByRole("dialog", { name: "Delete order" }),
    );
  }

  async goto(): Promise<void> {
    await this.page.goto("/orders");
  }
}

This shell does not proxy every method. Tests can call ordersPage.orders.openActionsFor(...) directly, which keeps ownership visible. Add a route-level workflow only when it genuinely coordinates multiple components and appears across enough tests to justify the abstraction.

Give Repeating Rows Their Own Identity

Tables are a common source of component-object overreach. Do not cache all row elements in the constructor or model rows by numeric position. Return a row object whose root is a live locator filtered by an immutable key. Every later action will resolve against the current DOM, including after sorting or virtualization.

TypeScript
import { expect, type Locator } from "@playwright/test";

class OrderRow {
  constructor(private readonly root: Locator) {}

  async expectStatus(status: string): Promise<void> {
    await expect(this.root.getByRole("gridcell", { name: status, exact: true }))
      .toBeVisible();
  }

  async requestDeletion(): Promise<void> {
    await this.root.getByRole("button", { name: "Order actions" }).click();
    await this.root.getByRole("menuitem", { name: "Delete" }).click();
  }
}

export class OrdersTable {
  constructor(private readonly root: Locator) {}

  row(orderId: string): OrderRow {
    const matchingRow = this.root.getByRole("row").filter({
      has: this.root.getByRole("gridcell", { name: orderId, exact: true }),
    });
    return new OrderRow(matchingRow);
  }

  async expectOneRow(orderId: string): Promise<void> {
    const row = this.root.getByRole("row").filter({ hasText: orderId });
    await expect(row).toHaveCount(1);
  }
}

If the grid does not expose usable roles, introduce a deliberate test id at the row key. Hiding a brittle generated CSS selector inside a class centralizes the breakage but does not make the contract stable.

Keep Modal Lifecycles Explicit

Modal actions need two boundaries: prove the correct dialog is active, then operate inside it. A global getByRole("button", { name: "Delete" }) can match a control behind the overlay or a second dialog. Rooting by role and accessible name prevents that ambiguity.

TypeScript
import { expect, type Locator } from "@playwright/test";

export class ConfirmModal {
  constructor(private readonly root: Locator) {}

  async confirmFor(subject: string): Promise<void> {
    await expect(this.root).toBeVisible();
    await expect(this.root).toContainText(subject);
    await this.root.getByRole("button", { name: "Confirm delete" }).click();
    await expect(this.root).toBeHidden();
  }

  async cancel(): Promise<void> {
    await this.root.getByRole("button", { name: "Cancel" }).click();
    await expect(this.root).toBeHidden();
  }
}

Checking the subject guards against confirming the wrong destructive operation. The test should then verify the persistent outcome, such as the order disappearing from the table or an audit entry being created.

Inject Components Through Focused Fixtures

A fixture can create a page shell or shared component without hiding the scenario's navigation. Keep construction cheap and deterministic. If a fixture also logs in, seeds data, and navigates, its name and scope should make those side effects obvious.

TypeScript
import { test as base } from "@playwright/test";
import { OrdersPage } from "./orders-page";

type UiFixtures = {
  ordersPage: OrdersPage;
};

export const test = base.extend<UiFixtures>({
  ordersPage: async ({ page }, use) => {
    await use(new OrdersPage(page));
  },
});

export { expect } from "@playwright/test";

The test remains direct: call goto, identify the order row, request deletion, confirm the named order, and assert the table result. The fixture removes constructor repetition while preserving the sequence in the report.

Place Assertions at the Right Layer

Component methods may assert their immediate contract: a menu opened, a dialog closed, or the requested row is unique. Broad business outcomes belong in the spec. Otherwise a method named submitRefund may silently assert five unrelated dashboard widgets and make a failure difficult to interpret.

Expose locators selectively when tests need varied assertions. A public readonly locator for a stable component output can be appropriate. Avoid exposing every internal locator, because callers will bypass domain methods and couple themselves to markup. A small observation method such as totalText() may be clearer when the raw element has no reusable meaning.

Keep Components Stateless Across Renders

A component object should retain locator recipes and immutable configuration, not a private copy of what the UI looked like earlier. Do not set this.isOpen = true after clicking a menu or cache the text of a selected row for later assertions. Another action, websocket update, route transition, or React render can invalidate that memory while the object continues to claim it is current.

Ask the page whenever state matters. A method such as isExpanded() may read aria-expanded, and selectedOrderId() may read the selected row at the moment of use. If a workflow needs to compare before and after values, keep those snapshots in the test so the temporal assertion remains visible. Stateless component objects can be reused safely after rerenders and make traces easier to reconcile with source.

Diagnose Architecture Failures

If one change touches many component classes, their roots overlap or the product boundary was split incorrectly. If a class needs conditionals for several unrelated pages, extract smaller components or pass a more precise root. If tests constantly reach into private fields, the public API is missing an important operation or observation.

Strictness errors inside a component usually reveal an insufficient root or missing accessible name. Detached-element errors suggest an ElementHandle or DOM value was cached instead of a locator. Slow tests may indicate that constructors or fixtures perform hidden navigation. Trace steps should make the component action understandable without opening its source.

Evaluate the Tradeoffs

Component objects add files and names, so they are not automatically worthwhile for a one-off control. Their value rises with stable ownership, repeated behavior, and multiple instances that benefit from rooted locators. Function helpers can be better for stateless operations; classes work well when several methods share a root and vocabulary.

Assertions inside components improve local contracts but can conceal scenario intent when overused. Fixture injection standardizes construction but can hide side effects if it performs too much. Prefer the smallest structure that makes ownership and failure evidence clearer.

Operational Checklist

  • Choose a component boundary that product developers and users can name.
  • Require one stable root locator for every component instance.
  • Resolve child controls relative to that root.
  • Use roles and accessible names before structural selectors.
  • Model repeated records by immutable identity, not row index.
  • Keep route navigation in a thin page shell.
  • Assert immediate action contracts inside the component only when useful.
  • Leave scenario outcomes visible in the test.
  • Use fixtures for construction without hiding costly setup.
  • Review traces to ensure method names and failures remain understandable.

Conclusion: Compose Around Stable Regions

Start with the shared regions that already cause duplication: primary navigation, one complex table, and one modal family. Give each a unique root and a short domain API, compose them in a thin page shell, and keep the final business assertion in the spec. That structure captures reuse without turning the automation layer into an opaque replica of the application.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 11, 2026 / Reviewed July 11, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Playwright documentation

    Microsoft

    Canonical API, locator, fixture, browser, and test-runner behavior.

  2. 02
    Playwright best practices

    Microsoft

    Official guidance for resilient tests, isolation, and user-facing locators.

  3. 03
    WebDriver standard

    W3C

    The browser automation protocol specification used by major automation stacks.

FAQ / QUICK ANSWERS

Questions testers ask

What is a page component object in Playwright?

It is a small model for a reusable UI region such as navigation, a table, or a modal. The object owns that region's root locator and exposes actions or observations in domain language.

Should component objects contain Playwright assertions?

Use focused assertions when they are part of the component's action contract, such as confirming that a modal opened before clicking its destructive button. Keep scenario-specific outcomes in the test so failures still explain the business expectation.

How should a modal object be located?

Root it at a unique dialog locator, usually by role and accessible name, then locate buttons and content within that root. This prevents similarly named controls behind the modal from matching.

Can a page object compose multiple component objects?

Yes. A thin page shell can create navigation, table, filter, and modal components that share the same Page while retaining separate ownership boundaries.

When is a component object too small?

A wrapper that only renames one obvious locator without adding reuse, scoping, or domain meaning usually adds indirection. Extract a component when it has a stable root and meaningful behavior used across scenarios or pages.