<x-select>

<x-select> is a ponyfill of the native <select> element (it uses its own tag names, so each page chooses whether to adopt it). Use it when you need to customize the appearance while keeping native <select> semantics, but can't yet rely on Baseline appearance: base-select (Chrome 135+ only). Prefer a plain <select> when default styling is fine; prefer <ui-combobox> when you need typeahead-filtering or async options.

The trigger draws at the same height as a text <input> beside it, so a form column keeps one rhythm whether a row holds a select or a text field. The component sets line-height: normal on the trigger to reach that height; the contract and what a decoration does to it are on Form fields § Field height.

x-select.mjs · Full test suite

ElementAttributesPropertiesMethodsEvents
<x-select> value, disabled, tabindex, name, required, anchor, raw .value, .disabled, .open, .form, .validity, .validationMessage, .willValidate focus(), checkValidity(), reportValidity() input, change
<x-option> value, disabled .value, .disabled — —
<x-optgroup> label, disabled .label, .disabled — —

x-select.mjs injects all of the element's CSS when the first instance connects, ahead of the page's stylesheets, so a page rule of equal specificity overrides it. Every token it reads has a fallback, so the element also renders on a page that links no design tokens.

CSS custom propertyDefaultDescription
--brand currentColor Focus-ring color on the trigger button.

1. Basic

Simple x-select with language options.

Show code

2. Optgroups

Grouped options with x-optgroup.

Show code

3. Custom Button with selectedcontent

Rich content display using <selectedcontent> inside a custom button.

Show code

4. Disabled

Select with the disabled attribute — cannot be opened or focused.

Show code

5. Form Participation

x-select inside a form — submit and reset integration. It is a form-associated custom element, so the browser submits the name/value pair, skips it when it is disabled or inside a disabled <fieldset>, restores the initial value on form.reset(), and runs required through constraint validation. There is no hidden <select> behind it.

Show code

6. Native base-select

Chrome 135+ appearance: base-select for comparison. Other browsers show standard select.

Show code

7. [raw] in an input-group

Add raw to let an outer .input-group wrapper provide the border, background, and focus ring. This removes the button’s border, rounded corners, and horizontal padding while preserving the dropdown popover’s styling. Pair with anchor (see below) so the picker spans the whole group. This is the same pattern as <ui-combobox raw>. See Form field states for how this interacts with the state vocabulary.

Show code

8. Popover placement and anchor

The picker opens below the trigger, left-aligned and at least as wide as the anchor — matching <ui-combobox>. By default the anchor is the <x-select> host itself. Set anchor="id" to a wrapping .input-group (or any other ancestor) to widen the picker to that element's bounds — useful in [raw] mode where the group draws the visible field border.

The picker prefers opening below its field. It matches at least its anchor's width within the viewport and tries the other inline alignment before opening above when the full list does not fit below. If neither side can fit the list, it becomes scrollable within the available space. This is a styling choice on the customizable-select ponyfill: it preserves the native selection, keyboard and form contract while using the declared placement order instead of position-try-order: most-block-size, which would prefer above whenever that side has more room. See the HTML rendering rules for customizable select.

The trigger fills the host's width and its selected value truncates with an ellipsis when constrained. The picker retains the full option text. This applies to ordinary fields, custom buttons with selectedcontent, and raw fields inside an input group.

On engines without CSS anchor positioning, load preload.mjs before the component. Its existing anchor fallback mirrors this placement for select pickers on horizontal pages, including right-to-left alignment. Other popovers retain their own placement contracts. The component remains an opt-in ponyfill, with native selection, keyboard and form behavior as its contract.

Accessibility

The component mirrors the keyboard contract and ARIA wiring of native <select>. Both suites under public/ui/test/select/ check that, and both run under npm run test:wpt on Chromium and Firefox: the x-select-test.html harness, and 32 WPT-style pages under wpt/ that load the component and assert through testharness.js. Three of those pages click through the open picker, so they load /preload.mjs the way the app does — see popover placement for the native placement rules and the fallback on engines without CSS anchor positioning.

Keyboard contract

KeyStateEffect
SpaceclosedOpen the picker
EnterclosedOpen the picker (non-Mac); no-op on Mac, matching native
↓ / ↑ / ← / →closedOpen the picker (no value change — matches appearance: base-select)
A…ZclosedOpen the picker and start typeahead
↓ / ↑openMove focus to next / previous enabled option
Home / EndopenFocus first / last enabled option
PageDown / PageUpopenSkip PAGE_SKIP options (10, matches Chrome's native page-skip)
A…ZopenTypeahead; buffer resets after 500 ms (matches native)
EnteropenCommit focused option; fire input + change; close
EscapeopenClose without committing; restore focus to trigger — and see what the press does afterwards
TabopenClose without committing; let Tab proceed

Handling Escape

With focus in the open picker, Escape cancels the default action but continues to propagate. The component handles it in its keydown listener on the picker: it closes without committing, returns focus to the trigger, and calls preventDefault() — never stopPropagation(). The event therefore reaches ancestor listeners with the picker already closed, so an outer Escape handler cannot decide by asking whether an overlay is open: document.querySelector(':popover-open, dialog[open]') may no longer match the picker by the time the handler runs. An overlay check alone can therefore cause it to handle the same key press again. Check e.defaultPrevented first — a handler nearer the event target may already have handled Escape. The picker’s listener performs the same check before acting. If a nearer handler has already cancelled the event, the picker stays open, so one press closes only one level.

The trigger's keydown listener does not handle Escape. When the picker is closed, the event therefore reaches the enclosing dialog or panel without being cancelled or stopped, allowing the user to close that container.

Escape behavior depends on focus. An open picker normally focuses an option, whose event reaches the picker's listener. If there are no options, or all are disabled, focus stays on the trigger instead. Its listener leaves Escape alone, and the browser closes the picker after dispatch. An ancestor handler then sees defaultPrevented as false while the picker is still open. Check both cancellation and open overlays to cover these cases.

Cancelling the default action also prevents the browser from closing a second overlay. The picker is popover="auto", so it sits in the platform's close-watcher stack, and the close request the same press raises is processed after dispatch — by which time the listener has already hidden the picker and taken it out of that stack. An uncancelled request then closes the current topmost entry: a <dialog> or a panel holding this field can close on the same press as the picker. That is also the difference from the combobox, whose list is popover="manual" so the browser does not close it through the close-watcher stack. Its handler controls the response to Escape.

The menu and the notification panel also cancel the default action when handling Escape, without stopping propagation. A popover behaves differently: it cancels as this component does and stops propagation, so a bubble-phase ancestor listener does not receive an Escape event handled by the panel. A capture-phase listener runs before the panel’s handler and sees defaultPrevented as false. Outer handlers must account for this difference. Accordingly, the library's accessibility page describes propagation differences and links to each component’s behaviour. In both cases, check defaultPrevented first.

Testing the browser’s close-watcher behaviour requires real key input. The harness’s Escape cases use dispatchEvent, and a synthetic KeyboardEvent raises no close request, so they verify that the picker closes but do not test the browser’s subsequent close request. A <dialog open> test element also stays open throughout dispatch, so the synthetic test does not expose the difference between checking cancellation and checking for open overlays. A real key press is needed to test that difference.

Labeling

Always pair <x-select> with a <label>, exactly as you would a native <select> . <x-select> is form-associated, making it a labelable element, so <label for="id"> and a wrapping <label> both form a real native association (label.control resolves to the <x-select>), and clicking the label focuses the control.

The component forwards that association to the trigger button as aria-labelledby: the combobox role lives on the button, not on the host, so the associated label must name that button. That happens automatically for both forms of label association; the page does not need to reference the button directly.

<!-- external label (preferred) --> <label for="my-select">Language</label> <x-select id="my-select" value="sv">…</x-select> <!-- wrapping label --> <label>Language <x-select value="sv">…</x-select></label>

ARIA wiring

WCAG criteria addressed

Known limitations

See also