Select
Displays a list of options for the user to pick from, triggered by a button.
Select is a custom selection dropdown (not a native <select>). Best for 3 to 20 options. Beyond that, prefer Combobox (with search).
Two ways to write it. The shorthand takes an options array and builds the trigger, the value and the items for you. The composed form uses SelectTrigger, SelectValue, SelectContent and SelectItem, for when an item needs custom markup or classes. Both render the same HTML.
With name, the root renders a hidden <input> whose value is the value of the chosen option, so the choice posts with the surrounding form. The current value is also written to data-value on the root.
Groups: wrap related items in SelectGroup with a SelectLabel, or pass { label, options } entries in the shorthand. SelectSeparator draws a line between groups.
Multiple selection: with multiple, choosing an option toggles it and the list stays open. The trigger shows the chosen labels separated by commas, or a count such as "4 selected" beyond three. With name, the root holds one hidden <input> per chosen value, all with that name, so the form posts every value.
Installation
$ bext ui add select
Copy and paste the following into src/components/ui/select.tsx.
This component is interactive. Make sure the shared island is loaded (see Islands). It reacts to data-ui="select" hooks.
Usage
Anatomy
<Select> // data-ui="select"
├── <SelectTrigger><SelectValue placeholder="…" /></SelectTrigger>
└── <SelectContent>
├── <SelectItem value="a">A</SelectItem>
├── <SelectItem value="b">B</SelectItem>
├── <SelectSeparator /> // optional
└── <SelectGroup> // optional, role="group"
├── <SelectLabel>Group</SelectLabel>
└── <SelectItem value="c">C</SelectItem>API Reference
Select
| Prop | Type | Default | Description |
|---|---|---|---|
| options | ({ value: string; label: string; disabled?: boolean } | { label: string; options: [...] })[] | — | Shorthand: generates the trigger, the value and one SelectItem per entry. An entry of the form { label, options } generates a SelectGroup with its SelectLabel. When set, children are ignored. |
| multiple | boolean | false | Several options can be chosen. Written to data-multiple on the root; the list carries aria-multiselectable="true". In the composed form, also pass multiple to SelectContent, otherwise the island sets the attribute on load. |
| placeholder | string | "Select…" | Shorthand only: muted text shown while nothing is selected. |
| value | string | string[] | — | Initial selection. Shorthand: the value of an option (its label is shown and the option is marked selected); a text that matches no option is displayed as is. Composed form: the value of the hidden input. With multiple: an array of option values. |
| name | string | — | Form field name. Renders a hidden input kept equal to the value of the chosen option. With multiple: one hidden input per chosen value, added and removed by the island. |
| id | string | — | Shorthand only: id of the trigger button, so that a <label for> can point to it. Also prefixes the id of the list. |
| label | string | — | Shorthand only: aria-label of the trigger button. |
| disabled | boolean | false | Shorthand only: disables the trigger. |
| children | ReactNode | — | Composed form: a SelectTrigger and a SelectContent. |
| className | string | — | Extra classes on the root <div> (relative). |
SelectTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | — | id of the <button>, for a <label for>. |
| label | string | — | aria-label of the <button>. Use it when there is no visible label. |
| controls | string | — | id of the SelectContent, written to aria-controls. Set by the island when omitted. |
| disabled | boolean | false | Disables the button. |
| children | ReactNode | — | Usually a SelectValue. A chevron-down icon is appended. |
SelectValue
| Prop | Type | Default | Description |
|---|---|---|---|
| placeholder | string | "Select…" | Muted text shown while nothing is selected. |
| value | string | — | Text shown on first render instead of the placeholder. When it equals the text of an item, the island marks that item selected. |
SelectContent
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | — | id of the list. Generated by the island when omitted. |
| multiple | boolean | false | Writes aria-multiselectable="true" on the list. Set by the island when the Select root has multiple. |
| children | ReactNode | — | The SelectItems. Rendered hidden, absolutely positioned under the trigger, max-h-72 with scroll. |
SelectItem
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | — | Written to data-value on the item. This is what the hidden input receives. |
| selected | boolean | false | Marks the item selected in the HTML (aria-selected and check mark). |
| disabled | boolean | false | The item cannot be chosen and is skipped by the keyboard (aria-disabled). |
| children | ReactNode | — | Displayed label. When the item is chosen, the island copies this text into SelectValue. |
SelectGroup
| Prop | Type | Default | Description |
|---|---|---|---|
| labelledby | string | — | id of the SelectLabel, written to aria-labelledby. When omitted, the island links the group to the SelectLabel it contains. |
| children | ReactNode | — | A SelectLabel followed by SelectItems. Rendered with role="group". |
SelectLabel
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | — | id of the label. Generated by the island when omitted. |
| children | ReactNode | — | Name of the group. It is not an option: it cannot be chosen and the keyboard skips it. |
SelectSeparator
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | Extra classes on the line. It is hidden from assistive technologies (aria-hidden). |
Data Attributes
Attributes you can target in custom CSS to override the style for each state.
| Attribute | On | Values | Description |
|---|---|---|---|
| data-ui="select" | root | data-value="…" | Root hook. data-value holds the value of the chosen option once there is one. With multiple, the chosen values joined by commas. |
| data-multiple | root | "true" or absent | Multiple selection. With name, the root also carries data-name, the name given to the hidden inputs. |
| aria-multiselectable | SelectContent | "true" or absent | Announces that several options can be chosen. |
| data-ui="select-group" | SelectGroup | — | A group of options, named by its label through aria-labelledby. |
| data-ui="select-label" | SelectLabel | — | Name of a group. |
| data-placeholder | SelectValue | the placeholder prop | Text put back in the trigger when a multiple selection becomes empty. |
| data-ui="select-trigger" | SelectTrigger | — | Opens and closes the list. |
| data-ui="select-value" | SelectValue | — | Receives the text of the chosen item. |
| data-ui="select-content" | SelectContent | — | The list. The island toggles its hidden attribute. |
| data-ui="select-item" | SelectItem | data-value="…" | One option. |
| data-active | SelectItem | "true" or absent | The option the keyboard is on while the list is open. Styled with the accent background. |
| aria-selected | SelectItem | "true" | "false" | The chosen option. It also shows a check mark. With multiple, several options can be "true". |
| aria-expanded | SelectTrigger | "true" | "false" | Reflects whether the list is open. |
| data-ui="select-input" | hidden input | — | Rendered when name is set. Emits a change event when the value changes. With multiple, the inputs are replaced on each change and the change event is sent from the root. |
Keyboard
| Key | Action |
|---|---|
| Enter / Space / Arrow Down / Arrow Up | On the closed trigger: opens the list, on the chosen option or the first one. |
| Home / End | Closed: opens the list on the first or last option. Open: moves to the first or last option. |
| Arrow Down / Arrow Up | Open: moves to the next or previous option. Stops at both ends. Group labels, separators and disabled options are skipped. |
| Letters and digits | Typeahead: moves to the next option whose label starts with what you type. Opens the list if needed. Typing the same letter again cycles through the matches. |
| Enter / Space | Open: chooses the active option and closes the list. With multiple: toggles the active option and leaves the list open. |
| Escape | Closes the list without changing the value. Focus stays on the trigger. With multiple, the options already toggled stay chosen. |
| Tab | Closes the list without changing the value and moves on. |
Accessibility
Select follows the select-only combobox pattern. SelectTrigger is a <button role="combobox"> with aria-haspopup="listbox", aria-expanded and aria-controls pointing at the list. SelectContent has role="listbox" and each SelectItem has role="option" with aria-selected. Focus never leaves the trigger: while the list is open, aria-activedescendant on the trigger names the active option. The island generates the ids it needs when the markup has none.
The accessible name of the trigger comes from the label prop (aria-label) or from a <label for> pointing at its id: always provide one of the two. With a role of combobox, the text of the chosen option is exposed as the value of the control, not as its name.
SelectGroup has role="group" and is named by its SelectLabel with aria-labelledby, so a screen reader announces the group when the active option enters it. The label itself is not an option. SelectSeparator is decorative and hidden from assistive technologies.
With multiple, the list carries aria-multiselectable="true" and every chosen option has aria-selected="true" and a check mark. The text of the trigger lists the chosen labels, so the current selection can be read without opening the list.
Limit: the list is not repositioned when it would overflow the viewport. For a long list with search, use Combobox.
Examples
Composed
Groups
Multiple
Edit this component live in the bext playground. The PRISM + signals version is the bext-native idiom: fine-grained reactivity, no virtual DOM.
On This Page