MSEdgeExplainers

Slotted Options in Customizable <select>

Authors

Participate

Status of this Document

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.

Table of Contents

Introduction

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.

User-Facing Problem

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:

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.

Goals

Non-goals

Motivation

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.

Proposed Approach

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>.

The common case

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.

Nested components

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.

A slotted trigger button

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.

Slotted option groups

<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>

Options from a nested shadow tree

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.

JavaScript APIs

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

Open questions

Alternatives Considered

Rebuild <select> from scratch in JavaScript

The 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.

MutationObserver that clones options into the shadow root

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

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.

Customized built-in elements (the 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.

Solve it generically for all elements at once

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

Cons

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, Internationalization, Privacy, and Security Considerations

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.

Stakeholder Feedback / Opposition

Discussion is being tracked in whatwg/html#11535.

References

Related work and background: