Skip to main content

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.Viewport>
<Select.Group>
<Select.Label />
<Select.Item value="..." label="...">
...
</Select.Item>
</Select.Group>
<Select.Separator />
</Select.Viewport>
<Select.Arrow />
</Select.Content>
</Select>
<Field.Description />
<Field.ErrorMessage />
</Field>

Select.Viewport is optional — short lists can render items directly in Select.Content. Wrap the items in a viewport when the list can grow long enough to need a bounded, scrollable area; the panel itself no longer scrolls, so a long list without a viewport will overflow.

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, 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.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 />);

Scrollable options​

Wrap the items in Select.Viewport to cap a long list and scroll within it. The viewport is the sole scroll container — the panel itself no longer scrolls — and it renders a slim branded scrollbar. Scrolling also works via wheel, trackpad, and keyboard, and typeahead / arrow navigation keep the highlighted option in view.


function ScrollableDemo() {
  const cities = [
    ['ist', 'Istanbul'], ['ank', 'Ankara'], ['izm', 'Izmir'],
    ['ada', 'Adana'], ['ant', 'Antalya'], ['bur', 'Bursa'],
    ['kon', 'Konya'], ['gaz', 'Gaziantep'], ['kay', 'Kayseri'],
    ['tra', 'Trabzon'], ['sam', 'Samsun'], ['esk', 'Eskisehir'],
    ['mer', 'Mersin'], ['diy', 'Diyarbakir'], ['erz', 'Erzurum'],
  ];

  return (
    <Select className="max-w-90" defaultValue="ist">
      <Select.Trigger placeholder="Choose a city" />
      <Select.Content>
        <Select.Viewport>
          {cities.map(([value, label]) => (
            <Select.Item key={value} value={value} label={label}>
              {label}
            </Select.Item>
          ))}
        </Select.Viewport>
      </Select.Content>
    </Select>
  );
}

render(<ScrollableDemo />);

Arrow​

Drop Select.Arrow inside Select.Content — as a sibling of Select.Viewport — to render a small pointer from the panel toward the trigger. Spar's Floating UI middleware positions and rotates it automatically, and its fill matches the panel surface. Keep it outside the viewport so the scroll region never clips it.


function ArrowDemo() {
  return (
    <Select className="max-w-90" defaultValue="ist">
      <Select.Trigger placeholder="Choose a city" />
      <Select.Content>
        <Select.Viewport>
          <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.Viewport>
        <Select.Arrow />
      </Select.Content>
    </Select>
  );
}

render(<ArrowDemo />);

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.Viewport (recommended) wrapping 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'center'Alignment relative to trigger
classNamestring-Appends custom classes to the root slot of this part.

Events​

NameTypeDefaultDescription
onCloseAutoFocus(event: FocusEvent) => void-Called when the select closes from inside the listbox (Escape, Enter/Space, Tab or an item click) and focus is about to return to the trigger. Not called for outside pointer dismissal, where focus stays where the user clicked.
onEscapeKeyDown(event: KeyboardEvent) => void-Escape key handler. Receives the native keyboard event; calling preventDefault() on it keeps the select open.
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.Viewport​

See Spar Select docs for primitive behavior.

Data attributes​

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

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.

Select.Arrow​

See Spar Select docs for primitive behavior.

Data attributes​

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for wrapper styling on the root slot.
data-placementAlwaysComputed placement (top | bottom | left | right, optionally with an align suffix) so the arrow can rotate to face the trigger.

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'