---
title: BaseSelect
description: Compose a custom listbox with selection, navigation, and popup ownership.
package: moraine
version: 0.6.0
repository: https://github.com/subframe7536/moraine
---

# BaseSelect

> Compose a custom listbox with selection, navigation, and popup ownership.

Use BaseSelect to build a custom selection control from a trigger and listbox parts. It manages
selected values, open state, keyboard navigation, and native form values. Your application owns
filtering, query text, grouping, and item creation.

For a ready-made field, start with [Select](https://moraine.subf.dev/components/select.md), [Combobox](https://moraine.subf.dev/components/combobox.md),
or [MultiSelect](https://moraine.subf.dev/components/multi-select.md).

## Basic usage

```tsx
import { BaseSelect } from 'moraine'

export function Example() {
  return (
    <BaseSelect items={[{ label: 'Option A', value: 'a' }]}>
      <BaseSelect.Control>
        <BaseSelect.Trigger>Choose an option</BaseSelect.Trigger>
      </BaseSelect.Control>
      <BaseSelect.Content>
        <BaseSelect.Listbox>
          <BaseSelect.Item item={{ label: 'Option A', value: 'a' }}>Option A</BaseSelect.Item>
        </BaseSelect.Listbox>
      </BaseSelect.Content>
    </BaseSelect>
  )
}
```

## Anatomy

```text
BaseSelect [component; no DOM]
├── BaseSelect.Control [part; slot=control]
│   └── BaseSelect.Trigger [part; slot=trigger]
└── positioner [internal]
    └── BaseSelect.Content [part; slot=content]
        ├── BaseSelect.Listbox [part; slot=listbox]
        │   ├── BaseSelect.Group [part; slot=group]
        │   │   ├── BaseSelect.GroupLabel [part; slot=groupLabel]
        │   │   └── BaseSelect.Item [part; slot=item]
        │   ├── BaseSelect.Item [part; slot=item]
        │   └── BaseSelect.Separator [part; slot=separator]
        └── BaseSelect.Empty [part; slot=empty]
```

Control is an optional layout container and floating anchor; without it, Content uses the registered focus owner as its anchor.

## Usage

### Control and focus ownership

Control is an optional, non-interactive layout container and floating anchor. It does not toggle,
own keyboard navigation, become a tab stop, or receive `role="combobox"`. Trigger is the primary
select-like focus owner and activator. If Control is omitted, Content falls back to the registered
focus owner for positioning.

### Standard composition

```tsx
import { BaseSelect, Button, Icon } from 'moraine'
import { For } from 'solid-js'

const GROUPS = [
  {
    label: 'Frontend',
    items: [
      { value: 'solid', label: 'Solid', description: 'Fine-grained reactive UI' },
      { value: 'react', label: 'React', description: 'Component-based UI' },
    ],
  },
  {
    label: 'Meta-frameworks',
    items: [
      { value: 'astro', label: 'Astro', description: 'Content-focused web framework' },
      { value: 'sveltekit', label: 'SvelteKit', description: 'Application framework for Svelte' },
    ],
  },
]

const FRAMEWORKS = GROUPS.flatMap((group) => group.items)

export default function Example() {
  return (
    <BaseSelect
      items={FRAMEWORKS}
      itemToLabelString={(item) => `${item.value} ${item.description}`}
    >
      <BaseSelect.Control>
        <BaseSelect.Trigger
          as={Button}
          variant="outline"
          class="min-w-52 justify-between"
          trailing="i-lucide:chevrons-up-down"
        >
          {(state) => (
            <span>
              {FRAMEWORKS.find((item) => item.value === state.value[0])?.label ??
                'Select framework…'}
            </span>
          )}
        </BaseSelect.Trigger>
      </BaseSelect.Control>
      <BaseSelect.Content>
        <BaseSelect.Listbox>
          <For each={GROUPS}>
            {(group) => (
              <BaseSelect.Group>
                <BaseSelect.GroupLabel>{group.label}</BaseSelect.GroupLabel>
                <For each={group.items}>
                  {(item) => (
                    <BaseSelect.Item item={item}>
                      {(state) => (
                        <>
                          <Icon
                            name="i-lucide:check"
                            class={state.selected ? 'opacity-100' : 'opacity-0'}
                          />
                          <span>
                            {state.item.label}
                            <small class="text-muted-foreground block">
                              {state.item.description}
                            </small>
                          </span>
                        </>
                      )}
                    </BaseSelect.Item>
                  )}
                </For>
              </BaseSelect.Group>
            )}
          </For>
        </BaseSelect.Listbox>
        <BaseSelect.Empty>No frameworks found.</BaseSelect.Empty>
      </BaseSelect.Content>
    </BaseSelect>
  )
}
```

### Custom searchable composition

```tsx
import { BaseSelect, Icon } from 'moraine'
import { createBaseSelectSearchInput } from 'moraine/utils'
import type { BaseSelectSearchInputOptions } from 'moraine/utils'
import { createMemo, createSignal, For } from 'solid-js'

const FRAMEWORKS = [
  { value: 'solid', label: 'Solid', description: 'Fine-grained reactive UI' },
  { value: 'react', label: 'React', description: 'Component-based UI' },
  { value: 'astro', label: 'Astro', description: 'Content-focused web framework' },
  { value: 'sveltekit', label: 'SvelteKit', description: 'Application framework for Svelte' },
]

function SearchControl(
  props: Pick<BaseSelectSearchInputOptions, 'searchValue' | 'setSearchValue'>,
) {
  const state = BaseSelect.useContext()
  const input = createBaseSelectSearchInput({
    state,
    searchValue: () => props.searchValue(),
    setSearchValue: (value) => props.setSearchValue(value),
  })
  return (
    <BaseSelect.Control class="px-2 border border-input rounded-md flex w-64 items-center">
      <input
        {...input.inputProps}
        class="py-1.5 outline-none bg-transparent flex-1"
        placeholder="Search frameworks…"
      />
      <button
        type="button"
        tabIndex={-1}
        aria-label="Toggle frameworks"
        onPointerDown={(event) => {
          event.preventDefault()
          event.stopPropagation()
          state.focusOwner()?.focus()
        }}
        onClick={(event) => {
          event.stopPropagation()
          state.setOpen(!state.open())
        }}
      >
        <Icon name="i-lucide:chevrons-up-down" />
      </button>
    </BaseSelect.Control>
  )
}

export default function Example() {
  const [searchValue, setSearchValue] = createSignal('')
  const items = createMemo(() => {
    const query = searchValue().toLowerCase()
    return query
      ? FRAMEWORKS.filter(
          (item) =>
            item.label.toLowerCase().includes(query) ||
            item.description.toLowerCase().includes(query),
        )
      : FRAMEWORKS
  })
  return (
    <BaseSelect
      items={items()}
      getItemByValue={(value) => FRAMEWORKS.find((item) => item.value === value)}
      itemToLabelString={(item) => `${item.label} ${item.description}`}
    >
      <SearchControl searchValue={searchValue} setSearchValue={setSearchValue} />
      <BaseSelect.Content onExitComplete={() => setSearchValue('')}>
        <BaseSelect.Listbox>
          <For each={items()}>
            {(item) => (
              <BaseSelect.Item item={item}>
                {(state) => (
                  <>
                    <Icon
                      name="i-lucide:check"
                      class={state.selected ? 'opacity-100' : 'opacity-0'}
                    />
                    <span>
                      {state.item.label}
                      <small class="text-muted-foreground block">{state.item.description}</small>
                    </span>
                  </>
                )}
              </BaseSelect.Item>
            )}
          </For>
        </BaseSelect.Listbox>
        <BaseSelect.Empty>No frameworks found.</BaseSelect.Empty>
      </BaseSelect.Content>
    </BaseSelect>
  )
}
```

The input helper registers `focusOwner`; it never becomes the floating anchor. The caller owns the
physical input, canonical source, filtering, and query cleanup. No BaseSelect.Trigger is required
for this editable composition.

### Item fields

`BaseSelect.Item` accepts collection item data via the `item` prop: `<BaseSelect.Item item={item} />`.
Ordinary component props are DOM/rendering props for the rendered `<div>`. Custom item fields are
available through render state: `<BaseSelect.Item<MyItem> item={item}>{(state) => state.item.custom}</BaseSelect.Item>`,
and arbitrary item fields never leak into rendered DOM attributes.

### Filtered collections

By default, `<BaseSelect items={items}>` uses `items` as both the canonical data source and the
active navigation collection.

For custom filtered compositions (such as searchable controls), provide `getItemByValue` alongside
a filtered `items` view:

```tsx
<BaseSelect items={filteredItems()} getItemByValue={(value) => allItemsByValue.get(value)}>
  {/* ... */}
</BaseSelect>
```

The filtered `items` array controls which options users can navigate. `getItemByValue` looks up
selected items in the full collection, so labels, disabled state, and form values remain available
when an item is filtered out.

### Styling

BaseSelect exposes `control`, `trigger`, `content`, `listbox`, `item`, `group`, `groupLabel`,
and direct `class` or `style` on the part that renders the element. See [Customization](https://moraine.subf.dev/docs/customization.md)
and [Theming](https://moraine.subf.dev/docs/theming.md) for nested Provider, Portal, and class-merging behavior.

## Examples

### Multiple selection

```tsx
import { BaseSelect, Button, Icon } from 'moraine'
import { For } from 'solid-js'

const FRAMEWORKS = [
  { value: 'solid', label: 'Solid' },
  { value: 'react', label: 'React' },
  { value: 'vue', label: 'Vue' },
]

export default function Example() {
  return (
    <BaseSelect items={FRAMEWORKS} multiple defaultValue={['solid']} name="frameworks">
      <BaseSelect.Control>
        <BaseSelect.Trigger as={Button} variant="outline" class="min-w-52">
          {(state) => <span>{state.value.length} framework(s) selected</span>}
        </BaseSelect.Trigger>
      </BaseSelect.Control>
      <BaseSelect.Content>
        <BaseSelect.Listbox>
          <For each={FRAMEWORKS}>
            {(item) => (
              <BaseSelect.Item item={item}>
                {(state) => (
                  <>
                    <Icon
                      name="i-lucide:check"
                      class={state.selected ? 'opacity-100' : 'opacity-0'}
                    />
                    {state.item.label}
                  </>
                )}
              </BaseSelect.Item>
            )}
          </For>
        </BaseSelect.Listbox>
        <BaseSelect.Empty>No frameworks found.</BaseSelect.Empty>
      </BaseSelect.Content>
    </BaseSelect>
  )
}
```

### Form integration

```tsx
import { BaseSelect, Button } from 'moraine'
import { createSignal, For } from 'solid-js'

const FRAMEWORKS = [
  { value: 'solid', label: 'Solid' },
  { value: 'react', label: 'React' },
  { value: 'vue', label: 'Vue' },
]

export default function Example() {
  const [submitted, setSubmitted] = createSignal('Not submitted')
  return (
    <form
      class="flex gap-2 items-start"
      onSubmit={(event) => {
        event.preventDefault()
        const value = new FormData(event.currentTarget).get('framework')
        setSubmitted(typeof value === 'string' ? value : 'No selection')
      }}
    >
      <BaseSelect items={FRAMEWORKS} name="framework" required>
        <BaseSelect.Control>
          <BaseSelect.Trigger as={Button} variant="outline" class="min-w-52">
            {(state) => (
              <span>
                {FRAMEWORKS.find((item) => item.value === state.value[0])?.label ??
                  'Select framework…'}
              </span>
            )}
          </BaseSelect.Trigger>
        </BaseSelect.Control>
        <BaseSelect.Content>
          <BaseSelect.Listbox>
            <For each={FRAMEWORKS}>
              {(item) => <BaseSelect.Item item={item}>{item.label}</BaseSelect.Item>}
            </For>
          </BaseSelect.Listbox>
        </BaseSelect.Content>
      </BaseSelect>
      <Button type="submit">Submit</Button>
      <output class="text-sm text-muted-foreground self-center">{submitted()}</output>
    </form>
  )
}
```

## Attributes

| Attributes | Slot | Description |
| --- | --- | --- |
| `data-closed` | `base-select-control`, `base-select-trigger`, `base-select-content` | Present when disclosure or transition content is closed. |
| `data-disabled` | `base-select-control`, `base-select-trigger`, `base-select-item` | Present when the component, slot, or item is disabled. |
| `data-expanded` | `base-select-control`, `base-select-trigger`, `base-select-content` | Present when the panel, accordion, or menu is expanded. |
| `data-invalid` | `base-select-control`, `base-select-trigger` | Present when the field or form has a validation error. |
| `data-readonly` | `base-select-control` | Present when the field is in read-only mode. |
| `data-required` | `base-select-control` | Present when the field input is required. |
| `data-side` | `base-select-content` | Stores the resolved floating or drawer content side. |
| `data-highlighted` | `base-select-item` | Present when the item is highlighted by pointer or keyboard navigation. |
| `data-selected` | `base-select-item` | Present when the item or tab is selected. |

## Props

### BaseSelect

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| closeOnSelect | boolean \| undefined | — | Close after selection. Defaults to true for single, false for multiple. |
| defaultOpen | boolean \| undefined | false | Initial popup state. |
| defaultValue | readonly TItem['value'][] \| undefined | [] | Initial selection. |
| disabled | boolean \| undefined | false | Whether the input is disabled. |
| getItemByValue | ((value: TItem['value']) => TItem \| undefined) \| undefined | — | Resolves an item from the canonical collection by value.<br><br>Use this when `items` represents only the current navigation view,<br>such as a filtered collection. When omitted, items are resolved from<br>the current `items` collection. |
| id | string \| undefined | — | The ID of the input element. |
| isItemDisabled | ((item: TItem, values: readonly TItem['value'][]) => boolean) \| undefined | — | Additional disabled policy evaluated against the current selection. |
| items | readonly ({<br>  /** Unique selection and form value. */<br>  value: string \| number;<br>  /** Visual label. */<br>  label: JSX.Element;<br>  /** Whether this item cannot be selected. */<br>  disabled?: boolean;<br>})[] \| undefined | — | Current flat navigation collection. |
| itemToLabelString | ((item: TItem) => string) \| undefined | — | Machine-readable text for matching; does not change visual labels. |
| loop | boolean \| undefined | true | Whether arrow-key navigation wraps from the ends. |
| multiple | boolean \| undefined | — | Whether selecting an item toggles multiple values. |
| name | string \| undefined | — | The name of the input element, used for form submission. |
| onOpenChange | ((open: boolean) => void) \| undefined | — | Called when popup state changes. |
| onReset | (() => void) \| undefined | — | Post-reset notification after an unprevented native form reset. |
| onValueChange | ((value: TItem['value'][]) => void) \| undefined | — | Called when selection changes. |
| open | boolean \| undefined | — | Controlled popup state. |
| readOnly | boolean \| undefined | false | Whether the input is read-only. |
| required | boolean \| undefined | false | Whether the input is required. |
| serializeValue | ((value: TItem['value']) => string \| undefined) \| undefined | — | Native form value; return undefined to omit a selected value from submission. |
| size | 'sm' \| 'md' \| 'lg' \| undefined | 'md' | Popup and item size. |
| value | readonly TItem['value'][] \| undefined | — | Controlled selection. Single mode uses at most the first value. |
| children | JSX.Element \| undefined | — | Composed trigger and popup parts. |
| classes | Classes \| undefined | — | Family slot class defaults for this BaseSelect instance. |
| styles | Styles \| undefined | — | Family slot style defaults for this BaseSelect instance. |

### BaseSelect.Control

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| class | SlotClassValue \| undefined | — | Class applied to the component root or trigger element. |
| style | SlotStyleValue \| undefined | — | Style applied to the component root or trigger element. |

### BaseSelect.Trigger

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| disabled | boolean \| undefined | — | Whether this trigger is disabled. |
| as | T \| undefined | 'button' | Element or component to render as. |
| children | JSX.Element \| ((state: TriggerRenderProps<TItem>) => JSX.Element) \| undefined | — | Label or reactive presentation function. |
| class | SlotClassValue \| undefined | — | Class applied to the component root or trigger element. |
| style | SlotStyleValue \| undefined | — | Style applied to the component root or trigger element. |

### BaseSelect.Content

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| gutter | number \| undefined | 0 | Gap between anchor and popup. |
| onExitComplete | (() => void) \| undefined | — | Called once after an open popup completes its exit. |
| overflowPadding | number \| undefined | 4 | Viewport collision padding. |
| class | SlotClassValue \| undefined | — | Class applied to the component root or trigger element. |
| style | SlotStyleValue \| undefined | — | Style applied to the component root or trigger element. |

### BaseSelect.Listbox

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| class | string \| undefined | — | — |
| style | JSX.CSSProperties \| undefined | — | — |

### BaseSelect.Item

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| item* | TItem | — | Raw item belonging to the current navigation collection. |
| children | JSX.Element \| ((state: ItemRenderProps<TItem>) => JSX.Element) \| undefined | — | Visual content or reactive row presentation. |
| class | SlotClassValue \| undefined | — | Class applied to the component root or trigger element. |
| style | SlotStyleValue \| undefined | — | Style applied to the component root or trigger element. |

### BaseSelect.Group

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| class | string \| undefined | — | — |
| style | JSX.CSSProperties \| undefined | — | — |

### BaseSelect.GroupLabel

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| class | string \| undefined | — | — |
| style | JSX.CSSProperties \| undefined | — | — |

### BaseSelect.Separator

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| class | string \| undefined | — | — |
| style | JSX.CSSProperties \| undefined | — | — |

### BaseSelect.Empty

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| class | string \| undefined | — | — |
| style | JSX.CSSProperties \| undefined | — | — |
