Select

A native select, stripped and repainted. The box reads like an Item and carries the chevron of an Input; the list that drops out of it belongs to the operating system.

API

Props

proptypedefault
options?readonly T[][]the rows; the component renders the options
itemExtractor?({ row, index }) => stringthe caption of each row; an option holds text, not markup
valueExtractor?({ row, index }) => string | numberthe value each row submits; also its key
placeholder?stringthe caption of the empty row, shown when nothing is chosen
canBeEmpty?booleanfalsekeeps the empty row selectable, so the field can be cleared
active?booleanfalsekeeps the box highlighted regardless of focus; also exposed as data-margo-active
clickable?booleantruewhen false, the box and the control ignore the pointer and the tab order
…restprops of selectvalue, onChange, name, required, disabled — all native

Examples

Usage

You pass the rows and two functions: one says what a row reads as, the other what it submits. The component renders the option elements, so there are no children to write.

tsx
const [value, setValue] = useState("");

<Label htmlFor="language">language</Label>
<Select
  id="language"
  value={value}
  onChange={(event) => setValue(event.target.value)}
  options={languages}
  placeholder="choose a language"
  valueExtractor={({ row }) => row.code}
  itemExtractor={({ row }) => row.label}
/>

An empty value

placeholder is the caption of the empty row. By default that row is only a prompt — hidden from the list and impossible to choose back. With canBeEmpty it stays in the list, which is how the field is cleared.

tsx
const [value, setValue] = useState("en");

<Label htmlFor="optional">language, optional</Label>
<Select
  id="optional"
  canBeEmpty
  value={value}
  onChange={(event) => setValue(event.target.value)}
  options={languages}
  placeholder="no language"
  valueExtractor={({ row }) => row.code}
  itemExtractor={({ row }) => row.label}
/>

States

Active and disabled. Disabled is the native attribute on the control, so the select also leaves the tab order; the box only dims and stops answering the pointer.

tsx
<Select
  active
  defaultValue="it"
  options={languages}
  valueExtractor={({ row }) => row.code}
  itemExtractor={({ row }) => row.label}
/>

<Select
  disabled
  defaultValue="it"
  options={languages}
  valueExtractor={({ row }) => row.code}
  itemExtractor={({ row }) => row.label}
/>

In a form

The element is a select, so it serialises under its name and required works as it does anywhere: the empty row is the empty string, and the browser refuses the submission while it is the one selected.

tsx
<form onSubmit={handleSubmit}>
  <Select
    name="language"
    required
    defaultValue=""
    options={languages}
    placeholder="choose a language"
    valueExtractor={({ row }) => row.code}
    itemExtractor={({ row }) => row.label}
  />
  <Button as="button" type="submit">
    <Button.Label label="save" />
  </Button>
</form>

Details

Stripped, then repainted

The control gets appearance: none and a transparent background, so what remains is a box the kit draws around it. The states are read from the control with has-*, the way Input does it, and the chevron is a sibling that ignores the pointer.

tsx
export const selectBaseClassName = `…
hover:border-on-main hover:text-on-main hover:shadow-item
focus-within:border-primary focus-within:text-on-main
has-[select:disabled]:pointer-events-none has-[select:disabled]:opacity-40`;

The open list is the one part the kit does not own — it is drawn by the operating system, and on Windows that means a white panel with a blue row under a dark theme. What a browser does accept there are the colours of the option elements, so the rows take bg-main and text-on-main and the selected one primary-darken: the panel follows the theme instead of contradicting it. The rest is color-scheme, which the kit already declares on both themes, and which is what tells the system to paint its own chrome light or dark.

The options are rendered for you

Select owns its children: it walks options and builds one option per row, taking the value from valueExtractor and the caption from itemExtractor. Without a value extractor the index is used, which is enough for a list of plain strings.

tsx
<List
  array={options}
  itemExtractor={({ row, index }) => (
    <option key={valueExtractor?.({ row, index }) ?? index} value={valueExtractor?.({ row, index })}>
      {itemExtractor?.({ row, index })}
    </option>
  )}
/>

Both extractors return a string, and that is not a simplification: an option holds text. Markup put inside one reaches the DOM and goes no further, because the open list is drawn by the operating system and only the text survives — so the type promises what the browser actually shows.

The placeholder is a row like the others, with the empty string as its value — the value a select reports when nothing has been chosen.

html
<option value="" disabled={!canBeEmpty} hidden={!canBeEmpty}>
  {placeholder}
</option>

The placeholder colour

A select has no ::placeholder: the text in the box is the selected option, whatever it is. The dimmed colour is a selector on the empty row instead, so it follows the selection without a line of JavaScript and without requiring the field.

tsx
export const selectControlClassName = `…
appearance-none text-on-main
has-[option[value='']:checked]:text-medium`;