Playwright Component Testing

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

A component test checks one piece of a user interface — a rating widget, an expandable description, a form field — in a real browser, without starting the whole application and without logging in or navigating to the right page first. Playwright 1.62 replaced its old experimental component-testing packages with a much smaller idea: the test runner stays exactly what it is, and the component is rendered by a small page of your own, the gallery, that the built-in mount fixture drives. This page explains that model, the contract the gallery must fulfil, every option involved, how to write and type stories, and how to leave the discontinued @playwright/experimental-ct-* packages behind. It is written for Playwright 1.64.1 (Node.js) and was checked on 25 October 2026. Component testing is documented for the Node.js (JavaScript and TypeScript) runner only; the official page describes no equivalent for the Python, Java and .NET bindings, whose examples were run at 1.63 (see Playwright with Python, Java and .NET). The complete example below, a React and Vite gallery with two Bookshelf-style components (a star rating for the review form and an expandable book description), was run in headless Chromium with @playwright/test pinned to 1.64.1: 9 tests, all passing. It is a standalone mini project, so it does not need the Bookshelf server itself; the components mirror two widgets of the application described in Example Application & Test Layout.

Three concepts make up the whole mechanism:

  • A story is a tiny wrapper component that renders the component under test in one specific scenario: hard-coded props, mock data, providers, recorded callbacks. Stories live next to the component in files named *.story.tsx (or .ts, .jsx, .js or .vue), and every named export is one story.

  • The gallery is a single page, served by your own dev server, that defines two functions on window: window.mount(params) renders a story into an element with the id root, and window.unmount() tears it down. It is the only framework-specific piece, and you own it.

  • The mount fixture of @playwright/test navigates to the gallery, calls window.mount() with the story id and the props, and returns a Locator for the gallery root element.

The test code runs in Node.js and the component runs in the browser, so a test performs real clicks, the browser does real layout and a visual comparison is possible. At the same time the test gets everything Playwright Test offers: parallel workers, retries, projects, fixtures, the trace viewer and the HTML report. There is no separate component-testing runtime, no bundler integration inside Playwright and no extra npm package: tests import test and expect from plain @playwright/test.

sequenceDiagram participant T as Test (Node.js) participant B as Browser page (gallery) participant S as Story + component T->>B: page.goto(baseURL) T->>B: page.evaluate calls window.mount with story id and props B->>S: resolve story id, render into the root element S-->>B: DOM B-->>T: Locator for the root element T->>B: click, expect(...) on the Locator B->>S: real events and state updates S-->>B: DOM and recorded values updated B-->>T: assertions retry until they pass

Everything the component needs is set up inside the story, because the story runs in the browser. Everything the test asserts must be observable through the page: the DOM, the URL or the network. That is why callbacks are not passed from the test: functions cannot cross the Node.js to browser boundary, so the story owns the state, supplies the callbacks and records the observable result into the page, where the test reads it with a web-first assertion.

This design replaced a very different one. The old @playwright/experimental-ct-* packages let a test write JSX inline, as in mount(<Button onClick={spy} />). To make that work Playwright had to scan the tests for components, compile a bundle with its own copy of Vite and its own configuration, serve it from its own server and marshal props and callbacks between Node.js and the browser. The official documentation lists the consequences: path aliases, plugins and CSS handling had to be mirrored by hand, projects on webpack, Next.js or custom pipelines could not use their own build, every framework needed its own package, live objects could not cross the boundary and module mocks silently did not apply. The gallery model inverts the control: your dev server builds and serves everything with your plugins, aliases and CSS, and Playwright only navigates to a page.

What mount does exactly

The fixture is small enough to describe completely. For a call mount(storyId, props) it does the following:

  1. Throws if no baseURL is configured, with the message "mount() requires baseURL to point at the component gallery".

  2. Navigates the page to baseURL. Every mount() is therefore a fresh navigation, which is why tests are isolated from one another and why mounting several stories in one test is cheap.

  3. Evaluates window.mount({ story: storyId, props }) in the page and awaits it. If the gallery did not define window.mount, the call throws "The gallery page does not define window.mount()". If the gallery’s function rejects (unknown story, an exception while rendering), the rejection becomes the exception thrown by await mount(...), with a real stack, so there is no status code or DOM attribute to inspect.

  4. Returns page.locator('#root') extended with two methods, update(newProps) and unmount().

Returned object Behaviour

The Locator

Points at the element with the id root. Scope queries from it: component.getByRole('button').click(), not component.click(). A story may render a fragment, for example the component plus a hidden form that records its state.

component.update(newProps)

Calls window.mount() again with the same story id and the new props, without navigating. If the gallery reuses its rendering root, the framework reconciles and the component keeps its internal state; the story is re-rendered with new props, nothing more.

component.unmount()

Calls window.unmount() if the gallery defines it. Only needed to assert teardown behaviour, because every mount() navigates fresh anyway.

The fixture is generic over the story: passing the story’s type as a template argument type-checks the props and the arguments of update(). The second argument of mount carries plain serializable data only, because it is passed through page.evaluate().

The gallery is application code, not Playwright code. Its contract is deliberately tiny:

  • It is a single HTML page, usually kept under playwright/gallery/, served by your own dev server. A Vite application serves it from the dev server it already runs, because Vite serves any HTML file under the project root; a project on another bundler runs a small standalone Vite server next to the application. The URL of this page is the baseURL of the component project.

  • window.mount({ story, props }) resolves the story id to a component, renders it with the props into the element with the id root and returns a promise that resolves once the component is mounted and rejects on failure.

  • It reuses the rendering root across calls: the root is created on the first call and rendered into on every later call. Recreating the root on each call would reset component state, and update() would then behave like a remount.

  • window.unmount() unmounts the current story and returns a promise.

  • It imports the global CSS the same way the application entry does. The body of window.mount is also the natural place for application-wide setup: installing providers or plugins, seeding a store or starting an in-browser mock server before rendering. That function is the browser-side equivalent of the old beforeMount and afterMount hooks; there is no separate hook registry.

  • Render failures should reject rather than be swallowed. In React that means wrapping the render in flushSync, so an exception thrown while rendering reaches the promise.

Playwright ships this methodology as an agent skill. Running the command below in a project writes three skills (playwright-cli, playwright-component-testing and playwright-trace) into .claude/skills/, and the component-testing skill contains the full gallery specification with worked React and Vue examples. Asking a coding agent to set up component testing with that skill makes it detect the framework and bundler, write the gallery, add the Playwright project and write a first story and spec. The command was run, and it printed one "Skill installed" line per skill. It has a --loop option with the values claude (the default) and agents.

npx playwright init-skills

Story ids and how mount resolves them

mount passes the story id to the gallery untouched, so resolution is the gallery’s job. The scheme Playwright recommends, and the one its skill generates, is:

Story file and export Story id

src/components/Button.story.tsx, export Primary

components/Button/Primary

the same story, shorter

Button/Primary (any unique trailing suffix resolves)

Button.story.vue (a single-file-component story, one story per file)

components/Button, the path alone, resolving its default export

The id is the path under src/ without the .story.* extension, a slash and the export name. Because ids are strings, renaming or moving a story breaks the tests that name it at run time, not at compile time. Passing the story type as a template argument ties the props to the story at compile time, and the skill documents a second, optional layer: a small Vite plugin next to the gallery generates a stories.d.ts that augments the exported Stories interface of @playwright/test, which types the story id itself, with autocomplete and rename safety, and the props that belong to it. With generated types the ids carry the package name as a prefix, for example acme-ui/components/Button/WithTitle, and any id that is not a key of Stories keeps untyped props, so adoption can be partial.

Configuring the component project

Component tests are an ordinary Playwright project. The configuration below, taken from the official documentation and used unchanged by the example project, points baseURL at the gallery and starts the dev server.

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  projects: [
    {
      name: 'components',
      testDir: './tests/components',
      use: {
        ...devices['Desktop Chrome'],
        baseURL: 'http://localhost:3919/playwright/gallery/index.html',
        serviceWorkers: 'block',
        reuseContext: true,
      },
    },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3919/playwright/gallery/index.html',
    reuseExistingServer: !process.env.CI,
  },
});

The documentation uses port 5173, Vite’s default; the example uses 3919 because that port was free on the machine the page was written on. The options that matter:

Option Purpose

baseURL

Must be the URL of the gallery page, because mount navigates to it. Without it mount throws.

testDir

A separate directory for component specs keeps them out of the end-to-end project, which has its own baseURL. Select the project with npx playwright test --project=components.

serviceWorkers: 'block'

Stops the application’s own service worker from serving cached responses that would shadow your page.route() mocks.

reuseContext: true

Reuses the browser context between tests in a worker instead of creating a new one per test. For component suites this is a large speed-up, and it is the optimization the old packages applied implicitly. Isolation still holds, because every mount() navigates to the gallery afresh.

webServer

Starts the dev server that serves the gallery and waits for the url. reuseExistingServer: !process.env.CI reuses a running server locally and always starts a clean one in continuous integration. See Configuration, Fixtures & Projects for the other webServer options.

devices['Desktop Chrome']

Any device descriptor or browser can be used; component specs run in headless Chromium, Firefox or WebKit like any other test, and several browser projects can share the same gallery. Only Chromium was run for this page.

A complete example: Bookshelf-style components

The example project has its own package.json with @playwright/test pinned to 1.64.1 and Vite, the React plugin, React 19 and TypeScript as development dependencies. Its layout:

ex-component-testing/
  package.json                  scripts: "dev": "vite"
  vite.config.ts                port 3919, React plugin
  playwright.config.ts          the "components" project shown above
  playwright/gallery/
    index.html                  the gallery page with <div id="root">
    main.tsx                    window.mount and window.unmount
  src/components/
    RatingStars.tsx             the star rating of the review form
    RatingStars.story.tsx       two stories: Default, ReadOnly
    Expandable.tsx              the collapsible book description
    Expandable.story.tsx        two stories: Stateful, StartsExpanded
  tests/components/
    rating-stars.spec.ts
    expandable.spec.ts

Vite needs only its default configuration, plus a fixed port so that the Playwright configuration and the dev server agree:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: { port: 3919, strictPort: true },
});

The components themselves are ordinary React components with no knowledge of Playwright. The expandable description is controlled: it receives expanded and setExpanded from its parent.

import type { ReactNode } from 'react';

type Props = {
  title: string;
  expanded: boolean;
  setExpanded: (expanded: boolean) => void;
  children?: ReactNode;
};

export function Expandable({ title, expanded, setExpanded, children }: Props) {
  return (
    <section className="expandable">
      <button type="button" aria-expanded={expanded} onClick={() => setExpanded(!expanded)}>
        {title}
      </button>
      {expanded && <div className="body">{children}</div>}
    </section>
  );
}
type Props = {
  value: number;
  onChange: (value: number) => void;
  max?: number;
  readOnly?: boolean;
};

export function RatingStars({ value, onChange, max = 5, readOnly = false }: Props) {
  return (
    <div className="stars" role="radiogroup" aria-label="Rating">
      {Array.from({ length: max }, (_, i) => i + 1).map(n => (
        <button
          key={n}
          type="button"
          role="radio"
          aria-checked={n === value}
          aria-label={`${n} star${n === 1 ? '' : 's'}`}
          disabled={readOnly}
          onClick={() => onChange(n)}
        >
          {n <= value ? '★' : '☆'}
        </button>
      ))}
    </div>
  );
}

This is the whole framework-specific glue, adapted from the worked example in Playwright’s skill. import.meta.glob collects every story file (Vite analyses it statically, which is why it has to live in this file), resolve implements the id scheme described above, and the two window functions are the contract. The root is created once and reused, so update() reconciles; flushSync makes a render error reject the promise; StrictMode matches how most applications render. The stylesheet import mirrors what the application’s own entry point would import.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Component gallery</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="./main.tsx"></script>
  </body>
</html>
import { StrictMode } from 'react';
import { flushSync } from 'react-dom';
import { createRoot, type Root } from 'react-dom/client';
import '../../src/index.css';

// Vite analyzes import.meta.glob statically, so it stays in this file.
const stories = import.meta.glob('../../src/**/*.story.{tsx,jsx}');
const idOf = (file: string) => file.replace(/^(\.\.\/)+src\//, '').replace(/\.story\.\w+$/, '');

async function resolve(storyId: string) {
  const sep = storyId.lastIndexOf('/');
  const [path, name] = [storyId.slice(0, sep), storyId.slice(sep + 1)];
  const file = Object.keys(stories).find(f => idOf(f) === path || idOf(f).endsWith('/' + path));
  const mod = (file && (await stories[file]())) as Record<string, any> | undefined;
  return mod?.[name] ?? mod?.default;
}

const rootEl = document.getElementById('root')!;
let root: Root | undefined;

(window as any).mount = async ({ story, props }: { story: string; props?: Record<string, any> }) => {
  const Story = await resolve(story);
  if (!Story)
    throw new Error(`Unknown story: ${story}`);
  root ??= createRoot(rootEl); // reuse the root so update() reconciles and keeps state
  // flushSync so that a render error rejects the promise instead of being swallowed
  flushSync(() => root!.render(<StrictMode><Story {...props} /></StrictMode>));
};

(window as any).unmount = async () => {
  root?.unmount();
  root = undefined;
};

Because the gallery is a normal page of the dev server, opening http://localhost:3919/playwright/gallery/index.html in a browser and calling await window.mount({ story: 'components/Expandable/Stateful' }) from the DevTools console does exactly what the fixture does, which is the official way to debug a story by eye. A gallery may also list all discovered stories on an optional index page.

Stories

Stories are where state and callbacks live. The Stateful story below owns the expanded state, supplies the setExpanded callback to the component and records the value into a hidden form, so that the test can read it. The StartsExpanded story takes a plain serializable prop, which a test can pass and later change with update().

import { useState } from 'react';
import { Expandable } from './Expandable';

export const Stateful = () => {
  const [expanded, setExpanded] = useState(false);
  return <>
    <Expandable expanded={expanded} setExpanded={setExpanded} title='Description'>
      A librarian discovers a door between shelves.
    </Expandable>
    <form hidden><input data-testid='expanded' readOnly value={String(expanded)} /></form>
  </>;
};

export const StartsExpanded = ({ title = 'Description' }: { title?: string }) => {
  const [expanded, setExpanded] = useState(true);
  return <Expandable expanded={expanded} setExpanded={setExpanded} title={title}>
    A librarian discovers a door between shelves.
  </Expandable>;
};

The star rating stories record two things: the current value and every change, as a JSON array, in two separate inputs. Recording each observed value in its own data-testid input, with String(...) for scalars and JSON.stringify(...) for payloads, is the convention of the official documentation.

import { useState } from 'react';
import { RatingStars } from './RatingStars';

export const Default = ({ initial = 0 }: { initial?: number }) => {
  const [value, setValue] = useState(initial);
  const [changes, setChanges] = useState<number[]>([]);
  return <>
    <RatingStars value={value} onChange={v => { setValue(v); setChanges(c => [...c, v]); }} />
    <form hidden>
      <input data-testid='value' readOnly value={String(value)} />
      <input data-testid='changes' readOnly value={JSON.stringify(changes)} />
    </form>
  </>;
};

export const ReadOnly = ({ value = 4 }: { value?: number }) =>
  <RatingStars value={value} readOnly onChange={() => {}} />;

The conventions that keep a gallery useful as living documentation:

  • One export per scenario. Prefer a new story such as Disabled or WithLongTitle over parameterizing an existing one: the list of exports of Button.story.tsx then reads as a specification of the button.

  • Stories live next to the component, so a rename or refactor touches both together.

  • The story owns everything the component needs (providers, mock data, state, callbacks); the test owns only interactions and assertions.

  • Keep the recording form hidden for a clean screenshot baseline, or drop the attribute while developing to watch the state change next to the component when you click it by hand.

If components need context (a theme, a router, a store), create one shared decorator component and use it inside the stories rather than inside the gallery, so every story states its scenario and nothing more, and stories can opt out. Create client objects such as a query client inside the story or decorator so that each navigation starts fresh.

Tests

The test for the expandable description is the one from the release notes, with an additional assertion on the visible text. toHaveValue() is a web-first assertion, so it retries until the story’s state lands and there is nothing to await or poll by hand.

import { test, expect } from '@playwright/test';

test('click should expand', async ({ mount }) => {
  const component = await mount('components/Expandable/Stateful');
  await expect(component.getByText('A librarian')).toBeHidden();

  await component.getByRole('button').click();

  await expect(component.getByTestId('expanded')).toHaveValue('true');
  await expect(component.getByText('A librarian')).toBeVisible();
});

test('two clicks leave it collapsed', async ({ mount }) => {
  const component = await mount('components/Expandable/Stateful');
  await component.getByRole('button').dblclick();
  await expect(component.getByTestId('expanded')).toHaveValue('false');
});

test('update() keeps component state', async ({ mount }) => {
  const component = await mount<typeof import('../../src/components/Expandable.story').StartsExpanded>('Expandable/StartsExpanded');
  await component.getByRole('button').click(); // collapse
  await expect(component.getByText('A librarian')).toBeHidden();
  await component.update({ title: 'Synopsis' });
  await expect(component.getByRole('button', { name: 'Synopsis' })).toBeVisible();
  await expect(component.getByText('A librarian')).toBeHidden(); // state preserved
});

test('unmount() removes the story', async ({ mount }) => {
  const component = await mount('Expandable/Stateful');
  await component.unmount();
  await expect(component.getByRole('button')).toHaveCount(0);
});

test('an unknown story makes mount() throw', async ({ mount }) => {
  await expect(mount('Expandable/DoesNotExist')).rejects.toThrow('Unknown story: Expandable/DoesNotExist');
});

test('visual baseline of the collapsed state', async ({ mount }) => {
  const component = await mount('Expandable/Stateful');
  await expect(component).toHaveScreenshot('collapsed.png');
});

The rating specs use typed props: the story type is imported with import type, so the story, React and the CSS are never loaded into the Node.js test process, and update() is checked against the same type.

import { test, expect } from '@playwright/test';
import type { Default } from '../../src/components/RatingStars.story';

test('clicking a star sets the rating', async ({ mount }) => {
  const component = await mount<typeof Default>('components/RatingStars/Default', { initial: 2 });
  await expect(component.getByRole('radio', { name: '2 stars' })).toBeChecked();

  await component.getByRole('radio', { name: '5 stars' }).click();

  await expect(component.getByRole('radio', { name: '5 stars' })).toBeChecked();
  await expect(component.getByTestId('value')).toHaveValue('5');
  await expect(component.getByTestId('changes')).toHaveValue('[5]');
});

test('a read-only rating ignores clicks', async ({ mount }) => {
  const component = await mount('RatingStars/ReadOnly', { value: 3 });
  await expect(component.getByRole('radio')).toHaveCount(5);
  await expect(component.getByRole('radio', { name: '1 star' })).toBeDisabled();
  await expect(component.getByRole('radio', { name: '3 stars' })).toBeChecked();
});

test('update() changes props without remounting', async ({ mount }) => {
  const component = await mount('RatingStars/ReadOnly', { value: 1 });
  await expect(component.getByRole('radio', { name: '1 star' })).toBeChecked();
  await component.update({ value: 4 });
  await expect(component.getByRole('radio', { name: '4 stars' })).toBeChecked();
});

Running the project with npx playwright test printed the result below. The first run of the visual test had failed with "A snapshot doesn’t exist …​ writing actual", which is how toHaveScreenshot always behaves when no baseline exists yet (it writes the baseline, here collapsed-components-darwin.png, and fails that one run); the second run passed. The platform suffix in the file name means baselines are per operating system, as explained in Visual Regression Testing.

Running 9 tests using 2 workers

  ✓  1 [components] › tests/components/rating-stars.spec.ts:4:1 › clicking a star sets the rating (299ms)
  ✓  2 [components] › tests/components/expandable.spec.ts:3:1 › click should expand (299ms)
  ✓  3 [components] › tests/components/rating-stars.spec.ts:15:1 › a read-only rating ignores clicks (147ms)
  ✓  4 [components] › tests/components/expandable.spec.ts:13:1 › two clicks leave it collapsed (179ms)
  ✓  5 [components] › tests/components/rating-stars.spec.ts:22:1 › update() changes props without remounting (142ms)
  ✓  6 [components] › tests/components/expandable.spec.ts:19:1 › update() keeps component state (169ms)
  ✓  7 [components] › tests/components/expandable.spec.ts:28:1 › unmount() removes the story (113ms)
  ✓  8 [components] › tests/components/expandable.spec.ts:34:1 › an unknown story makes mount() throw (90ms)
  ✓  9 [components] › tests/components/expandable.spec.ts:38:1 › visual baseline of the collapsed state (207ms)

  9 passed (1.9s)

To confirm that the typing is real, a temporary file calling mount<typeof Default>('RatingStars/Default', { initial: 'three' }) was type-checked with tsc --noEmit, which rejected it with "Type 'string' is not assignable to type 'number'", and the file was then removed.

Testing patterns

Record state for assertions

Components take callbacks and tests want to assert that they fired. Marshalling a callback from Node.js into the browser is not possible, so the pattern above is the answer for every callback: the story creates the state, wires the callback to it and writes the effect into the page. The scenario runs entirely in the browser, the recorded values are visible when the story is opened in the gallery, and the negative case works the same way (perform the operation, then assert that the value did not change). The official documentation shows the same pattern for Vue with defineComponent, ref and h, with a 'onUpdate:expanded' handler writing to the ref.

Per-test props and typing

Pass plain serializable props as the second argument and read them in the story:

import type { WithTitle } from '../../src/components/Button.story';

const component = await mount<typeof WithTitle>('Button/WithTitle', { title: 'Hello' });

This snippet is quoted from the official documentation and was not run as written; the equivalent typed calls in the example project, with RatingStars.story, were. Keep props to plain data: callbacks belong inside the story. For Vue the skill adds that stories must also declare their props at runtime.

Prop transitions with update()

component.update(newProps) re-renders the same story with new props on the existing root, so state is preserved. It needs a gallery that reuses its root, as the example’s does; the update() keeps component state test proves it by collapsing the panel first and asserting that it is still collapsed after the title changed.

const component = await mount('components/Counter/Default', { value: 1 });
await expect(component.getByTestId('value')).toHaveText('1');
await component.update({ value: 2 });
await expect(component.getByTestId('value')).toHaveText('2');

This counter snippet is the official one and was not run, since the example project has no Counter story; the same mechanism is exercised by the update() tests above.

Several states and visual comparison

Each mount() navigates afresh, so mounting several stories inside one test is cheap and does not leak state between them. Screenshot the returned root locator, not the page, so that nothing else on the gallery page ends up in the baseline. The options of toHaveScreenshot are in Visual Regression Testing.

await expect(await mount('Button/Primary')).toHaveScreenshot('primary.png');
await expect(await mount('Button/Disabled')).toHaveScreenshot('disabled.png');

Only the single-story screenshot of the example was run; this two-story snippet is quoted from the documentation.

Network requests

Use page.route() as in any other test, but register the routes before calling mount(), because mounting navigates and the routes must be in place before the story fetches anything. serviceWorkers: 'block' in the project configuration keeps a service worker from answering the request first. Teams that already have Mock Service Worker handler libraries can start the worker inside a story or decorator instead. General routing is covered in Network Mocking, Clock & API Testing.

test('renders the error state', async ({ page, mount }) => {
  await page.route('**/api/items', route => route.fulfill({ status: 500 }));
  const component = await mount('components/ItemList/Default');
  await expect(component.getByRole('alert')).toContainText('Something went wrong');
});

This example is quoted from the official documentation and was not run, because the example components make no network requests.

What not to do

Reaching into a component’s methods or instance from test code is neither recommended nor supported. Click it, look at the page and record internal effects into the DOM through a story; a test that fails from the user’s perspective has usually found a real bug, and tests that avoid implementation details are less fragile. The design principles in Test Design & Page Objects apply to stories as well.

Frameworks and bundlers

The mount fixture does not know which framework is on the other side of window.mount. The official documentation states that React, Vue, Svelte, Solid or anything else works, as long as the dev server can render it and the gallery resolves a story id to something renderable and renders it into #root. In practice:

  • React and Vue have worked examples in the skill: a React gallery with createRoot, flushSync and a reused root, and a Vue gallery that mounts a small reactive host once and updates its refs, because createApp(...).mount() would build a fresh instance on every call and lose state. React needs version 18 or later for createRoot.

  • Vite applications serve the gallery from the existing dev server; Vite ignores the extra HTML file in production builds.

  • Other stacks (Next.js, webpack, no dev server at all) run a small standalone Vite server for the gallery, with Vite and the framework plugin as development dependencies, and baseURL pointing at that server.

  • For Svelte, Solid or any other framework, the documentation’s advice is to ask a coding agent to implement the gallery contract: resolve a story id, render into #root, reuse the root across calls so that update() preserves state. Only React was run for this page; the Vue and other-framework statements come from the documentation.

Discontinued: the @playwright/experimental-ct-* packages

Before 1.62 Playwright component testing was a separate experimental feature with one package per framework: @playwright/experimental-ct-react, @playwright/experimental-ct-react17, @playwright/experimental-ct-vue, @playwright/experimental-ct-svelte and @playwright/experimental-ct-solid. Tests imported test and expect from the package, wrote JSX inline, and configured the bundling with defineConfig options such as ctViteConfig, ctPort and ctTemplateDir, a playwright/index.html template, beforeMount and afterMount hooks and a router fixture.

The status of each package in the npm registry on 25 October 2026 (from npm view):

Package Latest version Published Registry state

@playwright/experimental-ct-react

1.62.1

30 July 2026

latest tag is 1.62.1, no deprecation message; only next and beta pre-releases follow (the last 1.63.0-alpha is dated 7 August 2026)

@playwright/experimental-ct-react17

1.62.1

30 July 2026

same as above

@playwright/experimental-ct-vue

1.62.1

30 July 2026

same as above

@playwright/experimental-ct-svelte

1.58.2

6 February 2026

no stable release since; last next pre-release is 1.59.0-alpha of February 2026; no deprecation message

@playwright/experimental-ct-solid

1.48.2

25 October 2024

deprecated, with the registry message "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info."

The official page says the react, react17 and vue packages "have been removed and are no longer published", and that projects still on them should stay on Playwright 1.62 until they have migrated. The registry shows what that means in practice: the last stable versions, 1.62.1, can still be installed, but nothing newer than 1.62.1 was released, so they cannot follow Playwright 1.63 or later. The Svelte and Solid packages stopped earlier, at 1.58.2 and 1.48.2, and the documentation’s migration guide does not mention them by name; the gallery approach is the replacement for every framework.

Migration path

The recommended path is incremental, and the official documentation gives the mapping between the two models:

Experimental packages Story gallery

mount(<Button onClick={spy} />)

A stateful story supplies onClick and records the effect into a hidden input; the test asserts with toHaveValue()

Plain data props from the test

Unchanged in spirit: mount(id, props)

JSX children or slots from the test

One story export per composition (in Vue, a .story.vue file for slot-heavy scenarios)

component.update(<Button count={2} />)

component.update({ count: 2 })

component.unmount()

component.unmount()

beforeMount and afterMount hooks

The body of the gallery’s window.mount (global), or story decorators (per story)

hooksConfig per-test variation

Props interpreted by the story, for example mount('App/Routing', { route: '/dashboard' })

router fixture and MSW handlers in Node.js

page.route() in the test, or MSW setupWorker inside a story

playwright/index.html (styles, theme)

The gallery’s index.html and the imports of its entry module

ctViteConfig, ctPort, ctTemplateDir

Gone: the gallery runs through your own dev server, and the port lives in webServer and baseURL

defineConfig from the component package

Plain defineConfig from @playwright/test

A typical spec changes like this. Before, the callback was a closure in the test:

import { test, expect } from '@playwright/experimental-ct-react';
import Button from '../src/components/Button';

test('counts clicks', async ({ mount }) => {
  let clicks = 0;
  const component = await mount(<Button title='Submit' onClick={() => ++clicks} />);
  await component.getByRole('button').click();
  expect(clicks).toBe(1);
});

After, the closure becomes state in a story and the assertion becomes a web-first one:

import { useState } from 'react';
import { Button } from './Button';

export const CountsClicks = () => {
  const [clicks, setClicks] = useState(0);
  return <>
    <Button title='Submit' onClick={() => setClicks(count => count + 1)} />
    <form hidden><input data-testid='click-count' readOnly value={String(clicks)} /></form>
  </>;
};
import { test, expect } from '@playwright/test';

test('counts clicks', async ({ mount }) => {
  const component = await mount('components/Button/CountsClicks');
  await component.getByRole('button').click();
  await expect(component.getByTestId('click-count')).toHaveValue('1');
});

These before-and-after snippets use a Button component that the example project does not have, so they were not run as written; the same story-and-recorded-value mechanism is what the Expandable and RatingStars tests run.

The steps the documentation recommends:

  1. While still pinned to Playwright 1.62, set up the gallery and the components project next to the old component testing project.

  2. Port the specs one by one: a callback becomes state in a story, a per-test JSX tree becomes one story export per composition, hooks move into the gallery.

  3. Remove the @playwright/experimental-ct-* dependency together with playwright/index.html, playwright/index.ts and the playwright/.cache directory, and upgrade Playwright.

Two things to watch: story ids are strings, so a rename breaks specs at run time (use the typed mount<typeof Story> form to catch prop mismatches at compile time), and per-test JSX no longer exists, which is deliberate, since every composition worth testing is worth naming and reviewing.

Alternatives

Playwright is not the only way to test components in a real browser. Cypress has its own component testing mode that mounts components through a framework adapter inside the Cypress runner (Cypress component testing), Vitest can run component tests in a real browser through its browser mode (Vitest Browser Mode), and Storybook offers a documented way to test the stories you already maintain for documentation (Storybook testing). All three pages were reachable on 25 October 2026; none of the three tools was run for this page. A Playwright gallery is the lightest choice when the project already uses Playwright for end-to-end tests and wants one runner, one configuration and the same traces and reports for both levels.

What changed recently

Version Change

1.62

New component testing model: stories and a gallery page, and the fixtures.mount() fixture, for example mount('components/Expandable/Stateful'), returning a Locator scoped to the story root, with update(props) and unmount() on the returned locator and a generic story type for the props. The experimental -ct-react, -ct-react17 and -ct-vue packages end at 1.62.1.

1.63 and later

No component-testing change in the release notes; the experimental packages publish no stable releases after 1.62.1. Version 1.64.1 is the latest at the check date, and the example ran on it.

1.58 and earlier

@playwright/experimental-ct-svelte last released at 1.58.2 and @playwright/experimental-ct-solid at 1.48.2.