<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
| Element | Attributes | Properties | Methods | Events |
|---|---|---|---|---|
<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 property | Default | Description |
|---|---|---|
--brand |
currentColor |
Focus-ring color on the trigger button. |
Simple x-select with language options.
Grouped options with x-optgroup.
Rich content display using <selectedcontent> inside a custom button.
Select with the disabled attribute — cannot be opened or focused.
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.
Chrome 135+ appearance: base-select for comparison. Other browsers show standard select.
[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.
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.
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.
| Key | State | Effect |
|---|---|---|
| Space | closed | Open the picker |
| Enter | closed | Open the picker (non-Mac); no-op on Mac, matching native |
| ↓ / ↑ / ← / → | closed | Open the picker (no value change — matches appearance: base-select) |
| A…Z | closed | Open the picker and start typeahead |
| ↓ / ↑ | open | Move focus to next / previous enabled option |
| Home / End | open | Focus first / last enabled option |
| PageDown / PageUp | open | Skip PAGE_SKIP options (10, matches Chrome's native page-skip) |
| A…Z | open | Typeahead; buffer resets after 500 ms (matches native) |
| Enter | open | Commit focused option; fire input + change; close |
| Escape | open | Close without committing; restore focus to trigger — and see what the press does afterwards |
| Tab | open | Close without committing; let Tab proceed |
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.
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.
role="combobox", aria-haspopup="listbox", aria-expanded reflects open/closed, aria-labelledby points at the associated label(s).role="listbox", aria-labelledby points at the trigger.role="option", aria-selected reflects the current value.role="group", aria-labelledby points at the synthesized <legend>.tabindex="0" on the active option, tabindex="-1" on the rest; aria-activedescendant on the trigger is also updated. Both mechanisms are retained to mirror native <select> accessibility behavior.aria-expanded / aria-selected state.MutationObserver takes
<x-option> / <x-optgroup> children
added later and moves them into the picker; when the selected option is
no longer among them, the same value is kept if another
option still carries it, otherwise the first option is selected.
setOptions() in
ui/lib/select.mjs is the
helper for that: it reconciles by value and reuses the
existing <x-option> nodes, so a re-render does not
lose the selection.
required validation message comes from the catalog, but the
first validation after a cold load may still show English.
ElementInternals.setValidity() requires a message string and the
browser's own localized "please select an item" is unavailable to script, so the component supplies its own: ui.select.value_missing via
lib/l10n.mjs, with the English literal as the fallback while the catalog is loading. The import is deliberately not awaited — see
Conventions § A user-facing string in a
component for the reason and the localization convention.
<ui-combobox> — when you need typeahead-filtering, async options, or free-text entry.x-select.mjs[raw] mode pairs with .input-group.