bx

Select

Displays a list of options for the user to pick from, triggered by a button.

tsx
<div className="w-full max-w-xs">
  <Select
    label="Fruit"
    placeholder="Select a fruit"
    options={[
      { value: "apple", label: "Apple" },
      { value: "banana", label: "Banana" },
      { value: "orange", label: "Orange" },
      { value: "grape", label: "Grape" },
    ]}
  />
</div>

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

Terminal
$ bext ui add select

Usage

tsx
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, SelectGroup, SelectLabel, SelectSeparator } from "@bext-stack/ui/primitives"

// Shorthand with the options prop
<Select
  label="Fruit"
  name="fruit"
  placeholder="Select a fruit"
  options={[
    { value: "apple", label: "Apple" },
    { value: "banana", label: "Banana" },
    { value: "orange", label: "Orange" },
  ]}
/>

// Composed form: same output
<Select>
  <SelectTrigger label="Fruit">
    <SelectValue placeholder="Select a fruit" />
  </SelectTrigger>
  <SelectContent>
    <SelectItem value="apple">Apple</SelectItem>
    <SelectItem value="banana">Banana</SelectItem>
    <SelectItem value="orange">Orange</SelectItem>
  </SelectContent>
</Select>

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

tsx
<div className="w-full max-w-xs">
  <Select>
    <SelectTrigger label="Theme">
      <SelectValue placeholder="Select a theme" />
    </SelectTrigger>
    <SelectContent>
      <SelectItem value="light">Light</SelectItem>
      <SelectItem value="dark">Dark</SelectItem>
      <SelectItem value="system">System</SelectItem>
    </SelectContent>
  </Select>
</div>

Groups

tsx
<Select
  id="timezone"
  label="Timezone"
  name="timezone"
  placeholder="Select a timezone"
  options={[
    { label: "Europe", options: [
      { value: "paris", label: "Paris" },
      { value: "lisbon", label: "Lisbon" },
    ] },
    { label: "Americas", options: [
      { value: "new-york", label: "New York" },
      { value: "sao-paulo", label: "Sao Paulo" },
    ] },
  ]}
/>

// Composed form, with a separator between the groups
<Select name="timezone">
  <SelectTrigger label="Timezone">
    <SelectValue placeholder="Select a timezone" />
  </SelectTrigger>
  <SelectContent>
    <SelectGroup>
      <SelectLabel>Europe</SelectLabel>
      <SelectItem value="paris">Paris</SelectItem>
      <SelectItem value="lisbon">Lisbon</SelectItem>
    </SelectGroup>
    <SelectSeparator />
    <SelectGroup>
      <SelectLabel>Americas</SelectLabel>
      <SelectItem value="new-york">New York</SelectItem>
      <SelectItem value="sao-paulo">Sao Paulo</SelectItem>
    </SelectGroup>
  </SelectContent>
</Select>

Multiple

tsx
<Select
  multiple
  label="Toppings"
  name="toppings"
  placeholder="Select toppings"
  options={[
    { value: "cheese", label: "Cheese" },
    { value: "mushrooms", label: "Mushrooms" },
    { value: "olives", label: "Olives" },
    { value: "peppers", label: "Peppers" },
    { value: "onions", label: "Onions" },
  ]}
/>

// Start with a selection: pass an array
<Select multiple name="toppings" value={["cheese", "olives"]} options={[...]} />
Try it live
Open in play

Edit this component live in the bext playground. The PRISM + signals version is the bext-native idiom: fine-grained reactivity, no virtual DOM.

src/app/page.tsx 2 files · signals · runs in your browser
Pure PRISM + real bext signals: fine-grained, no re-render.