<select>This document is a starting point for engaging the community and standards bodies in developing collaborative solutions fit for standardization. As the solutions to the problems described in this document progress along the standards track, we will retain this document as an archive and use this section to keep the community up to date.
The <select> element is one of the most common form controls on the web. With
the new customizable <select> (appearance: base-select), developers can
finally style and structure a real <select> to match their design.
Today, you can't build a reusable <select>-based component that lets the
people using your component supply the <option>s. If you wrap a <select>
inside a
web component
and expose a
<slot> for
options, the <select> ignores the slotted options and its dropdown comes up
empty.
This explainer proposes that a customizable <select> recognize <option>,
<optgroup>, and its trigger <button> when they are slotted in from outside
the component, so they behave as if they were written directly inside the
<select>. This lets developers wrap the native <select> in a component
instead of rebuilding it from scratch.
Say you're building a design system and want to ship a styled select that
consumers use like the native one, writing <option>s as children. Design
systems are built as web components, so you reach for the obvious approach: put
a real <select> in the component's shadow root and expose a <slot> for the
options:
<my-custom-select>
<template shadowrootmode="open">
<select>
<slot></slot>
</select>
</template>
<option>One</option>
<option>Two</option>
</my-custom-select>
This doesn't work today. A <select> builds its option list from its own
descendants. The slotted <option>s are descendants of <my-custom-select>,
not of the <select>, so the select never sees them and the dropdown is empty.
There are two common workarounds today:
MutationObserver. Keep a native <select> in the shadow
root and use a
MutationObserver
to move or copy the slotted options into it whenever they change. The native
control keeps working, but you take on the bookkeeping: a second, relocated
(or duplicated) set of option elements that has to be kept in sync on every
change. Moving options pulls them out of the light DOM where the author wrote
them; copying them leaves the select using clones that can drift from the
originals (attributes, state, and event listeners set in JavaScript don't
carry over). The sync also runs asynchronously, so options can show up late or
flicker, with potential performance issues.<select> and re-create the
trigger, popup, option list, keyboard support, focus handling, and
screen-reader semantics with custom elements and JavaScript.Design systems already ship these workarounds; see Shopify's Polaris web components as an example.
Why this matters to end users. Both workarounds reach the user. The
MutationObserver approach keeps the native control, so its accessibility
mostly survives, but the syncing is fragile: options can show up late or
flicker, the selected value can fall out of step with what the page shows, and
copied options can drift from the originals, all while shipping extra JavaScript
on every page. The rebuild approach replaces the native control, so keyboard
support, focus, and screen-reader semantics are re-implemented by hand and are
often worse or inconsistent.
Why this matters to web developers. Slotting removes the workaround
entirely: no MutationObserver to sync options, no second copy to keep in step,
and no rebuilding the control from scratch. Developers keep a real <select>
and let consumers pass <option>s the way they already do, so selection,
accessibility, and form behavior come from the platform instead of being
reimplemented and maintained by hand. Easier, correct authoring helps users too:
developers reach for the accessible native control instead of a fragile
substitute.
Note on scope: This initially targets
<select>in customizable mode (appearance: base-select). Whether to extend it to the default native appearance is an open question.
<option> elements slotted into a customizable <select> as that
select's options.<optgroup>s and for a slotted trigger
<button>.<select> behaviors and JavaScript APIs work transparently
with slotted options: selection, value, keyboard interaction, accessibility,
and form submission.<slot>
before reaching the <select>.<select> through its flat tree, whether via
a <slot> or a descendant's shadow tree.<datalist>, <table>, <fieldset>, or <form>). Those may be worth doing
later, but are out of scope here.<select> already accepts (<option>, <optgroup>, and the
trigger <button>) are in scope.Multiple developers and design-system authors have asked for this. They want to
offer a <select>-based component with a familiar API, where consumers just
write <option>s, while staying close to the platform. Without slotting
support, each of them has to ship a large, hand-built select.
The recurring request in the
discussion is to let <option>s
be provided through a slot, like any other child content.
A customizable <select> should find its <option>s (and <optgroup>s and
trigger <button>) by looking at the content that is actually rendered inside
it, including content placed there through <slot>s, instead of only its direct
children. If an <option> is rendered inside the select because it was slotted
in, the select treats it as one of its options.
Everything else about <select> stays the same. From the page's point of view,
a slotted <option> behaves as if it had been written directly inside the
<select>.
A component wraps a <select> and exposes a single <slot> for options:
<my-custom-select>
<template shadowrootmode="open">
<select>
<slot></slot>
</select>
</template>
<option>One</option>
<option>Two</option>
</my-custom-select>
Proposed result: the <select> recognizes both <option>s and shows them
in its dropdown. select.options contains the two options, selection works, and
the control is the real, accessible native select.
Components are often nested. Here the options live in the light DOM of
<my-section>, are slotted into <my-custom-select>, and are then forwarded
again into the inner <select>, crossing two shadow boundaries:
<my-section>
<template shadowrootmode="open">
<my-custom-select>
<template shadowrootmode="open">
<select>
<slot></slot>
</select>
</template>
<slot></slot>
</my-custom-select>
</template>
<option>One</option>
<option>Two</option>
</my-section>
Proposed result: the <select> still recognizes the options, even though
they pass through multiple slots. This needs to work, because nesting
design-system components is common.
In customizable mode, the first child <button> of a <select> becomes the
control's trigger (the thing you click to open the dropdown), replacing the
default. A component may want consumers to provide both the trigger button and
the options through the same slot:
<my-custom-select>
<template shadowrootmode="open">
<select>
<slot></slot>
</select>
</template>
<button>Pick a number</button>
<option>One</option>
<option>Two</option>
</my-custom-select>
Proposed result: the <select> uses the slotted <button> as its trigger
and treats the <option>s as its options. select.options returns the two
options and does not include the button.
<optgroup>s (with their nested <option>s) can be slotted too, and behave
just like directly authored groups: the label groups the options, and a
disabled group disables its options.
<my-custom-select>
<template shadowrootmode="open">
<select>
<slot></slot>
</select>
</template>
<optgroup label="Numbers">
<option>One</option>
<option>Two</option>
</optgroup>
<optgroup label="Letters" disabled>
<option>A</option>
<option>B</option>
</optgroup>
</my-custom-select>
The lookup is over the flat tree, so it is not limited to <slot>. Options
placed in a descendant's shadow tree are recognized too, even without an
explicit <slot>:
<select>
<options-container>
<template shadowrootmode="open">
<option>One</option>
<option>Two</option>
</template>
</options-container>
</select>
Proposed result: the <select> recognizes both <option>s, because they
are in the select's flat tree.
Keeping a native <option> inside custom option wrappers also lets the
wrapper own that option's styles. This is useful because the outer <my-select>
cannot style every consumer-provided option: ::slotted() only matches elements
directly assigned to its slot, not <option> descendants of a slotted
<optgroup>. Putting the native option and its styles in the wrapper's shadow
root avoids that limitation:
<my-select>
<template shadowrootmode="open">
<select><slot></slot></select>
</template>
<my-option>
<template shadowrootmode="open">
<style>
option { padding-inline: 1em; background: canvas; }
</style>
<option><slot></slot></option>
</template>
One
</my-option>
</my-select>
Proposed result: the <select> recognizes the native <option> inside each
<my-option>. Because that <option> lives in the wrapper's shadow root, it is
styled by the wrapper's own CSS, so the component controls each option's
appearance while the wrapper stays transparent to the select.
Because slotted options are treated as the select's options, the existing imperative APIs behave exactly as they would for direct children:
const select = document.querySelector('my-custom-select')
.shadowRoot.querySelector('select');
select.options.length; // counts the slotted options
select.selectedIndex = 1; // selects the second slotted option
select.selectedOptions; // reflects the current selection
select.value; // the selected option's value
The option list also stays in sync as the page adds, removes, or re-slots options, and those changes are observable immediately, without waiting for a later turn of the event loop:
const option = document.createElement('option');
option.textContent = 'Three';
myCustomSelect.append(option); // gets slotted into the inner <select>
select.options.length; // already reflects the new option
appearance: base-select, but the same flat-tree lookup could apply to a
default-appearance <select> too. The main concern is web compatibility,
which we would want to measure (for example, with use counters) before
deciding.<select>, or as part of a general mechanism that applies to elements such as
<datalist>, <table>, <fieldset>, and <form>?<select>?<select> from scratch in JavaScriptThe status-quo workaround: don't use a real <select> at all. Build the
trigger, the popup, and the option list out of generic elements and JavaScript.
Pros
Cons
Reason for rejection: it pushes a large, error-prone burden onto every component author and tends to produce worse outcomes for end users.
Keep a real <select> in the shadow root, but use a MutationObserver to watch
the component's light-DOM children and copy any <option>s into the shadow
<select>.
Pros
<select> for rendering and behavior.Cons
Reason for rejection: it's a brittle workaround for something slots are meant to handle natively, and the duplicated-element model can cause subtle bugs.
is attribute)Extend the native <select> with the customized built-in mechanism
(<select is="my-select">).
Pros
Cons
Reason for rejection: it doesn't provide the shadow DOM and slot composition that this problem needs.
Instead of fixing <select>, define a general mechanism so that any element can
find slotted children, so <table>, <form>, <fieldset>, and others would
all benefit.
Pros
<select> becoming a special
case.Cons
<form>.<select>) behind a broad, open-ended
effort.Reason for rejection (for now): <select> has clear, present demand and a
tractable scope. Starting here does not rule out generalizing later, and it
gives a concrete case to learn from before designing something broader.
Accessibility. Reusing the native <select> inside a component gives end
users the platform's built-in keyboard support, focus handling, and
assistive-technology semantics, instead of a hand-rebuilt control that often
gets these wrong. A slotted <option> is exposed to accessibility tooling the
same way a directly authored <option> is.
Internationalization. No new internationalization surface is introduced. Slotted options carry their text, language, and direction just like ordinary options, and grouping and labels behave the same.
Privacy. No new information is exposed. Slotting only rearranges where a developer's own elements render within their own document; it doesn't reveal anything across origins or to any other party.
Security. No new capability or cross-boundary access is introduced. The options come from the page's own content being composed into the page's own component; this proposal doesn't let a shadow tree reach content it couldn't already compose.
Discussion is being tracked in whatwg/html#11535.
Related work and background:
<select> and appearance: base-select
(Open UI).<select> element
and
the <option> element
(HTML Standard).