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';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
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.
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.
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
| Prop | Type | |
|---|---|---|
| className | string | Added to the panel’s own classes, so yours wins |
| style | CSSProperties | Merged 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.
| Prop | Type | Default | |
|---|---|---|---|
| items | SelectItem[] | [] | |
| value | string | null | — | Controlled |
| defaultValue | string | null | null | |
| onValueChange | (value: string | null) => void | — | |
| searchable | boolean | false | A search field in the panel |
| filter | (item, query) => boolean | substring on the label | |
| searchPlaceholder | string | 'Search…' | |
| empty | ReactNode | 'No results' | |
| clearable | boolean | false | An X in the field |
| label | ReactNode | — | Above the field, and the accessible name |
| placeholder | string | 'Select…' | |
| hint | ReactNode | — | Under the field |
| error | ReactNode | — | Replaces the hint, and reddens the edge |
| side | 'top' | 'right' | 'bottom' | 'left' | 'bottom' | Tried first |
| align | 'start' | 'center' | 'end' | 'start' | |
| gap | number | 6 | Between the field and the list |
| offset | number | 8 | The least room against the window’s edge |
| matchWidth | boolean | true | The list is the field’s width |
| maxHeight | number | 280 | Past this the list scrolls |
| radius | number | 10 | |
| disabled | boolean | false | |
| theme | 'dark' | 'light' | 'dark' | |
| container | Element | null | document.body | Where the list is rendered |
Keyboard
| Key | |
|---|---|
| Enter / Space / ↓ | Opens, on the current value |
| ↑ ↓ | Walks the options, skipping disabled ones |
| Home / End | First and last |
| A–Z | Typeahead, when there is no search field |
| Enter | Chooses |
| Esc | Closes, and the field keeps focus |