Select
A field for choosing one or more values from a list.
import { Select } from '@aksornproject/aksara-ui'Basic select
Select is a namespace of parts. Use it to pick values. To run actions, use Menu.
Select.Trigger draws the 44px field box. Select.Positioner places the panel 4px below the trigger with the left edges aligned. Select.Popup is the menu panel, at least 280px wide and at least as wide as the trigger. Select.List holds the items and scrolls natively.
Pass items to Select.Root so the trigger can show a label before the list mounts. It takes a record of labels or an array of value and label entries. A single select can change to null.
<div className="w-full max-w-[320px]">
<Select.Root items={subjects} value={subject} onValueChange={setSubject}>
<Select.Trigger label="Subject" />
<Select.Portal>
<Select.Positioner>
<Select.Popup>
<Select.List>
{subjects.map((item) => (
<Select.Item key={item.value} value={item.value}>
{item.label}
</Select.Item>
))}
</Select.List>
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>
</div>Variants
variant is input, the default, or ghost. The ghost trigger has no border and no fill until hover. Give a trigger without a label an aria-label.
<div className="flex w-full max-w-[320px] flex-col gap-spacing-16">
{VARIANTS.map((variant) => (
<Select.Root key={variant} items={terms} defaultValue="term-1">
<Select.Trigger variant={variant} aria-label={`Term, ${variant}`} />
<Select.Portal>
<Select.Positioner>
<Select.Popup>
<Select.List>
{terms.map((item) => (
<Select.Item key={item.value} value={item.value}>
{item.label}
</Select.Item>
))}
</Select.List>
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>
))}
</div>Label, description and required
label names the field and renders above the trigger. Clicking it focuses the trigger without opening the popup. description renders between the label and the trigger. placeholder shows while nothing is selected and defaults to "Select...".
Put required on Select.Root. It turns on native form validation and adds one star to the label.
<div className="w-full max-w-[320px]">
<Select.Root items={grades} name="grade" required>
<Select.Trigger
label="Grade"
description="Students see homework for this grade only"
placeholder="Choose a grade"
/>
<Select.Portal>
<Select.Positioner>
<Select.Popup>
<Select.List>
{grades.map((item) => (
<Select.Item key={item.value} value={item.value}>
{item.label}
</Select.Item>
))}
</Select.List>
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>
</div>Status
status is error, warning or success. It colours the border and the message under the trigger. Only error sets aria-invalid. Disabled, read-only and loading triggers ignore the status border.
<div className="flex w-full max-w-[320px] flex-col gap-spacing-16">
{FIELDS.map(({ status, label, message }) => (
<Select.Root key={status} items={rooms}>
<Select.Trigger status={status} label={label} message={message} />
<Select.Portal>
<Select.Positioner>
<Select.Popup>
<Select.List>
{rooms.map((item) => (
<Select.Item key={item.value} value={item.value}>
{item.label}
</Select.Item>
))}
</Select.List>
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>
))}
</div>Disabled, read only and loading
disabled and readOnly belong on Select.Root. A read-only trigger keeps its value readable and hides the chevron. Its popup still opens for browsing, but the value cannot change.
loading shows a skeleton bar in place of the value, the chevron and the message. The trigger stays focusable, the popup does not open and the value cannot change.
<div className="flex w-full max-w-[320px] flex-col gap-spacing-16">
<Year label="Disabled" disabled />
<Year label="Read only" readOnly />
<Year label="Loading" loading />
</div>Multiple values
multiple on Select.Root makes the value an array and keeps the popup open after each toggle. The trigger shows the selected labels joined with commas on one line and truncates them.
A function child of Select.Value formats the value instead, for example as a count. It runs only when something is selected. Pass the Select.Value as the trigger's children. The chevron stays.
<div className="flex w-full max-w-[320px] flex-col gap-spacing-16">
{[false, true].map((count) => (
<Select.Root
key={String(count)}
items={subjects}
multiple
value={picked}
onValueChange={setPicked}
>
<Select.Trigger label={count ? 'Subjects, as a count' : 'Subjects'}>
{count ? (
<Select.Value placeholder="Select...">
{(selected: string[]) => `${selected.length} selected`}
</Select.Value>
) : undefined}
</Select.Trigger>
<Select.Portal>
<Select.Positioner>
<Select.Popup>
<Select.List>
{subjects.map((item) => (
<Select.Item key={item.value} value={item.value}>
{item.label}
</Select.Item>
))}
</Select.List>
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>
))}
</div>Search
Select.Search adds a search row above the list. It does not filter automatically. Track the query in your app and apply hidden to items that do not match. Keep all items mounted so filtering does not remove a selected value from an uncontrolled multiple select.
Text keys stay in the search field and do not run typeahead. Arrow keys move into the list. The clear button empties the query, never the selection.
<div className="w-full max-w-[320px]">
<Select.Root
items={students}
multiple
value={picked}
onValueChange={setPicked}
>
<Select.Trigger label="Students" />
<Select.Portal>
<Select.Positioner>
<Select.Popup>
<Select.Search value={query} onValueChange={setQuery} />
<Select.List>
{students.map((item) => (
<Select.Item
key={item.value}
value={item.value}
hidden={
!item.label.toLowerCase().includes(query.toLowerCase())
}
>
{item.label}
</Select.Item>
))}
</Select.List>
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>
</div>Groups and scrolling
Put a Select.GroupLabel first inside each Select.Group. Select.Separator draws a rule between items. maxHeight on Select.List caps the list, in px or any CSS length, and it scrolls past that. Without it, the list grows to the space the viewport has.
<div className="w-full max-w-[320px]">
<Select.Root items={[...core, ...electives]}>
<Select.Trigger label="Subject" />
<Select.Portal>
<Select.Positioner>
<Select.Popup>
<Select.List maxHeight={240}>
<Select.Group>
<Select.GroupLabel>Core</Select.GroupLabel>
{core.map((item) => (
<Select.Item key={item.value} value={item.value}>
{item.label}
</Select.Item>
))}
</Select.Group>
<Select.Separator />
<Select.Group>
<Select.GroupLabel>Electives</Select.GroupLabel>
{electives.map((item) => (
<Select.Item key={item.value} value={item.value}>
{item.label}
</Select.Item>
))}
</Select.Group>
</Select.List>
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>
</div>Accessibility
- The trigger is a native button with
role="combobox". Thelabelnames it. descriptionandmessageare linked witharia-describedby.status="error"setsaria-invalid.requiredonSelect.Rootsetsaria-required. The star on the label isaria-hidden.- Arrow keys, Home, End and typeahead move between options. Enter and Space pick, and Escape closes.
Select.Searchis named "Search" and its clear button stays out of the tab order and the accessibility tree.
API
Select.Root
Select.Root takes the Base UI Select Root props and adds loading. It is generic over the value type and multiple.
Prop
Type
Select.Trigger
The ref and className reach the trigger button, not the field around it. Select.Trigger has no render.
Prop
Type
Select.Value
Prop
Type
Select.Icon
Prop
Type
Select.Portal
Prop
Type
Select.Positioner
side defaults to bottom, align to start, sideOffset to 4 and alignItemWithTrigger to false.
Prop
Type
Select.Popup
Prop
Type
Select.List
Prop
Type
Select.Item
A selected item gets the selection fill and the end tick.
Prop
Type
Select.ItemText
Prop
Type
Select.ItemIndicator
Prop
Type
Select.Group
Prop
Type
Select.GroupLabel
Prop
Type
Select.Separator
Prop
Type
Select.Search
The ref and className reach the input.
Prop
Type