Skip to content

Select

Chooses one value from a fixed list. The trigger is the same shell as TextField, wearing a chevron.

tsx
import { Select } from 'neba';

<Select
  label="Region"
  placeholder="Pick a region"
  items={[
    { value: 'icn', label: 'Seoul' },
    { value: 'nrt', label: 'Tokyo' }
  ]}
/>;

Props

PropTypeDefaultDescription
variantshared'solid' | 'outline' | 'text''outline'Weight of the surface. The same shell as a TextField, so the two are indistinguishable in a form
sizeshared'xs' | 'sm' | 'md' | 'lg' | 'xl''md'Height and type scale. The same heights as Button and TextField
colorshared'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info''primary'Semantic colour role. The surface is white, so it reaches the edge and the focus ring
densityshared'default' | 'compact''default'Padding only: never the height, never the type scale
elevationshared0 | 1 | 2 | 30Drop shadow depth of the trigger. The popup carries its own
items * readonly SelectOption[]The options, as an array of { value, label?, disabled?, group? }. Adjacent options naming the same group get a heading over them
valuestring | number | nullThe chosen value. Use with onValueChange for a controlled select
defaultValuestring | number | nullThe initial value, for an uncontrolled select
onValueChange(value: string | number | null) => voidCalled when the chosen value changes
placeholderReactNodeShown in the trigger while nothing is chosen
labelReactNodeThe label, wired to the control by Base UI's Field
descriptionReactNodeHelper text
errorReactNodeError message. Its presence turns the control invalid and re-points the colour family at danger
invalidboolean!!errorForces the invalid state without a message, for when a form library owns validity
startIconReactNodeContent before the value
fullWidthbooleanfalseStretches to the width of the container
namestringIdentifies the field when a form is submitted
requiredbooleanWhether a value must be chosen before the form is submitted
readOnlybooleanfalseShown but not changeable. Keeps its colour and edge, drains the saturation
disabledbooleanfalseUnavailable. Drops the colour family for neutral grey
classNamesNebaSlots<'label' | 'control' | 'description' | 'error' | 'popup' | 'item'>Class names for the parts behind the root. The root itself is className, so there is no root key

Native <div> attributes pass through to the root. Only color and defaultValue are excluded, since the table above spells them differently.

When the options have to be searched, use Combobox; with only two or three, use RadioGroup or SegmentedButton.

items

Options are passed as an array rather than composed from components.

ts
interface SelectOption {
  value: string | number;
  label?: React.ReactNode; // defaults to the value
  disabled?: boolean;
  group?: string;
}

value is a string or a number. It is what gets submitted with a form, so objects are not accepted: keep the identifier here and look the object up at the call site.

Examples

variant

The same three weights a TextField has, drawn on the same shell, so a select and the fields around it never disagree about height or border.

size

group

group puts a heading over an option and the ones next to it that name the same group: time zones under their region, fonts under their family. The heading cannot be highlighted, chosen or reached by typeahead.

A group is a run of adjacent options, so the array's order is the list's order and nothing is moved out from under you. Two separate runs naming the same group draw two headings. Options with no group render on their own, wherever the array puts them.

disabled · readOnly · error

The popup

The popup renders in a portal at the end of <body>, so it leaves any subtree your app scoped a CSS reset to. The positioner carries a neba-portal class to hang that reset off. An app with Tailwind's Preflight applied globally needs nothing.

classNames

className lands on the root (the column holding the label, the trigger and the two lines under it), so the trigger is reached through classNames.control.

tsx
<Select
  items={plans}
  label="Plan"
  classNames={{ control: 'font-mono', popup: 'max-h-40', item: 'rounded-none' }}
/>

The slots are label, control, description, error, popup and item. The last two matter more than the rest: the popup renders at the end of <body>, so a descendant selector written against the root will not reach it and these are the only way in. See prop conventions for how a class name you pass resolves against the component's own.

Accessibility

  • The trigger has the combobox role, and label becomes its accessible name.
  • The popup's positioning and flipping at the window edge, focus handling, typeahead and the hidden input for form submission are all handled.
  • A disabled option stays in the list and reports aria-disabled.

Released under the MIT License