Skip to content

Documentation

neelam-ui

Search documentation

Getting Started
Forms
Overlays
Navigation
Data Display
Layout
AI & Chat
Blocks
GitHub repository

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:

  • SelectContent is a native popover (popover="auto"), so Escape and outside-click dismissal come from the browser.
  • Focus returns to the trigger on close via the popover's own toggle event — covering choosing an option, Escape, and clicking away with one path, the way a native <select> does.
  • The panel's min-width is 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#

Keyboard shortcuts
KeyBehaviour
EnterSpaceOpens the listbox when focus is on the trigger.
Opens the listbox from the trigger; once open, moves between options and wraps at either end.
HomeEndMoves to the first or last option.
EscapeCloses without changing the value, and returns focus to the trigger.
TabReaches 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" with aria-haspopup="listbox", aria-expanded, and aria-controls pointing at the panel.
  • The panel is role="listbox"; each option is a native <button> recategorized as role="option" with aria-selected, so click, Enter, and Space activation 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#

Props for Select
PropTypeDefault
childrenReactNode
defaultValuestring
disabledboolean
onOpenChange((open: boolean) => void)
onValueChange((value: string) => void)
open

Controls the open state. Omit to let the select manage its own state.

boolean
valuestring

SelectTrigger#

Props for SelectTrigger
PropTypeDefault
defaultValuestring
disabledboolean
onOpenChange((open: boolean) => void)
onValueChange((value: string) => void)
open

Controls the open state. Omit to let the select manage its own state.

boolean
valuestring

SelectValue#

Props for SelectValue
PropTypeDefault
placeholder

Shown when nothing is selected yet.

string

SelectItem#

Props for SelectItem
PropTypeDefault
valuerequiredstring