Input

Select

One answer out of a list, with the list under the field that asks. A field to read against, a panel to choose from, and focus that never moves between them.

Usage

import { Select } from '@carabine/ui/select';
Country

Where you are billed

<Select
  label="Country"
  items={countries}
  value={value}
  onValueChange={setValue}
  hint="Where you are billed"
/>

Two materials, on purpose. The field is a hole and it is opaque; the panel is glass over the page. A select that used one material for both would be a panel you could read a page through.

Where focus lives

Focus never leaves the field. The panel is a portal — it has to be, or the first ancestor with overflow: hidden clips it — and moving focus into a portal is how a select ends up scrolling the page, closing on its own, or returning focus somewhere else when it shuts. The field keeps focus and drives the list with aria-activedescendant.

Two consequences worth knowing, because both were bugs first. The scroll listener that closes the panel when the page moves has to exclude its own panel — a capture-phase scroll on window hears everything, including the list you are scrolling:

const away = (event: Event) => {
  const target = event.target;
  if (target instanceof Node && panelRef.current?.contains(target)) return;
  shut();
};

And scrollIntoView is gated to the keyboard. Called on every active change it yanks the list under a pointer that was only passing over a row.

Examples

searchable puts a field in the panel and filters as you type — a substring on the label by default, or whatever filter says. clearable adds an X to the field, which sets the value to null rather than to an empty string.

Country
<Select label="Country" items={countries} searchable clearable placeholder="Anywhere" />

Groups, icons, errors

Consecutive items sharing a group are drawn under one heading. A disabled option is stepped over by the arrows rather than merely dimmed, and an error replaces the hint and reddens the edge.

Plan

Pick a plan to continue.

<Select
  label="Plan"
  items={[
    { id: 'free', label: 'Free', icon: <Globe /> },
    { id: 'pro', label: 'Pro', icon: <Lock />, description: '€19 a month' },
    { id: 'ent', label: 'Enterprise', disabled: true, description: 'Talk to us' },
  ]}
  error="Pick a plan to continue."
/>

Customization

<Select
  items={items}
  side="top"          // tried first; it flips only if the other side is better
  align="end"
  gap={6}             // between the field and the list
  offset={8}          // the least room kept against the window's edge
  matchWidth={false}  // let the list size to its content
  maxHeight={320}     // past this the list scrolls
  radius={10}
  theme="light"
/>

Styling reference

Tailwind utilities plus select.css, which carries the panel's scrollbar — thin, no track, and transparent until the pointer is on the list. The panel is a core, not a shell: it is a single layer and the text lands on it, so it takes the heavier of the two glass weights.

The active row is one sliding bar, not a background painted on each row in turn. Hover and keyboard focus are the same question, and answering it twice leaves a list pointing at two options at once.

API reference

PropType
classNamestringAdded to the panel’s own classes, so yours wins
styleCSSPropertiesMerged after the component’s own inline styles, so yours wins

Both land on the panel. The trigger is already your element — you style it where you write it — and the panel is the part that portals away from your markup and is otherwise out of reach. State is legible from CSS as well — data-state, data-disabled, data-side — so a rule can answer it without knowing a single class name of ours. See Theming.

PropTypeDefault
itemsSelectItem[][]
valuestring | nullControlled
defaultValuestring | nullnull
onValueChange(value: string | null) => void
searchablebooleanfalseA search field in the panel
filter(item, query) => booleansubstring on the label
searchPlaceholderstring'Search…'
emptyReactNode'No results'
clearablebooleanfalseAn X in the field
labelReactNodeAbove the field, and the accessible name
placeholderstring'Select…'
hintReactNodeUnder the field
errorReactNodeReplaces the hint, and reddens the edge
side'top' | 'right' | 'bottom' | 'left''bottom'Tried first
align'start' | 'center' | 'end''start'
gapnumber6Between the field and the list
offsetnumber8The least room against the window’s edge
matchWidthbooleantrueThe list is the field’s width
maxHeightnumber280Past this the list scrolls
radiusnumber10
disabledbooleanfalse
theme'dark' | 'light''dark'
containerElement | nulldocument.bodyWhere the list is rendered

Keyboard

Key
Enter / Space / ↓Opens, on the current value
↑ ↓Walks the options, skipping disabled ones
Home / EndFirst and last
A–ZTypeahead, when there is no search field
EnterChooses
EscCloses, and the field keeps focus