Skip to main content

Combobox

Choose one collection item through an editable search input.

Use Combobox when users should type to find one item in a collection. Use Select for a non-editable choice and MultiSelect for multiple collection values.

Basic usage#

import { Field, Combobox } from 'moraine'

export function Example() {
  return (
    <Field label="Fruit">
      <Combobox
        items={[
          { label: 'Apple', value: 'apple' },
          { label: 'Pear', value: 'pear' },
        ]}
        placeholder="Search fruit"
      />
    </Field>
  )
}

Playground#

Props
Slots

Anatomy#

Combobox [component; no DOM]
├── control [slot]
│   ├── leading [slot]
│   ├── input [slot]
│   ├── clear [slot]
│   └── trigger [slot]
└── positioner [internal]
    └── content [slot]
        ├── listbox [slot]
        │   ├── group [slot]
        │   │   └── groupLabel [slot]
        │   └── item [slot]
        │       ├── itemLeading [slot]
        │       ├── itemWrapper [slot]
        │       │   ├── itemLabel [slot]
        │       │   └── itemDescription [slot]
        │       └── itemIndicator [slot]
        └── empty [slot]

Combobox renders the portaled popup collection internally; its content and item names are style slots, rather than attached child parts.

Usage#

String items#

Pass strings when the label and value are identical. Strings and objects can be mixed at the root or inside a group. Whitespace is preserved, and an empty string is a valid value.

import { Combobox } from 'moraine'
import type { ComboboxT } from 'moraine'

const MIXED_ITEMS: ComboboxT.Entry[] = [
  'Apple',
  { value: 'pear', label: 'Pear', description: 'Fresh fruit' },
  {
    type: 'group',
    label: 'More fruit',
    items: ['Banana', { value: 'cherry', label: 'Cherry', disabled: true }],
  },
]

export function StringItems() {
  return (
    <div class="flex flex-col gap-3 max-w-xs w-full">
      <Combobox
        aria-label="String fruit"
        items={['Apple', 'Banana']}
        defaultValue="Apple"
        allowClear
      />
      <Combobox
        aria-label="Mixed fruit"
        items={MIXED_ITEMS}
        placeholder="Choose a fruit..."
        allowClear
      />
    </div>
  )
}

String items are converted to the same item shape used by callbacks: 'Apple' becomes { value: 'Apple', label: 'Apple' }. Object items retain their identity and custom fields. ComboboxT.NormalizedItem<TItem> names the callback item type. Duplicate values use the first occurrence, including collisions between strings and objects.

Value and query#

Pair value with onValueChange for controlled selection, or use defaultValue for an initial uncontrolled choice. null means no selection; an empty string is a valid item value.

While open, the input displays the query; while closed, it displays the selected item label. Arbitrary query text is never committed as a value. Use value / onValueChange for selection and searchValue / onSearch for query text.

import { Button, Combobox } from 'moraine'
import type { ComboboxT } from 'moraine'
import { createSignal } from 'solid-js'

const FRAMEWORKS: ComboboxT.Item[] = [
  { label: 'SolidJS', value: 'solid' },
  { label: 'Vue.js', value: 'vue' },
  { label: 'React', value: 'react' },
  { label: 'Svelte', value: 'svelte' },
  { label: 'Astro', value: 'astro' },
]

export function UsageQuery() {
  const [selected, setSelected] = createSignal<string | null>('solid')
  const [query, setQuery] = createSignal('')

  return (
    <div class="max-w-xs w-full space-y-3">
      <Combobox
        items={FRAMEWORKS}
        value={selected()}
        onValueChange={setSelected}
        searchValue={query()}
        onSearch={setQuery}
        placeholder="Search framework..."
        leadingIcon="i-lucide:search"
        allowClear
      />
      <div class="text-xs text-muted-foreground space-y-1">
        <p>
          Committed Value:{' '}
          <span class="text-foreground font-medium font-mono">{selected() ?? 'null'}</span>
        </p>
        <p>
          Query Text:{' '}
          <span class="text-foreground font-medium font-mono">
            {query() ? `"${query()}"` : '(idle)'}
          </span>
        </p>
      </div>
      <Button
        variant="ghost"
        size="sm"
        disabled={selected() === null && !query()}
        onClick={() => {
          setSelected(null)
          setQuery('')
        }}
      >
        Reset Both
      </Button>
    </div>
  )
}

Pointer opening#

By default, clicking inside the input focuses the field without opening the popup immediately. The trailing chevron button toggles the dropdown, while typing or pressing ArrowDown opens it. Set openOnControlClick={true} if you want any click on the field to open the options list.

import { Combobox } from 'moraine'
import type { ComboboxT } from 'moraine'

const ITEMS: ComboboxT.Item[] = [
  { label: 'Development', value: 'dev' },
  { label: 'Design', value: 'design' },
  { label: 'Marketing', value: 'marketing' },
  { label: 'Product', value: 'product' },
]

export function ExplicitTrigger() {
  return (
    <div class="max-w-md w-full space-y-4">
      <div class="space-y-1.5">
        <label class="text-xs text-muted-foreground font-medium block">
          Default (Only typing or chevron button toggles popup)
        </label>
        <Combobox items={ITEMS} openOnControlClick={false} placeholder="Click chevron to open..." />
      </div>

      <div class="space-y-1.5">
        <label class="text-xs text-muted-foreground font-medium block">
          Open on control click (Click anywhere inside field to open)
        </label>
        <Combobox
          items={ITEMS}
          openOnControlClick={true}
          placeholder="Click anywhere in the field..."
        />
      </div>
    </div>
  )
}

Keyboard interaction#

Key Behavior
ArrowDown / ArrowUp Open the popup or navigate through matched options
Enter Commit the currently highlighted option
Escape Revert query text or close the dropdown
Home / End Move text cursor within input (or navigate options when listbox is focused)

Virtualization#

Add createListVirtualizer when rendering the full collection makes opening or scrolling slow.

Read the Virtualization guide for setup, row measurements, stable keys, and keyboard navigation.

import { Combobox } from 'moraine'
import type { ComboboxT } from 'moraine'
import { createListVirtualizer } from 'moraine/virtualizer'

const ITEMS: ComboboxT.Item<string>[] = Array.from({ length: 10_000 }, (_, index) => ({
  value: `option-${index}`,
  label: `Option ${index + 1}`,
}))

export function Virtualization() {
  const virtualizer = createListVirtualizer<
    ComboboxT.Row<ComboboxT.Item<string>>,
    HTMLDivElement,
    HTMLDivElement
  >({
    estimateSize: (entry) => (entry.type === 'label' ? 30 : 32),
    getItemKey: (entry) => entry.key,
    overscan: 8,
  })

  return (
    <div class="max-w-xs w-full">
      <Combobox
        items={ITEMS}
        placeholder="Search 10,000 options..."
        leadingIcon="i-lucide:search"
        openOnControlClick
        virtualRender={virtualizer.virtualRender}
        scrollToItem={(_, entryIndex) => virtualizer.scrollToIndex(entryIndex)}
        classes={{ listbox: 'h-80 max-h-80' }}
      />
    </div>
  )
}

Examples#

Basic autocomplete#

import { Combobox } from 'moraine'
import type { ComboboxT } from 'moraine'

const FRAMEWORKS: ComboboxT.Item[] = [
  { label: 'SolidJS', value: 'solid' },
  { label: 'React', value: 'react' },
  { label: 'Vue.js', value: 'vue' },
  { label: 'Svelte', value: 'svelte' },
  { label: 'Astro', value: 'astro' },
  { label: 'Next.js', value: 'next' },
  { label: 'Nuxt', value: 'nuxt' },
]

export function Basic() {
  return (
    <div class="max-w-xs w-full">
      <Combobox
        items={FRAMEWORKS}
        placeholder="Search framework..."
        leadingIcon="i-lucide:search"
        allowClear
      />
    </div>
  )
}

Custom and rich items#

Display status indicators, icons, and descriptions using icon and description.

import { Combobox } from 'moraine'
import type { ComboboxT } from 'moraine'

const STATUSES: ComboboxT.Item[] = [
  {
    label: 'Backlog',
    value: 'backlog',
    icon: 'i-lucide:circle-dashed',
    description: 'Ideas and unprioritized tasks',
  },
  {
    label: 'Todo',
    value: 'todo',
    icon: 'i-lucide:circle',
    description: 'Ready to be picked up',
  },
  {
    label: 'In Progress',
    value: 'in-progress',
    icon: 'i-lucide:circle-dot',
    description: 'Work actively underway',
  },
  {
    label: 'In Review',
    value: 'review',
    icon: 'i-lucide:clock',
    description: 'PR open and waiting for review',
  },
  {
    label: 'Done',
    value: 'done',
    icon: 'i-lucide:check-circle-2',
    description: 'Completed and merged',
  },
  {
    label: 'Canceled',
    value: 'canceled',
    icon: 'i-lucide:x-circle',
    description: 'Discarded or not planned',
  },
]

export function CustomItems() {
  return (
    <div class="max-w-sm w-full space-y-2">
      <label class="text-xs text-muted-foreground font-medium block">Issue Status</label>
      <Combobox
        items={STATUSES}
        defaultValue="in-progress"
        placeholder="Filter status..."
        openOnControlClick
      />
    </div>
  )
}

Search teammates by name or email.

import { Combobox } from 'moraine'
import type { ComboboxT } from 'moraine'

const USERS: ComboboxT.Item[] = [
  {
    label: 'Sarah Connor',
    value: 'sarah',
    icon: 'i-lucide:user',
    description: '[email protected] · Engineering Lead',
  },
  {
    label: 'Alex Rivera',
    value: 'alex',
    icon: 'i-lucide:user',
    description: '[email protected] · Product Designer',
  },
  {
    label: 'Elena Rostova',
    value: 'elena',
    icon: 'i-lucide:user',
    description: '[email protected] · Frontend Engineer',
  },
  {
    label: 'David Kim',
    value: 'david',
    icon: 'i-lucide:user',
    description: '[email protected] · DevOps Specialist',
  },
]

export function UserPicker() {
  return (
    <div class="max-w-sm w-full space-y-2">
      <label class="text-xs text-muted-foreground font-medium block">Assignee</label>
      <Combobox
        items={USERS}
        placeholder="Assign to teammate..."
        leadingIcon="i-lucide:user-plus"
        openOnControlClick
        allowClear
      />
    </div>
  )
}

Filter strategies#

Configure how query text matches items using filterItem="contains" (default) or filterItem="startsWith".

import { Button, Combobox } from 'moraine'
import type { ComboboxT } from 'moraine'
import { createSignal } from 'solid-js'

const CITIES: ComboboxT.Item[] = [
  { label: 'San Francisco', value: 'sf' },
  { label: 'San Jose', value: 'sj' },
  { label: 'Santa Clara', value: 'sc' },
  { label: 'Los Angeles', value: 'la' },
  { label: 'New York', value: 'ny' },
  { label: 'Boston', value: 'bos' },
]

export function FilterStrategies() {
  const [strategy, setStrategy] = createSignal<'contains' | 'startsWith'>('contains')

  return (
    <div class="max-w-xs w-full space-y-3">
      <div class="flex gap-2 items-center">
        <span class="text-xs text-muted-foreground font-medium">Filter mode:</span>
        <Button
          size="sm"
          variant={strategy() === 'contains' ? 'default' : 'secondary'}
          onClick={() => setStrategy('contains')}
        >
          contains
        </Button>
        <Button
          size="sm"
          variant={strategy() === 'startsWith' ? 'default' : 'secondary'}
          onClick={() => setStrategy('startsWith')}
        >
          startsWith
        </Button>
      </div>

      <Combobox
        items={CITIES}
        filterItem={strategy()}
        placeholder={`Search cities (${strategy()})...`}
        leadingIcon="i-lucide:search"
        openOnControlClick
      />
    </div>
  )
}

Simulate async fetching by combining searchValue, onSearch, and loading.

import { Combobox } from 'moraine'
import type { ComboboxT } from 'moraine'
import { createSignal } from 'solid-js'

interface RepoItem extends ComboboxT.Item {
  label: string
}

const ALL_REPOS: RepoItem[] = [
  { label: 'subframe7536/moraine', value: 'moraine', icon: 'i-lucide:box' },
  { label: 'solidjs/solid', value: 'solid', icon: 'i-lucide:box' },
  { label: 'tailwindlabs/tailwindcss', value: 'tailwind', icon: 'i-lucide:box' },
  { label: 'unocss/unocss', value: 'unocss', icon: 'i-lucide:box' },
  { label: 'vitejs/vite', value: 'vite', icon: 'i-lucide:box' },
]

export function AsyncSearch() {
  const [query, setQuery] = createSignal('')
  const [loading, setLoading] = createSignal(false)
  const [items, setItems] = createSignal<ComboboxT.Item[]>(ALL_REPOS)

  let timer: ReturnType<typeof setTimeout> | undefined

  function handleSearch(search: string) {
    setQuery(search)
    setLoading(true)
    clearTimeout(timer)

    timer = setTimeout(() => {
      const filtered = ALL_REPOS.filter((item) =>
        item.label.toLowerCase().includes(search.toLowerCase()),
      )
      setItems(filtered)
      setLoading(false)
    }, 400)
  }

  return (
    <div class="max-w-xs w-full space-y-2">
      <Combobox
        items={items()}
        searchValue={query()}
        onSearch={handleSearch}
        loading={loading()}
        filterItem={false}
        placeholder="Type to search repositories..."
        leadingIcon="i-lucide:search"
        openOnControlClick
        allowClear
      />
      <p class="text-xs text-muted-foreground">
        {loading() ? 'Searching remote index...' : `${items().length} repositories found`}
      </p>
    </div>
  )
}

Groups and custom empty state#

Organize options into categories and customize the empty state when no matches exist.

import { Combobox } from 'moraine'
import type { ComboboxT } from 'moraine'

const TECH_GROUPS: ComboboxT.Entry[] = [
  {
    type: 'group',
    label: 'Frontend Frameworks',
    items: [
      { label: 'SolidJS', value: 'solid', icon: 'i-lucide:atom' },
      { label: 'Vue.js', value: 'vue', icon: 'i-lucide:sparkles' },
      { label: 'React', value: 'react', icon: 'i-lucide:box' },
      { label: 'Svelte', value: 'svelte', icon: 'i-lucide:flame' },
    ],
  },
  {
    type: 'group',
    label: 'Backend & Systems',
    items: [
      { label: 'Rust', value: 'rust', icon: 'i-lucide:cog' },
      { label: 'Go', value: 'go', icon: 'i-lucide:zap' },
      { label: 'Node.js', value: 'node', icon: 'i-lucide:server' },
    ],
  },
  {
    type: 'group',
    label: 'Databases',
    items: [
      { label: 'PostgreSQL', value: 'postgres', icon: 'i-lucide:database' },
      { label: 'Redis', value: 'redis', icon: 'i-lucide:database' },
      { label: 'SQLite', value: 'sqlite', icon: 'i-lucide:database' },
    ],
  },
]

export function Groups() {
  return (
    <div class="max-w-xs w-full space-y-2">
      <Combobox
        items={TECH_GROUPS}
        placeholder="Filter technologies..."
        leadingIcon="i-lucide:search"
        openOnControlClick
        emptyRender={(ctx) => (
          <div class="text-xs text-muted-foreground p-3 text-center">
            No technology found matching &ldquo;
            <span class="text-foreground font-medium">{ctx.inputValue}</span>&rdquo;.
          </div>
        )}
      />
    </div>
  )
}

Form integration#

Use form.Field from createForm when the selected value needs schema validation and error display.

import { Button, createForm, Combobox } from 'moraine'
import { createSignal } from 'solid-js'
import * as v from 'valibot'

const FRAMEWORKS = [
  { label: 'SolidJS', value: 'solid' },
  { label: 'Vue.js', value: 'vue' },
  { label: 'React', value: 'react' },
  { label: 'Svelte', value: 'svelte' },
  { label: 'Astro', value: 'astro' },
]

export function FormIntegration() {
  const [submitted, setSubmitted] = createSignal<string | null>(null)
  const form = createForm({
    schema: v.object({
      framework: v.pipe(
        v.nullable(v.string()),
        v.check(
          (value): value is string => value !== null && value.length > 0,
          'Please select a primary framework.',
        ),
      ),
    }),
    initialInput: { framework: null },
    validate: 'input',
  })

  return (
    <form.Form onSubmit={(output) => setSubmitted(output.framework)}>
      <div class="max-w-xl space-y-4">
        <form.Field
          name="framework"
          label="Primary Framework"
          description="Used to configure your starter template and linting rules."
          required
        >
          <Combobox
            items={FRAMEWORKS}
            placeholder="Search and select framework..."
            leadingIcon="i-lucide:search"
            openOnControlClick
            allowClear
          />
        </form.Field>
        <div class="flex gap-3 items-center">
          <Button type="submit" variant="secondary" size="sm">
            Validate
          </Button>
          <p class="text-xs text-muted-foreground">
            Selected:{' '}
            <span class="text-foreground font-medium font-mono">{submitted() ?? 'none'}</span>
          </p>
        </div>
      </div>
    </form.Form>
  )
}

Attributes#

Attributes
data-closedSlot: combobox-control, combobox-contentDescription: Present when disclosure or transition content is closed.
data-disabledSlot: combobox-control, combobox-itemDescription: Present when the component, slot, or item is disabled.
data-editableSlot: combobox-controlDescription: Present when the value can be edited as text.
data-expandedSlot: combobox-control, combobox-contentDescription: Present when the panel, accordion, or menu is expanded.
data-invalidSlot: combobox-controlDescription: Present when the field or form has a validation error.
data-readonlySlot: combobox-controlDescription: Present when the field is in read-only mode.
data-requiredSlot: combobox-controlDescription: Present when the field input is required.
data-sideSlot: combobox-contentDescription: Stores the resolved floating or drawer content side.
data-highlightedSlot: combobox-item, combobox-item-descriptionDescription: Present when the item is highlighted by pointer or keyboard navigation.
data-selectedSlot: combobox-itemDescription: Present when the item or tab is selected.
data-loadingSlot: combobox-triggerDescription: Present when the component or async operation is loading.

Props#

Renders a <div> element by default.

Prop