Skip to main content
Markdown

Select

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">
      {/* Default — a chevron that flips direction with the open state. */}
      <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>

      {/* Custom indicator via the Trigger prop — render function gets { isOpen }. */}
      <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>

      {/* Opt out entirely. */}
      <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() {
  // Full layout control: a render-prop children opts the trigger out of the
  // built-in indicator, so you own every node — drop a standalone
  // <Select.Indicator /> wherever you want it.
  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.
KeyBehavior
Enter / SpaceOpen the dropdown or select the highlighted item.
/ Open the dropdown / move highlight to next or previous item.
Home / EndMove highlight to the first / last item.
EscClose the dropdown without changing selection.
typing charactersTypeahead — jumps to the first matching item.

API Reference

Select

See Spar Select docs for primitive behavior.

Props

NameTypeDefaultDescription
childrenReact.ReactNode-Compound parts (Select.Trigger, Select.Content).
sizeSelectSize'base'Size scale.
invalidbooleanfalseInvalid state for form validation styling.
contentWidthSelectContentWidth'trigger'How the portalled SelectContent panel computes its width. See SelectContentWidth.
classNamesPartial<Record<"root", string>>-Per-slot class name overrides.
slotPropsPartial<Record<"root", React.HTMLAttributes<HTMLElement>>>-Per-slot HTML attribute overrides.
defaultValuestring-Uncontrolled initial value
autoFocusbooleanfalseWhether to focus the trigger on mount
idstring-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.
valuestring-Controlled selected value
disabledboolean-Disables the entire select. When inside a Field, inherited from Field.
namestring-Form field name
requiredboolean-Makes the select required for forms. When inside a Field, inherited from Field.
readOnlyboolean-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.
openboolean-Controlled open state
defaultOpenbooleanfalseUncontrolled initial open state
classNamestring-Appends custom classes to the root slot of this part.

Events

NameTypeDefaultDescription
onChange(value: string) => void-Callback when selection changes
onOpenChange(open: boolean) => void-Callback when open state changes

Data attributes

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for wrapper styling on the root slot.
data-sizeAlwaysReflects the resolved size prop so theme recipes can scope size variants.
data-invalidWhen invalid is true.Borders/background switch to the danger state. Mirrored from the root.
data-disabledWhen disabled is true.Theme hook for the disabled state. Emitted by Spar Select.

Select.Trigger

See Spar Select docs for primitive behavior.

Props

NameTypeDefaultDescription
childrenReact.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.
indicatorboolean | React.ReactNode | ((state: SelectIndicatorRenderState) => React.ReactNode)trueDisclosure 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.
classNamesPartial<Record<SelectTriggerSlot, string>>-Per-slot class name overrides.
slotPropsPartial<Record<SelectTriggerSlot, React.HTMLAttributes<HTMLElement>>>-Per-slot HTML attribute overrides.
placeholderReact.ReactNode-Text shown when no value is selected
classNamestring-Appends custom classes to the root slot of this part.

Data attributes

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for wrapper styling on the root slot.
data-sizeAlwaysReflects the resolved size prop so theme recipes can scope size variants.
data-invalidWhen 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-placeholderWhen no value is selected.Theme hook to style the placeholder color.

Select.Indicator

See Spar Select docs for primitive behavior.

Data attributes

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for wrapper styling on the root slot.

Select.Content

See Spar Select docs for primitive behavior.

Props

NameTypeDefaultDescription
childrenReact.ReactNode-Select.Item, Select.Group, Select.Separator.
classNamesPartial<Record<"root", string>>-Per-slot class name overrides.
slotPropsPartial<Record<"root", React.HTMLAttributes<HTMLElement>>>-Per-slot HTML attribute overrides.
containerHTMLElement | nulldocument.bodyPortal container element. Content is portaled to document.body by default.
sideSide'bottom'Preferred side for positioning relative to trigger
alignAlign'start'Alignment relative to trigger
classNamestring-Appends custom classes to the root slot of this part.

Events

NameTypeDefaultDescription
onCloseAutoFocus(event: FocusEvent) => void-Focus handler on close
onEscapeKeyDown(event: KeyboardEvent) => void-Escape key handler
onPointerDownOutside(event: PointerEvent) => void-Outside click handler

Data attributes

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for wrapper styling on the root slot.
data-sizeAlwaysReflects the resolved size prop so theme recipes can scope size variants.

Select.Item

See Spar Select docs for primitive behavior.

Props

NameTypeDefaultDescription
childrenReact.ReactNode-Visible item content. Set label alongside it to register the text used for typeahead and trigger display.
classNamesPartial<Record<"root", string>>-Per-slot class name overrides.
slotPropsPartial<Record<"root", React.HTMLAttributes<HTMLElement>>>-Per-slot HTML attribute overrides.
labelstring-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.
valuestring-Option value
disabledbooleanfalseDisables the option
classNamestring-Appends custom classes to the root slot of this part.

Data attributes

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for wrapper styling on the root slot.
data-highlightedWhen 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-disabledWhen the item disabled is true.Theme hook for the disabled item state.

Select.Group

See Spar Select docs for primitive behavior.

Data attributes

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for wrapper styling on the root slot.

Select.Label

See Spar Select docs for primitive behavior.

Data attributes

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for wrapper styling on the root slot.

Select.Separator

See Spar Select docs for primitive behavior.

Data attributes

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for wrapper styling on the root slot.

Type Definitions

NameDefinition
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'