Select is a fully-keyboard, accessible custom select. State (value, open
state, disabled, required) lives on the root; the trigger renders the collapsed
button, and the content portals a positioned dropdown. Wrap Select in a
Field to attach a label, helper text, or error message — the field-level state
cascades into the trigger automatically.
Usage
import { Field, Select } from '@takeoff-ui/react-spar';
<Field>
<Field.Label />
<Select>
<Select.Trigger placeholder="Choose..." />
<Select.Content>
<Select.Group>
<Select.Label />
<Select.Item value="..." label="...">
...
</Select.Item>
</Select.Group>
<Select.Separator />
</Select.Content>
</Select>
<Field.Description />
<Field.ErrorMessage />
</Field>
Playground
function PlaygroundDemo() {
return (
<Field className="max-w-90" required>
<Field.Label>Cabin</Field.Label>
<Select defaultValue="economy">
<Select.Trigger placeholder="Pick a cabin" />
<Select.Content>
<Select.Item value="economy" label="Economy">
Economy
</Select.Item>
<Select.Item value="business" label="Business">
Business
</Select.Item>
<Select.Item value="first" label="First class">
First class
</Select.Item>
</Select.Content>
</Select>
<Field.Description>Select the cabin for this itinerary.</Field.Description>
</Field>
);
}
render(<PlaygroundDemo />);
Using with Field
Field is the generic ARIA wrapper. Setting invalid, disabled, required,
optional, or readOnly on Field cascades into the nested Select, and
Field.Label, Field.Description, and Field.ErrorMessage are wired to the
trigger through shared IDs.
function FieldSelectDemo() {
return (
<Field className="max-w-90" required>
<Field.Label>Cabin</Field.Label>
<Select invalid>
<Select.Trigger placeholder="Choose cabin" aria-invalid="true" />
<Select.Content>
<Select.Item value="economy" label="Economy">
Economy
</Select.Item>
<Select.Item value="business" label="Business">
Business
</Select.Item>
<Select.Item value="first" label="First class">
First class
</Select.Item>
</Select.Content>
</Select>
<Field.Description>Select the cabin for this itinerary.</Field.Description>
<Field.ErrorMessage>Please choose a cabin to continue.</Field.ErrorMessage>
</Field>
);
}
render(<FieldSelectDemo />);
Sizes
function SizesDemo() {
return (
<div className="grid w-full max-w-90 gap-3">
<Select size="small">
<Select.Trigger placeholder="Small" />
<Select.Content>
<Select.Item value="ist" label="Istanbul">
Istanbul
</Select.Item>
<Select.Item value="ank" label="Ankara">
Ankara
</Select.Item>
</Select.Content>
</Select>
<Select size="base">
<Select.Trigger placeholder="Base" />
<Select.Content>
<Select.Item value="ist" label="Istanbul">
Istanbul
</Select.Item>
<Select.Item value="ank" label="Ankara">
Ankara
</Select.Item>
</Select.Content>
</Select>
<Select size="large">
<Select.Trigger placeholder="Large" />
<Select.Content>
<Select.Item value="ist" label="Istanbul">
Istanbul
</Select.Item>
<Select.Item value="ank" label="Ankara">
Ankara
</Select.Item>
</Select.Content>
</Select>
</div>
);
}
render(<SizesDemo />);
Groups, Separator, Disabled Item
function GroupsDemo() {
return (
<Select className="max-w-90" defaultValue="ist">
<Select.Trigger placeholder="Choose origin" />
<Select.Content>
<Select.Group>
<Select.Label>Türkiye</Select.Label>
<Select.Item value="ist" label="Istanbul">
Istanbul
</Select.Item>
<Select.Item value="ank" label="Ankara">
Ankara
</Select.Item>
<Select.Item value="izm" label="Izmir">
Izmir
</Select.Item>
</Select.Group>
<Select.Separator />
<Select.Group>
<Select.Label>Europe</Select.Label>
<Select.Item value="lhr" label="London Heathrow">
London Heathrow
</Select.Item>
<Select.Item value="cdg" label="Paris CDG">
Paris CDG
</Select.Item>
<Select.Item value="fra" disabled label="Frankfurt (unavailable)">
Frankfurt (unavailable)
</Select.Item>
</Select.Group>
</Select.Content>
</Select>
);
}
render(<GroupsDemo />);
Indicator
Select.Trigger renders a disclosure chevron at its trailing edge by default —
it flips direction (and switches to the primary color) with the open state, the
same visual language as Accordion.Indicator.
Pass the indicator prop to customize it: a node for a custom static icon, a
render function ({ isOpen }) => … to swap icons by open state, or false to
hide it.
function IndicatorDemo() {
const PlusMinus = ({ isOpen }) => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3 8h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
{!isOpen && <path d="M8 3v10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />}
</svg>
);
return (
<div className="grid w-full max-w-90 gap-3">
{}
<Select defaultValue="ist">
<Select.Trigger placeholder="Default chevron" />
<Select.Content>
<Select.Item value="ist" label="Istanbul">Istanbul</Select.Item>
<Select.Item value="ank" label="Ankara">Ankara</Select.Item>
</Select.Content>
</Select>
{}
<Select defaultValue="ist">
<Select.Trigger placeholder="Custom icon" indicator={(state) => <PlusMinus isOpen={state.isOpen} />} />
<Select.Content>
<Select.Item value="ist" label="Istanbul">Istanbul</Select.Item>
<Select.Item value="ank" label="Ankara">Ankara</Select.Item>
</Select.Content>
</Select>
{}
<Select defaultValue="ist">
<Select.Trigger placeholder="No indicator" indicator={false} />
<Select.Content>
<Select.Item value="ist" label="Istanbul">Istanbul</Select.Item>
<Select.Item value="ank" label="Ankara">Ankara</Select.Item>
</Select.Content>
</Select>
</div>
);
}
render(<IndicatorDemo />);
For full layout control, drive the trigger's render-prop children. A
render-function children opts the trigger out of its built-in indicator and
value wrapper entirely — you own every node, so place a standalone
<Select.Indicator /> wherever you need it. Its children accept the same
node-or-render-function form, so you can swap the glyph by isOpen without
reaching for a hook.
function IndicatorCompoundDemo() {
return (
<Select className="max-w-90" defaultValue="ist">
<Select.Trigger>
{({ label }) => (
<>
<span className="tk-select-value">{label || 'Choose a city'}</span>
<Select.Indicator>
{({ isOpen }) => (isOpen ? '▲' : '▼')}
</Select.Indicator>
</>
)}
</Select.Trigger>
<Select.Content>
<Select.Item value="ist" label="Istanbul">Istanbul</Select.Item>
<Select.Item value="ank" label="Ankara">Ankara</Select.Item>
<Select.Item value="izm" label="Izmir">Izmir</Select.Item>
</Select.Content>
</Select>
);
}
render(<IndicatorCompoundDemo />);
Controlled
function ControlledSelectDemo() {
const [value, setValue] = useState('eco');
return (
<div className="grid w-full max-w-90 gap-3">
<Badge className="w-fit" variant="neutral" appearance="outlined">
Selected: {value}
</Badge>
<Select value={value} onChange={setValue}>
<Select.Trigger placeholder="Pick a fare" />
<Select.Content>
<Select.Item value="eco" label="Eco">
Eco
</Select.Item>
<Select.Item value="flex" label="Eco Flex">
Eco Flex
</Select.Item>
<Select.Item value="biz" label="Business">
Business
</Select.Item>
</Select.Content>
</Select>
</div>
);
}
render(<ControlledSelectDemo />);
Invalid State
function InvalidDemo() {
return (
<Select className="max-w-90" invalid>
<Select.Trigger placeholder="Required field" />
<Select.Content>
<Select.Item value="a" label="Option A">
Option A
</Select.Item>
<Select.Item value="b" label="Option B">
Option B
</Select.Item>
</Select.Content>
</Select>
);
}
render(<InvalidDemo />);
Accessibility & Keyboard
Select.Trigger renders a button with role="combobox",
aria-haspopup="listbox", and aria-expanded.
Select.Content is a role="listbox" portalled to document.body by default
and positioned with Floating UI.
- The trigger displays the selected item's
label (or the configured
placeholder) and exposes that as its accessible name; when nested in a
Field it also gets aria-labelledby pointing at the Field label.
| Key | Behavior |
|---|
| Enter / Space | Open the dropdown or select the highlighted item. |
| ↓ / ↑ | Open the dropdown / move highlight to next or previous item. |
| Home / End | Move highlight to the first / last item. |
| Esc | Close the dropdown without changing selection. |
| typing characters | Typeahead — jumps to the first matching item. |
API Reference
Select
See
Spar Select docs
for primitive behavior.
Props
| Name | Type | Default | Description |
|---|
| children | React.ReactNode | - | Compound parts (Select.Trigger, Select.Content). |
| size | SelectSize | 'base' | Size scale. |
| invalid | boolean | false | Invalid state for form validation styling. |
| contentWidth | SelectContentWidth | 'trigger' | How the portalled SelectContent panel computes its width. See SelectContentWidth. |
| classNames | Partial<Record<"root", string>> | - | Per-slot class name overrides. |
| slotProps | Partial<Record<"root", React.HTMLAttributes<HTMLElement>>> | - | Per-slot HTML attribute overrides. |
| defaultValue | string | - | Uncontrolled initial value |
| autoFocus | boolean | false | Whether to focus the trigger on mount |
| id | string | - | Custom base ID for ARIA relationships. If not provided, one will be generated automatically. Sub-element IDs are derived as ${id}-trigger and ${id}-content. |
| value | string | - | Controlled selected value |
| disabled | boolean | - | Disables the entire select. When inside a Field, inherited from Field. |
| name | string | - | Form field name |
| required | boolean | - | Makes the select required for forms. When inside a Field, inherited from Field. |
| readOnly | boolean | - | Select read-only state. When inside a Field, inherited from Field. A read-only select can be opened and inspected but its value cannot change. |
| open | boolean | - | Controlled open state |
| defaultOpen | boolean | false | Uncontrolled initial open state |
| className | string | - | Appends custom classes to the root slot of this part. |
Events
| Name | Type | Default | Description |
|---|
| onChange | (value: string) => void | - | Callback when selection changes |
| onOpenChange | (open: boolean) => void | - | Callback when open state changes |
Data attributes
| Attribute | Applied when | Purpose |
|---|
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-size | Always | Reflects the resolved size prop so theme recipes can scope size variants. |
| data-invalid | When invalid is true. | Borders/background switch to the danger state. Mirrored from the root. |
| data-disabled | When disabled is true. | Theme hook for the disabled state. Emitted by Spar Select. |
Select.Trigger
See
Spar Select docs
for primitive behavior.
Props
| Name | Type | Default | Description |
|---|
| children | React.ReactNode | - | Trigger content. Defaults to the selected item label or placeholder; supply children to override the rendering, or pass a render function for full layout control. |
| indicator | boolean | React.ReactNode | ((state: SelectIndicatorRenderState) => React.ReactNode) | true | Disclosure indicator after the value. Defaults to a chevron that flips with the open state. Pass false to hide it, a node for a custom static icon, or a render function ({ isOpen }) => … to swap icons by open state. |
| classNames | Partial<Record<SelectTriggerSlot, string>> | - | Per-slot class name overrides. |
| slotProps | Partial<Record<SelectTriggerSlot, React.HTMLAttributes<HTMLElement>>> | - | Per-slot HTML attribute overrides. |
| placeholder | React.ReactNode | - | Text shown when no value is selected |
| className | string | - | Appends custom classes to the root slot of this part. |
Data attributes
| Attribute | Applied when | Purpose |
|---|
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-size | Always | Reflects the resolved size prop so theme recipes can scope size variants. |
| data-invalid | When invalid is true. | Borders/background switch to the danger state. Mirrored from the root. |
| data-state="open" | While the dropdown is open. | Spar open-state hook on the trigger and content. |
| data-state="closed" | While the dropdown is closed. | Spar closed-state hook on the trigger. |
| data-placeholder | When no value is selected. | Theme hook to style the placeholder color. |
Select.Indicator
See
Spar Select docs
for primitive behavior.
Data attributes
| Attribute | Applied when | Purpose |
|---|
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
Select.Content
See
Spar Select docs
for primitive behavior.
Props
| Name | Type | Default | Description |
|---|
| children | React.ReactNode | - | Select.Item, Select.Group, Select.Separator. |
| classNames | Partial<Record<"root", string>> | - | Per-slot class name overrides. |
| slotProps | Partial<Record<"root", React.HTMLAttributes<HTMLElement>>> | - | Per-slot HTML attribute overrides. |
| container | HTMLElement | null | document.body | Portal container element. Content is portaled to document.body by default. |
| side | Side | 'bottom' | Preferred side for positioning relative to trigger |
| align | Align | 'start' | Alignment relative to trigger |
| className | string | - | Appends custom classes to the root slot of this part. |
Events
| Name | Type | Default | Description |
|---|
| onCloseAutoFocus | (event: FocusEvent) => void | - | Focus handler on close |
| onEscapeKeyDown | (event: KeyboardEvent) => void | - | Escape key handler |
| onPointerDownOutside | (event: PointerEvent) => void | - | Outside click handler |
Data attributes
| Attribute | Applied when | Purpose |
|---|
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-size | Always | Reflects the resolved size prop so theme recipes can scope size variants. |
Select.Item
See
Spar Select docs
for primitive behavior.
Props
| Name | Type | Default | Description |
|---|
| children | React.ReactNode | - | Visible item content. Set label alongside it to register the text used for typeahead and trigger display. |
| classNames | Partial<Record<"root", string>> | - | Per-slot class name overrides. |
| slotProps | Partial<Record<"root", React.HTMLAttributes<HTMLElement>>> | - | Per-slot HTML attribute overrides. |
| label | string | - | Text label for this item. Shown in the trigger when this item is selected, and used as the search key for keyboard typeahead. Should be set whenever children is not plain text (e.g. contains icons or other elements) so the trigger can display a clean string representation. |
| value | string | - | Option value |
| disabled | boolean | false | Disables the option |
| className | string | - | Appends custom classes to the root slot of this part. |
Data attributes
| Attribute | Applied when | Purpose |
|---|
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-highlighted | When the item is focused via keyboard or pointer. | Theme hook for the highlighted state. |
| data-state="checked" | When the item matches the current value. | Theme hook for the checked state. |
| data-disabled | When the item disabled is true. | Theme hook for the disabled item state. |
Select.Group
See
Spar Select docs
for primitive behavior.
Data attributes
| Attribute | Applied when | Purpose |
|---|
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
Select.Label
See
Spar Select docs
for primitive behavior.
Data attributes
| Attribute | Applied when | Purpose |
|---|
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
Select.Separator
See
Spar Select docs
for primitive behavior.
Data attributes
| Attribute | Applied when | Purpose |
|---|
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
Type Definitions
| Name | Definition |
|---|
| SelectSize | 'small' | 'base' | 'large' |
| SelectContentWidth | 'trigger' | 'content' | number | string |
| SelectIndicatorRenderState | { isOpen: boolean } |
| SelectTriggerSlot | 'root' | 'value' | 'indicator' |
| Side | 'top' | 'right' | 'bottom' | 'left' |
| Align | 'start' | 'center' | 'end' |