Select
A single-value select built from a trigger button and a popup listbox — the one place this library does not restyle the native element, because most browsers will not let you.
Usage#
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "neelam-ui";
<Select defaultValue="utc">
<SelectTrigger>
<SelectValue placeholder="Select a timezone" />
</SelectTrigger>
<SelectContent>
<SelectItem value="utc">UTC</SelectItem>
<SelectItem value="est">Eastern (EST)</SelectItem>
</SelectContent>
</Select>Why not a native <select>?#
Everywhere else this library styles the real native control. A <select> is the
exception: its open dropdown cannot be restyled in most browsers — padding,
hover colour, and the radius of the <option> list itself are all outside CSS's
reach. So the popup is rebuilt, and the pieces the platform can still provide
are used deliberately:
SelectContentis a native popover (popover="auto"), soEscapeand outside-click dismissal come from the browser.- Focus returns to the trigger on close via the popover's own
toggleevent — covering choosing an option,Escape, and clicking away with one path, the way a native<select>does. - The panel's
min-widthis set from the trigger, so it never renders narrower than the control it belongs to.
Not built on ContextMenu
It uses the same techniques — native popover, deferred opening, scroll lock,
roving DOM focus — but a listbox's semantics (role="listbox"/"option", a
persistent selection) differ enough from a menu's (role="menu"/"menuitem",
activate-and-close) that sharing the components would mean parameterizing
their roles for a second pattern. See DECISIONS.md.
Labelling#
SelectTrigger is a <button>, which is a labelable element, so a wrapping
<label> names it. SelectValue's placeholder is not a label — it is what
shows before a choice is made.
Selected text comes from the item#
SelectValue displays the selected option's rendered text, not its value.
Each SelectItem registers its own label, so value="est" can display as
"Eastern (EST)" with nothing to keep in sync.
Grouping#
SelectSeparator divides runs of options. It renders with no ARIA role —
unlike ContextMenuSeparator, which is role="separator". That is not a
stylistic choice: role="listbox" does not permit a separator child the way
role="menu" does, and this was verified against axe. A role here would be
invalid ARIA structure.
Controlled#
Both the value and the open state can be controlled, independently:
const [value, setValue] = useState("utc");
<Select value={value} onValueChange={setValue}>…</Select>Keyboard#
| Key | Behaviour |
|---|---|
| EnterSpace | Opens the listbox when focus is on the trigger. |
| ↓↑ | Opens the listbox from the trigger; once open, moves between options and wraps at either end. |
| HomeEnd | Moves to the first or last option. |
| Escape | Closes without changing the value, and returns focus to the trigger. |
| Tab | Reaches the trigger. The open listbox is a popover, so it is not a separate tab stop. |
Opening moves focus to the selected option, or the first one if nothing is selected yet — so re-opening a select lands where the user left it.
Accessibility#
Implements the APG select-only combobox pattern:
- The trigger is
role="combobox"witharia-haspopup="listbox",aria-expanded, andaria-controlspointing at the panel. - The panel is
role="listbox"; each option is a native<button>recategorized asrole="option"witharia-selected, so click,Enter, andSpaceactivation stay native. - Focus moves into the list on open and back to the trigger on close, through every dismissal path.
- The page behind is scroll-locked while the list is open, so the panel cannot drift away from its trigger.
- Open and close transitions are dropped under
prefers-reduced-motion.
Long lists want a Combobox
Past a couple of dozen options, arrow-keying becomes the bottleneck.
Combobox is the same control with a text input
that filters as you type.
API reference#
Select#
| Prop | Type | Default |
|---|---|---|
children | ReactNode | — |
defaultValue | string | — |
disabled | boolean | — |
onOpenChange | ((open: boolean) => void) | — |
onValueChange | ((value: string) => void) | — |
openControls the open state. Omit to let the select manage its own state. | boolean | — |
value | string | — |
SelectTrigger#
| Prop | Type | Default |
|---|---|---|
defaultValue | string | — |
disabled | boolean | — |
onOpenChange | ((open: boolean) => void) | — |
onValueChange | ((value: string) => void) | — |
openControls the open state. Omit to let the select manage its own state. | boolean | — |
value | string | — |
SelectValue#
| Prop | Type | Default |
|---|---|---|
placeholderShown when nothing is selected yet. | string | — |
SelectItem#
| Prop | Type | Default |
|---|---|---|
valuerequired | string | — |