Playwright architecture, selector reliability, and advanced interaction patterns.

Handling Dropdowns, Checkboxes, and Radio Buttons

Form controls split into two worlds: native HTML elements the browser renders itself, and custom widgets built from <div>s and ARIA roles. A native <select> is driven with selectOption(), native checkboxes and radios with check(), uncheck(), and setChecked(). The moment a design system replaces those with a styled role="listbox" or a clickable card, those APIs no longer apply and you must drive the widget the way a user would — open it, then pick the option by its accessible role. Mixing the two strategies up is the most common reason a form test clicks the right pixel and changes nothing. This page maps each control type to the correct API, shows how the checked state actually transitions, and walks the open-and-select lifecycle a portalled menu goes through.

Choosing an input API by control type A decision flow from a control to the right Playwright API: native select, native checkbox or radio, or custom ARIA widget. What kind of control? native <select> selectOption() checkbox / radio setChecked() custom ARIA widget click + getByRole Always assert state with toBeChecked() or toHaveValue() afterward
Pick the API from the control type. Native elements have dedicated methods; custom widgets are opened and selected by accessible role.

Root cause: one API does not fit every control

selectOption() calls the DOM HTMLSelectElement selection API directly and dispatches input/change — it works only on a real <select>. check() toggles a real <input type="checkbox"> or type="radio" and is a no-op-style guard that verifies the element ends up checked. A custom dropdown is none of those: it is usually a button that toggles a role="listbox" containing role="option" nodes, with no <select> anywhere in the DOM. Calling selectOption() on it throws because there are no <option> elements to choose. Likewise a "checkbox" drawn as a styled <div role="checkbox"> will not respond to check() unless it exposes the checkbox role and aria-checked.

The confusion is compounded by how similar the two look on screen. A component library styles its listbox to be pixel-identical to a native dropdown, so the failing test looks correct in a screenshot while the underlying element type is completely different. The only reliable discriminator is the DOM: open the inspector, or run await page.getByLabel('Country').evaluate(el => el.tagName) once and record the answer. Driving each control with the matching API is the foundation of Form Automation & Input Handling, which sits under Advanced Interactions & Test Assertions. The matrix below is the lookup table worth pinning to the wall.

Control type to API and assertion matrix A five-row table mapping each form control type to the Playwright method that drives it and the assertion that verifies it. Control in the DOM Playwright API State assertion native <select> selectOption({ label }) toHaveValue('DE') input type=checkbox setChecked(true | false) toBeChecked() radio group check() one option toBeChecked() per option div role=listbox click, then getByRole toHaveText on trigger div role=switch click the switch aria-checked attribute Read the tag and role before writing the interaction
Each control type has exactly one driving method and one matching assertion; reading the tag name first tells you which row you are on.

Minimal reproducible example

The test fills a signup form that mixes all three control types: a native country <select>, a terms checkbox, a plan radio group, and a custom timezone dropdown built from ARIA roles. Every interaction is followed by an assertion on resulting state rather than on the click that produced it, which is the habit that makes form tests survive a redesign.

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

test('signup form handles every control type', async ({ page }) => {
  await page.goto('/signup');

  // Native <select>: choose by visible label, value, or index.
  await page.getByLabel('Country').selectOption({ label: 'Germany' });

  // Native checkbox: setChecked(true) is idempotent — it only acts if needed.
  await page.getByRole('checkbox', { name: 'Accept terms' }).setChecked(true);

  // Native radio group: check() the option you want; siblings clear themselves.
  await page.getByRole('radio', { name: 'Pro plan' }).check();

  // Custom dropdown: open the trigger, then pick the option by its ARIA role.
  await page.getByRole('button', { name: 'Timezone' }).click();
  await page.getByRole('option', { name: 'Europe/Berlin' }).click();

  // Assert resulting state, not the clicks we performed.
  await expect(page.getByLabel('Country')).toHaveValue('DE');
  await expect(page.getByRole('checkbox', { name: 'Accept terms' })).toBeChecked();
  await expect(page.getByRole('radio', { name: 'Pro plan' })).toBeChecked();
  await expect(page.getByRole('button', { name: 'Timezone' }))
    .toHaveText(/Europe\/Berlin/);
});

Step-by-step fix

  1. Identify the control in the DOM first. Inspect the element. A real <select> with <option> children uses one API; a <div role="listbox"> with role="option" children uses another. This single check determines everything that follows.
  2. Drive native <select> with selectOption(). Pass { label }, { value }, { index }, or an array for a multi-select. Selecting by label keeps the test readable and resilient to value renames; the call dispatches the change event the app listens for.
  3. Toggle native checkboxes and radios with setChecked(). Prefer setChecked(true|false) over check()/uncheck() when the starting state is unknown — it is idempotent and will not toggle an already-correct control. For radios, check() the desired option and the group's others clear automatically.
  4. Open custom widgets, then select by role. Click the trigger (getByRole('button') or getByRole('combobox')) to open the menu, wait for the listbox, then getByRole('option', { name }) and click it. This is the same path a keyboard or mouse user takes.
  5. Scope the query when several controls share a name. Two "Country" selects on one page make the locator ambiguous and the run fails on strict mode. Anchor on the enclosing getByRole('group', { name: 'Billing address' }) before the field query, as described in Resolving Strict Mode Violations.
  6. Locate by accessible name, not brittle CSS. Use getByLabel for fields and getByRole(... , { name }) for options so a class or markup change does not break the test. See getByRole & Accessibility Selectors for why role-based queries survive refactors.
  7. Assert the resulting state. Follow each interaction with toBeChecked(), toHaveValue(), or a visible-text assertion on the trigger. Verifying state — not the click — is what proves the control actually changed, and the retrying behaviour behind these matchers is covered in Web-First Assertions.

How the checked state actually changes

check(), uncheck(), and setChecked() are not three ways of writing the same click. check() asserts the control ends up checked and throws if it does not; called on an already-checked box it performs no click at all rather than toggling it off. uncheck() is its mirror. setChecked(value) reads the current state, compares it to the target, and acts only on a mismatch — which is why it is the safe choice when a previous test, a saved session, or a server-rendered default may have left the box in either position.

That guard matters most in tests that run twice. A test that calls check() unconditionally passes on a fresh page and still passes on a pre-checked one; a raw click() in the same place silently unchecks it and the form submits the opposite of what you intended. Tri-state checkboxes add a third position: an element with indeterminate set reports neither checked nor unchecked, so assert toBeChecked({ checked: false }) or read aria-checked="mixed" directly instead of expecting a boolean.

Checkbox state machine for check, uncheck and setChecked Two states, unchecked and checked, with the transitions each Playwright method triggers and the calls that are no-ops. Checkbox state machine unchecked aria-checked = false checked aria-checked = true check() / setChecked(true) uncheck() / setChecked(false) uncheck() here does nothing setChecked(true) is a no-op
Both guarded methods move the control toward a target state and skip the click entirely when it is already there, which is what makes a re-run safe.

The open-and-select lifecycle for custom widgets

A custom dropdown is a small state machine spread across two parts of the DOM. The trigger owns aria-expanded; the option list is usually rendered by a portal that appends to document.body, far from the trigger in the tree even though it appears anchored to it on screen. Understanding that split explains almost every failure: scoping the option query to the trigger's subtree finds nothing, and asserting on the menu before the mount animation finishes reads a stale tree.

The reliable path is to click the trigger, then query the option from the page root and click it directly. Playwright's actionability checks make the second click wait for the option to exist, be visible, and stop moving, so you rarely need an explicit wait between the two steps. Where a widget fetches its options over the network, the wait becomes a data wait rather than an animation wait, and the patterns in Waiting Strategies for Dynamic React Components apply directly. Widgets shipped as web components hide their internals behind a shadow root, which locators pierce automatically — see Shadow DOM Traversal if the option nodes never appear in a plain DOM dump.

Sequence of a custom dropdown selection A sequence diagram between the test script, the trigger button and the portalled listbox, from opening the menu to asserting the trigger text. Test script Trigger button Listbox in portal click() waits for enabled aria-expanded flips, menu mounts getByRole('option', { name }) from page root option click closes the menu expect(trigger).toHaveText()
The option node lives in a portal at the document root, so the third step queries from the page rather than from inside the trigger's subtree.

Troubleshooting variants

selectOption() throws "element is not a <select>"

The control is a custom widget, not a native dropdown. There is no <select> to operate on. Switch to the open-then-pick pattern: click the trigger and choose the entry with getByRole('option', { name }). Reserve selectOption() for genuine <select> elements. A quick way to confirm which you have is to evaluate el.tagName on the locator once during development and hard-code the branch from there rather than writing a test that tries both.

check() reports the element is not a checkbox

The control is a styled <div> or <button> without the checkbox role, or it exposes role="switch" instead. Target it by its actual role (getByRole('switch')) and click it, then assert with toBeChecked() or toHaveAttribute('aria-checked', 'true'). If it has no ARIA state at all, assert the downstream visible effect instead — the panel that appears, the price that updates — and file the missing role as an accessibility bug, because a control with no exposed state is unusable with a screen reader too.

The custom option list closes before I can click it

The dropdown closed on blur because the locator query stole focus, or the options render in a portal outside the trigger's subtree. Query the option from page (not scoped to the trigger) since portals attach at the document root, and avoid intermediate hovers that move focus away. If it animates open, let Playwright auto-wait by clicking the option locator directly rather than asserting visibility first. This pattern recurs across the steps in Automating Multi-Step Forms with Playwright.

The UI updates but the submitted payload keeps the old value

The widget updated its own visual state without notifying the framework's controlled-input layer, which happens when a test dispatches a bare click() on a native input rather than using check() or selectOption(). Those methods fire the input and change events React and Vue listen for; a synthetic pointer event alone may not. Confirm by capturing the outgoing request with a route handler as shown in Intercepting and Modifying Network Requests and comparing the body against what the DOM claims.

Verification

Confirm each control changed three ways. First, the state assertions (toBeChecked, toHaveValue, trigger text) pass on repeated runs (npx playwright test --repeat-each=10), proving the interaction is not racing the widget's open animation. Second, submit the form and assert the request payload carries the selected values via a route handler, so you know the DOM state propagated to the app. Third, run headed with --headed --slow-mo=200 and watch the select change, the boxes toggle, and the custom menu open and resolve to the chosen option.

When a form carries a dozen fields, collect the checks with Soft Assertions for Multi-Check Tests so one wrong control reports alongside the rest instead of hiding them behind the first failure. If a control still misbehaves only in CI, open the recording in the Playwright Trace Viewer and step through the action snapshots — the frame before the click shows whether the option list had actually mounted.

Frequently Asked Questions

Why does selectOption() fail on my dropdown?

Because it only works on a native <select> element. If the dropdown is built from a <div role="listbox"> with role="option" children, there is no <select> for selectOption() to operate on. Open the trigger with a click and select the entry with getByRole('option', { name }) instead.

Should I use check()/uncheck() or setChecked()?

Use setChecked(true|false) when you do not know the starting state, because it is idempotent and only acts if the control is not already in the target state. Use check() and uncheck() when you want to assert a transition occurs, or for radio groups where check() clears the sibling options automatically.

How do I select an option in a custom ARIA dropdown?

Drive it like a user: click the trigger button or combobox to open the menu, then click the entry located with getByRole('option', { name }). If the options render in a portal, query them from the page root rather than scoping to the trigger, and assert the trigger's visible text afterward.

How do I choose several options at once?

On a native <select multiple>, pass an array to selectOption() — for example selectOption(['red', 'blue']) — and the call replaces the whole selection in one step, so omitted values become deselected. Passing an empty array clears every option. A custom multi-select needs one click per entry with the menu reopened if it closes on each choice, and the assertion moves to the rendered chips rather than a single value.

Do these APIs work inside rich text editors and content-editable fields?

No. A content-editable region has no checked or selected state to set, so its toolbar toggles are buttons whose pressed state you read from aria-pressed. Those controls are covered separately in Automating Rich Text Editors.

Back to overview