Dropdowns, Checkboxes, and Date Pickers in Playwright

Native selects with selectOption

A real <select> element is the easiest form control to automate. selectOption picks an option and fires the change event, no opening or scrolling required.

<label for="country">Country</label>
<select id="country">
  <option value="nl">Netherlands</option>
  <option value="fr">France</option>
  <option value="de">Germany</option>
</select>

You can select by value, by visible label, or by index:

await page.getByLabel('Country').selectOption('nl');
await page.getByLabel('Country').selectOption({ label: 'France' });
await page.getByLabel('Country').selectOption({ index: 2 });

Selecting by label keeps the test aligned with what the user sees, and it fails when a translation or copy change breaks the visible text, which is often a failure worth having. Selecting by value survives copy changes but says nothing about what the user saw.

For a multi-select, pass an array and Playwright replaces the current selection:

await page.getByLabel('Regions').selectOption(['eu-west', 'us-east']);

Assert the outcome with toHaveValue, which checks the select's current value:

await expect(page.getByLabel('Country')).toHaveValue('nl');

For multi-selects, toHaveValues takes an array. Like all web-first assertions, both retry until the condition holds, so they tolerate a re-render between the selection and the check.

Checkboxes and radios

Use check() and uncheck() instead of click(). The difference is intent: click toggles, so a checkbox that starts in an unexpected state silently ends up in the wrong one. check is idempotent, does nothing when the box is already checked, and throws if the element never becomes checked.

await page.getByRole('checkbox', { name: 'Subscribe to newsletter' }).check();
await page.getByRole('checkbox', { name: 'Marketing emails' }).uncheck();
await page.getByRole('radio', { name: 'Annual billing' }).check();

When the desired state comes from data, setChecked reads better than a conditional:

await page.getByRole('checkbox', { name: 'Subscribe' }).setChecked(user.wantsEmails);

Assert with toBeChecked, and its negation:

await expect(page.getByRole('radio', { name: 'Annual billing' })).toBeChecked();
await expect(page.getByRole('checkbox', { name: 'Marketing emails' })).not.toBeChecked();

These calls work on custom checkbox components too, provided the component exposes role="checkbox" and aria-checked. When a styled checkbox refuses check(), the real input is usually visually hidden behind a decorative span; Playwright handles the common patterns automatically, but a component with a fully detached input may need you to target the label or the visible wrapper instead.

Custom dropdowns

Most dropdowns in modern apps are not <select> elements. Component libraries render a button that opens a floating list of divs, and selectOption has nothing to grab. Automate them the way a user operates them, in two steps:

await page.getByRole('combobox', { name: 'Assignee' }).click();
await page.getByRole('option', { name: 'Leo Baecker' }).click();

Well-built components expose role="combobox" or role="listbox" on the trigger and role="option" on the entries, because the ARIA authoring practices require it for screen readers. That is why getByRole works so often here: the same attributes that make the widget accessible make it testable. If getByRole('option') finds nothing, inspect the open dropdown in the browser or through codegen, and fall back to a test id on the option elements as described in the locators guide.

One structural surprise: many libraries render the options list in a portal at the end of <body>, outside the component's DOM subtree. Locators scoped to the form container will miss it, so search the options from page rather than from the dropdown's parent.

Keyboard navigation is a solid fallback when the options resist clicking, and it doubles as an accessibility check:

const assignee = page.getByRole('combobox', { name: 'Assignee' });
await assignee.click();
await assignee.press('ArrowDown');
await assignee.press('ArrowDown');
await assignee.press('Enter');

For searchable comboboxes, type to filter first, then pick the surviving option:

await page.getByRole('combobox', { name: 'Assignee' }).fill('leo');
await page.getByRole('option', { name: 'Leo Baecker' }).click();

Multi-select and tag inputs

Tag inputs, the fields that accumulate removable chips, are a loop over the same pattern:

const labels = page.getByRole('combobox', { name: 'Labels' });
for (const label of ['bug', 'regression', 'p1']) {
  await labels.fill(label);
  await page.getByRole('option', { name: label }).click();
}

Assert the chips rather than the input, since the input clears itself after each pick:

await expect(page.getByRole('listitem')).toHaveCount(3);
await expect(page.getByRole('listitem').filter({ hasText: 'regression' })).toBeVisible();

Removing a tag is usually a button inside the chip, reachable by chaining: page.getByRole('listitem').filter({ hasText: 'p1' }).getByRole('button', { name: 'Remove' }).

Date pickers

Date pickers come in two families, and the first one is pleasantly boring. A native <input type="date"> accepts fill with an ISO date string, regardless of how the browser displays it:

await page.getByLabel('Start date').fill('2026-08-01');
await expect(page.getByLabel('Start date')).toHaveValue('2026-08-01');

The same applies to type="datetime-local" (2026-08-01T09:30) and type="month" (2026-08).

Custom calendar widgets need a decision first: does the test care about the calendar UI, or only about the selected date? If the underlying text input accepts typed dates, filling it directly is faster and less brittle:

await page.getByLabel('Start date').fill('08/01/2026');
await page.keyboard.press('Escape');

The Escape closes the calendar popup that many widgets open on focus. Reserve full calendar navigation for tests where the widget itself is the subject:

await page.getByLabel('Start date').click();

const calendar = page.getByRole('dialog');
await calendar.getByRole('button', { name: 'Next month' }).click();
await calendar.getByRole('button', { name: 'Next month' }).click();
await calendar.getByRole('gridcell', { name: '15' }).click();

await expect(page.getByLabel('Start date')).toHaveValue('09/15/2026');

Day cells typically expose gridcell or button roles with the day number, or sometimes the full date, as the accessible name. Check with codegen rather than guessing. Whichever path you take, always end by asserting the input's value. Calendars are dense grids, an off-by-one click lands on a neighboring day, and without the assertion the test passes with the wrong date.

Beware of tests that click "day 15 of the current month" without controlling what the current month is. They pass all month and fail when the picker opens on a month where the layout shifts. Fix the reference date with page.clock.setFixedTime or navigate to an absolute month before clicking.

Sliders and range inputs

A native <input type="range"> accepts fill with the numeric value as a string:

await page.getByLabel('Volume').fill('75');
await expect(page.getByLabel('Volume')).toHaveValue('75');

Custom sliders built on role="slider" respond to arrow keys once focused, which beats trying to compute drag coordinates:

const slider = page.getByRole('slider', { name: 'Volume' });
await slider.focus();
for (let i = 0; i < 5; i++) await slider.press('ArrowRight');
await expect(slider).toHaveAttribute('aria-valuenow', '75');

Animated dropdowns and the force option

Dropdowns that animate open are a classic flake source: the option exists in the DOM but is still transitioning, and a click lands while the element is moving. Playwright's actionability checks already wait for the element to be visible and stable, meaning it has kept the same bounding box for at least two animation frames, so most animations are handled for you.

When a dropdown still misbehaves, the tempting fix is force: true:

// Anti-pattern: bypasses every check that makes the click reliable
await page.getByRole('option', { name: 'France' }).click({ force: true });

force skips the visibility, stability, and hit-target checks and dispatches the click regardless. The test goes green, including when the option is invisible, covered by another element, or detached from the pointer's actual target. Real users cannot click through an overlay, so a forced click can hide a genuine bug where the dropdown never becomes clickable.

Treat force as a last resort with a comment explaining why. Before reaching for it, try the usual suspects: hover the parent menu item first if the submenu opens on hover, close a toast or cookie banner that overlaps the list, or disable animations globally for tests via reducedMotion: 'reduce' in the config. The waits and timeouts guide walks through diagnosing which actionability check is failing from the error's call log.

Run it in production

Selects, checkboxes, and date pickers sit in the middle of every checkout, filter, and settings flow you ship. Hyperping Browser Checks run your Playwright scripts against production on a schedule, so a component-library upgrade that breaks a dropdown pops an alert instead of a support ticket.

Get started

Start monitoring in the next 5 minutes.

Stop letting customers discover your outages first. Set up monitoring, status pages, on-call, and alerts before your next coffee break.

14 days free trial. No card required.