Skip to content
RU
Scale
Density
Theme
Accent

Inputs

Select

JS optional A list of your own instead of the native one. The native one works with no script

Choosing one value from a list. A native <select> — with the whole keyboard, first-letter search and the system dropdown, which on a phone becomes a wheel.

The component
Markup
<span class="inst-select-wrap">
  <select class="inst-select" aria-label="Model">
    <option>opus</option>
    <option>sonnet</option>
    <option>haiku</option>
  </select>
</span>

Contract

The wrapper is required — the chevron lives in it: the browser does not hand over the pseudo-elements of a <select> itself.

What Required Why
inst-select-wrap on the wrapper yes The chevron is drawn by a pseudo-element of the wrapper. Without it the field is left with no arrow and is indistinguishable from a text one
inst-select on the field yes The recess, the border, the height and the states come from there
A <label for> or an aria-label yes The first <option> is not a label

The keyboard is entirely native: arrows, Home and End, first-letter search, Enter. The label is given by a <label for> or an aria-label — the first <option> is not a label.

The chevron is drawn by a mask and gets pointer-events: none, so that a click on the arrow opens the list rather than falling past it; in forced-colors the mask loses its background colour and is therefore moved to CanvasText explicitly.

The native list stays native deliberately: on a phone it turns into a system wheel, and a home-made dropdown loses that.

Sizes

Example
Markup
<span class="inst-select-wrap">
  <select class="inst-select inst-select--sm" aria-label="sm"><option>sm</option></select>
</span>
<span class="inst-select-wrap">
  <select class="inst-select" aria-label="md"><option>md</option></select>
</span>
<span class="inst-select-wrap">
  <select class="inst-select inst-select--lg" aria-label="lg"><option>lg</option></select>
</span>

They are symmetrical with those of the button and the field: a select, a field and a button of the same size stand in one row of a form and cannot part.

States

Example
Markup
<span class="inst-select-wrap">
  <select class="inst-select" aria-label="Ordinary"><option>Ordinary</option></select>
</span>
<span class="inst-select-wrap">
  <select class="inst-select" aria-label="Unavailable" disabled><option>Unavailable</option></select>
</span>
<span class="inst-select-wrap">
  <select class="inst-select" aria-label="Error" aria-invalid="true"><option>Error</option></select>
</span>

The recess, the border, focus, error, disabled and readonly are the same as on the text field: all three controls are declared by one rule, so they cannot part.

JS

A native <select> calls for not one line: the keyboard, typing by first letters, a full-screen list on a phone — all of it from the platform. A script is wanted only where the native one is not enough: search within the list, two lines in an item, an icon on a row.

When it is worth leaving the native one

More than two or three dozen options Search is wanted, and a <select> has none
An item holds more than text The native list draws one line with no markup
Choosing several A <select multiple> looks like a list box and is awkward on a phone

In every other case the native one wins, and replacing it is work with no gain.

A list of your own

The surface and the keyboard are given by the library. What is left to the application is the data: opening, filtering, writing the choice into a hidden field.

Example
worldgen-01
biomes-04
rivers-12
Markup
<div class="inst-field" style="max-inline-size:16rem">
  <label class="inst-label" for="pick">Run</label>
  <span class="inst-select-wrap">
    <button class="inst-select" type="button" id="pick"
            popovertarget="pick-list" aria-haspopup="listbox">worldgen-01</button>
  </span>
  <div class="inst-popover inst-popover--fill inst-popover--anchored" id="pick-list" popover>
    <input class="inst-input inst-input--sm" type="search" aria-label="Search the runs" placeholder="Search">
    <div role="listbox" aria-label="Runs">
      <div class="inst-menu-item" role="option" aria-selected="true" tabindex="0">worldgen-01</div>
      <div class="inst-menu-item" role="option" aria-selected="false" tabindex="-1">biomes-04</div>
      <div class="inst-menu-item" role="option" aria-selected="false" tabindex="-1">rivers-12</div>
    </div>
  </div>
</div>

The arrows, Home, End and carrying the selection inside the list are done by instrument.js — this is a role="listbox". Opening and closing are taken by the Popover API.

Events

list.addEventListener('inst:select', (e) => {
  trigger.textContent = e.detail.value;
  hidden.value = e.detail.value;      // the value goes with the form
  list.hidePopover();
});

The hidden field is obligatory: a list of your own is markup, and a form sends fields. Without it the choice is visible to a person and invisible to the server.

API

Classes
NameWhat it does
class
inst-select-wrapThe wrapper. Required — the chevron lives in it
inst-selectThe <select> itself
modifier
inst-select--smThe size
inst-select--lgThe size
Tokens
NameValueWhere from
token
--surface-fieldvar(--surface-raised)one across every cell
--control-h-sm/md/lg—one across every cell
--control-pad-smcalc(var(--control-h-sm) * var(--control-ratio-sm))one across every cell
--size-chevron10pxbase +4: scale
--space-724pxone across every cell
--radius-sm6pxbase +4: scale
--border-controllight-dark(oklch(0 0 0 / 0.46), oklch(1 0 0 / 0.38))one across every cell
--text-mutedlight-dark(var(--n-8), var(--n-6))one across every cell

Why the kit works this way — the constitution · Open source under MIT, github.com/keshon/instrument