Skip to main content

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, Combobox, or MultiSelect.

Basic usage#

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>
  )
}

Playground#

Props
Slots

Anatomy#

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#

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#

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:

<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 and Theming for nested Provider, Portal, and class-merging behavior.

Examples#

Multiple selection#

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#

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
data-closedSlot: base-select-control, base-select-trigger, base-select-contentDescription: Present when disclosure or transition content is closed.
data-disabledSlot: base-select-control, base-select-trigger, base-select-itemDescription: Present when the component, slot, or item is disabled.
data-expandedSlot: base-select-control, base-select-trigger, base-select-contentDescription: Present when the panel, accordion, or menu is expanded.
data-invalidSlot: base-select-control, base-select-triggerDescription: Present when the field or form has a validation error.
data-readonlySlot: base-select-controlDescription: Present when the field is in read-only mode.
data-requiredSlot: base-select-controlDescription: Present when the field input is required.
data-sideSlot: base-select-contentDescription: Stores the resolved floating or drawer content side.
data-highlightedSlot: base-select-itemDescription: Present when the item is highlighted by pointer or keyboard navigation.
data-selectedSlot: base-select-itemDescription: Present when the item or tab is selected.

Props#

BaseSelect#

Prop

Control#

Renders a <div> element by default.

Prop

Trigger#

Renders a <button> element by default.

Prop

Content#

Renders a <div> element by default.

Prop

Listbox#

Prop

Item#

Renders a <div> element by default.

Prop

Group#

Prop

GroupLabel#

Prop

Separator#

Prop

Empty#

Prop