Components
Select
A native <select> for picking one value from a list: it works with no JavaScript, submits with its form and keeps the platform's keyboard, typeahead and mobile pickers. Where the browser has customizable select (appearance: base-select, Chrome 135, Safari 26), Select.Trigger and Select.Value shape the button, the picker is styleable and options may hold more than text; elsewhere it is the classic select. No script, but a tiny one for items with a label; bind:value keeps a signal in sync in islands.
import { Select } from "@lumiose/ui"; // or "@lumiose/ui/select"
import "@lumiose/ui/select.css"; // makes a select with a Trigger a customizable select
import "@lumiose/ui/themes/default.css"; // optional: the default theme#Anatomy
<Select.Root>
<Select.Trigger>
<Select.Value />
</Select.Trigger>
<Select.Item />
<Select.Separator />
<Select.Group>
<Select.Item />
</Select.Group>
</Select.Root>| Part | Renders |
|---|---|
Root |
<select>, the control (role="combobox"), with the props it is given. |
Trigger |
<button type="button">, customizable select's button. Optional; it must be the first child of Root. |
Value |
<selectedcontent>, where the browser copies the selected option's content. Inside the Trigger. |
Item |
<option value="…">. |
Group |
<optgroup label="…"> of options. |
Separator |
<hr> between options or groups. |
The picker, the popup of options, is the select's own ::picker(select): there is no part for it.
Browsers without customizable select drop the Trigger and show their own control and picker, with
the options' text.
#API
#Select.Root
| Prop | Type | Default | |
|---|---|---|---|
value |
string |
Selects the option with this value when it changes. Pair it with onValueChange. |
|
defaultValue |
string |
the first option | The option selected at first, when value is left out. |
onValueChange |
(value: string) => void |
Called after the user selects another option, with its value. | |
bind:value |
Signal<string> |
Stands in for value and onValueChange. |
|
name |
string |
The name the value is submitted under. | |
required |
boolean |
false |
The form needs a value: an option whose value is "" counts as none. |
disabled |
boolean |
false |
The select cannot be used. |
Every other <select> prop passes through: id, autocomplete, form, class, aria-*, event
handlers. Name it with a <label for> pointing at its id, or aria-label, or put it in a
Field with a Field.Label: it takes the field's id, name and ARIA references.
#Select.Item
| Prop | Type | Default | |
|---|---|---|---|
value |
string |
Required: the value the select takes, and submits, when selected. | |
disabled |
boolean |
false |
The option cannot be selected; the arrow keys skip it. |
label |
string |
The option's name, for typeahead, assistive technology and the classic select, when its content starts with an icon or holds more than text. |
A customizable select's picker shows each item's content, with or without label. The classic
select, which shows text only, shows the label. An item with a label renders the <option>'s
label and aria-label, and a hidden copy of it first in its content (<span hidden data-label>), which typeahead matches: so option.text, and <selectedcontent>'s
textContent, start with the label, while the rendered text does not.
#Select.Group
| Prop | Type | Default | |
|---|---|---|---|
label |
string |
Required: the group's name, shown above its items. |
#The other parts
Trigger, Value and Separator take the props of the element they render (class, style,
aria-*, data-*) and pass them through.
Differences from Base UI's Select. Select is the native control, chosen over the listbox
pattern because it works without JavaScript, carries forms, autofill, validation and the mobile
pickers, and needs no script. So it has no open, onOpenChange or modal (the platform fires no
event when the picker opens; style :open instead), no Label (use <label for>), no multiple
(use Combobox with multiple), no alignItemWithTrigger (the picker opens below the
control and flips), no Portal, Positioner, Popup, List, Icon, ItemText,
ItemIndicator, GroupLabel, Arrow or scroll arrows (the picker, ::picker-icon and
option::checkmark are the platform's), and Enter does not open it (Space and the arrows do, as on
any select).
#Example
A select in a server-rendered page, with no island, styled by the default theme:
import { Select } from "@lumiose/ui";
export function PlanField() {
return (
<form method="post">
<label for="plan">Plan</label>
<Select.Root id="plan" name="plan" defaultValue="pro" class="lu-select">
<Select.Trigger>
<Select.Value />
</Select.Trigger>
<Select.Item value="free">Free</Select.Item>
<Select.Item value="pro">Pro</Select.Item>
<Select.Separator />
<Select.Group label="Teams">
<Select.Item value="team">Team</Select.Item>
<Select.Item value="enterprise" disabled>
Enterprise
</Select.Item>
</Select.Group>
</Select.Root>
<button class="lu-button">Save</button>
</form>
);
}In an island, bound to a signal. bind:value={theme} is short for value={theme()} plus
onValueChange={(next) => theme.set(next)}.
import { Select } from "@lumiose/ui";
import { signal } from "lumiose";
export function ThemePicker() {
const theme = signal("system");
return (
<Select.Root aria-label="Theme" bind:value={theme} class="lu-select">
<Select.Trigger>
<Select.Value />
</Select.Trigger>
<Select.Item value="light">Light</Select.Item>
<Select.Item value="dark">Dark</Select.Item>
<Select.Item value="system">System</Select.Item>
</Select.Root>
);
}#Styling
With the default theme, put lu-select on Root. It makes the select a customizable select
and styles the control, the picker, the options (hover and keyboard focus, disabled), the groups and
the separators; in browsers without customizable select it styles the closed control. Its colours
come from the theme's --lu- properties (UI components).
With your own CSS, opt in to customizable select and style the select's parts and states:
- the control: the
selectitself,:openwhile its picker is open,:focus-visible,:disabled,:invalidand:user-invalid, and::picker-icon; - the picker:
::picker(select), placed with anchor positioning (position-area,position-try-fallbackswork on it); - options:
option,option:checked,option:hover,option:focus-visible,option:disabled, andoption::checkmark; optgroup,hr, and inside theTrigger,selectedcontent.
A starting point:
.select,
.select::picker(select) {
appearance: base-select;
}
.select {
padding: 0.5rem 0.75rem;
border: 1px solid #85858e;
border-radius: 0.5rem;
}
.select::picker(select) {
padding: 0.25rem;
border: 1px solid #e4e4e7;
border-radius: 0.5rem;
}
.select option:focus-visible {
background: #f4f4f5;
outline: 2px solid #2563eb;
outline-offset: -2px;
}@lumiose/ui/select.css holds what Select needs to work, in the lu.core layer: a select whose
first child is a Trigger gets appearance: base-select (and so does its picker), since only a
customizable select renders the trigger, Value and options with more than text. Where
customizable select is missing, it hides the Trigger (Firefox keeps it in the select, a nameless
button for assistive technology otherwise). A select with no Trigger keeps the browser's look
until your CSS or the theme opts in.
#Keyboard and focus
The platform's: the select is one tab stop with role="combobox", and the browser runs the
keyboard. In Chromium's customizable select:
| Key | Does |
|---|---|
| Space, Arrow Down, Arrow Up | On the control: opens the picker, with focus on the selected option. |
| Alt+Arrow Down, F4 | On the control: opens the picker. |
| Arrow Down, Arrow Up | In the picker: the next or previous option; disabled options are skipped. |
| Home, End | In the picker: the first or last option. |
| A character | In the picker: typeahead, the next option whose text starts with it. |
| Enter, Space | In the picker: selects the focused option and closes the picker. |
| Escape | Closes the picker without selecting, focus back on the control; closed, it goes on, so a dialog closes. |
| Tab | In the picker: closes it, focus back on the control; from there Tab moves on. |
A click on a <label for> focuses the select without opening it. A click on the control opens the
picker; a click on an option selects it; a click outside closes the picker. Other browsers keep
their own select keyboard and pickers, which differ by platform.
#Without JavaScript, and the client script
Select needs none: it is a native control, in server HTML and with JavaScript off, on every
browser. It submits under its name, is validated (required), resets with its form, takes the
browser's autofill and is disabled by a disabled <fieldset>. value, onValueChange and
bind:value need the component to run in the browser, in an island; there value sets the
control's value when it changes, and its change event calls onValueChange.
Items with a label are the exception. A customizable select's picker shows an <option>'s
label attribute instead of its content, through the browser's own style, which no CSS
overrides. So a page whose selects have such items loads a 0.2 kB (brotli) script once, without
the Lumiose runtime (an island loads the same module). When a customizable select is pressed or
takes focus, before its picker opens, the script removes the attribute from its items: the picker
shows their content, typeahead matches the label's hidden copy, and aria-label keeps their name.
A classic select keeps the attribute, which it shows. Without the script, the customizable
picker shows the labels alone.
#Browser support
- Customizable select (
appearance: base-select,::picker(select),<selectedcontent>, a<button>and rich content inside<select>): Chrome and Edge 135, Safari 26 (the end-to-end tests run it in Chromium and WebKit 26.6). Firefox has none yet: it shows its classic select, styled by the theme when closed, with the options' text (or theirlabel). - Firefox 155 keeps the
Triggerand the tags inside options in the DOM (select.csshides theTrigger). Older browsers without customizable select drop them from server HTML, so an island's select falls back to rendering on the client there (LUM4006). - Typeahead matches an option's text, or its
label: give options whose content starts with an icon (an emoji, a symbol) alabel. :opencomes with customizable select.
#Related
- Combobox: when the list is long enough to filter, for several values, or for async options.
- Menu: commands rather than a value.
- UI components: the approach, styling, the default theme and the other components.
- Islands: running a component in the
browser, for
bind:value.