---
title: Combobox
description: Choose one collection item through an editable search input.
package: moraine
version: 0.6.0
repository: https://github.com/subframe7536/moraine
---

# 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](https://moraine.subf.dev/components/select.md) for a non-editable choice and [MultiSelect](https://moraine.subf.dev/components/multi-select.md) for multiple collection values.

## Basic usage

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

## Anatomy

```text
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.

```tsx
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.

```tsx
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.

```tsx
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](https://moraine.subf.dev/docs/virtualization.md) for setup, row measurements, stable keys, and keyboard navigation.

```tsx
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

```tsx
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`.

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

### User search

Search teammates by name or email.

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

const USERS: ComboboxT.Item[] = [
  {
    label: 'Sarah Connor',
    value: 'sarah',
    icon: 'i-lucide:user',
    description: 'sarah@example.com · Engineering Lead',
  },
  {
    label: 'Alex Rivera',
    value: 'alex',
    icon: 'i-lucide:user',
    description: 'alex@example.com · Product Designer',
  },
  {
    label: 'Elena Rostova',
    value: 'elena',
    icon: 'i-lucide:user',
    description: 'elena@example.com · Frontend Engineer',
  },
  {
    label: 'David Kim',
    value: 'david',
    icon: 'i-lucide:user',
    description: 'david@example.com · 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"`.

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

### Debounced remote search

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

```tsx
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.

```tsx
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.

```tsx
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 | Slot | Description |
| --- | --- | --- |
| `data-closed` | `combobox-control`, `combobox-content` | Present when disclosure or transition content is closed. |
| `data-disabled` | `combobox-control`, `combobox-item` | Present when the component, slot, or item is disabled. |
| `data-editable` | `combobox-control` | Present when the value can be edited as text. |
| `data-expanded` | `combobox-control`, `combobox-content` | Present when the panel, accordion, or menu is expanded. |
| `data-invalid` | `combobox-control` | Present when the field or form has a validation error. |
| `data-readonly` | `combobox-control` | Present when the field is in read-only mode. |
| `data-required` | `combobox-control` | Present when the field input is required. |
| `data-side` | `combobox-content` | Stores the resolved floating or drawer content side. |
| `data-highlighted` | `combobox-item`, `combobox-item-description` | Present when the item is highlighted by pointer or keyboard navigation. |
| `data-selected` | `combobox-item` | Present when the item or tab is selected. |
| `data-loading` | `combobox-trigger` | Present when the component or async operation is loading. |

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| allowClear | boolean \| undefined | — | Show a clear action when a value or query exists. |
| autocomplete | JSX.InputHTMLAttributes<HTMLInputElement>['autocomplete'] \| undefined | 'off' | The autocomplete attribute for the search input. |
| closeIcon | IconT.Name \| undefined | 'icon-close' | Clear icon. |
| closeOnSelect | boolean \| undefined | — | Close after selection. Defaults to true for single, false for multiple. |
| defaultOpen | boolean \| undefined | false | Initial popup state. |
| defaultSearchValue | string \| undefined | "" | Initial search text. |
| defaultValue | NormalizedItem<TItem>['value'] \| null \| undefined | — | The default value of the input (uncontrolled). |
| disabled | boolean \| undefined | false | Whether the input is disabled. |
| emptyRender | JSX.Element \| ((props: EmptyRenderProps<TItem>) => JSX.Element) \| undefined | — | Content or render function for the filtered empty state. |
| filterItem | boolean \| 'startsWith' \| 'endsWith' \| 'contains' \| ((query: string, item: NormalizedItem<TItem>) => boolean) \| undefined | true | Filtering strategy or predicate receiving the raw item. |
| gutter | number \| undefined | 0 | Anchor gap in pixels. |
| id | string \| undefined | — | The ID of the input element. |
| inputRef | Ref<HTMLInputElement> \| undefined | — | Optional inner input element ref. |
| isItemDisabled | ((item: NormalizedItem<TItem>, values: readonly NormalizedItem<TItem>['value'][]) => boolean) \| undefined | — | Additional disabled policy evaluated against the current selection. |
| itemProps | ((state: BaseSelectT.ItemRenderProps<NormalizedItem<TItem>>) => ElementProps<HTMLDivElement> \| undefined) \| undefined | — | Additional row attributes. |
| itemRender | ((props: BaseSelectT.ItemRenderProps<NormalizedItem<TItem>>) => JSX.Element) \| undefined | — | Renderer for each collection item. |
| items | (string \| {<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>  /** Leading item icon. */<br>  icon?: IconT.Name;<br>  /** Secondary item description. */<br>  description?: JSX.Element;<br>} \| {<br>  /** Structural group discriminator. */<br>  type: 'group';<br>  label: JSX.Element;<br>  items: (string \| {<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>    /** Leading item icon. */<br>    icon?: IconT.Name;<br>    /** Secondary item description. */<br>    description?: JSX.Element;<br>  })[];<br>} \| {<br>  /** Structural separator discriminator. */<br>  type: 'separator';<br>})[] \| undefined | — | String shorthand or object items, optionally grouped. Item values must be unique. |
| itemToLabelString | ((item: NormalizedItem<TItem>) => string) \| undefined | — | Machine-readable text for matching; does not change visual labels. |
| leadingIcon | IconT.Name \| undefined | — | Leading icon. |
| listboxProps | ElementProps<HTMLDivElement> \| undefined | — | Additional listbox attributes. |
| loading | boolean \| undefined | — | Whether the control is loading. |
| loadingIcon | IconT.Name \| undefined | 'icon-loading' | Loading icon. |
| name | string \| undefined | — | The name of the input element, used for form submission. |
| onClear | (() => void) \| undefined | — | Called once when clear is triggered. |
| onOpenChange | ((open: boolean) => void) \| undefined | — | Called when popup state changes. |
| onReset | (() => void) \| undefined | — | Post-reset notification after an unprevented native form reset. |
| onScrollBottom | (() => void) \| undefined | — | Called once when scrolling reaches the bottom. |
| onSearch | ((value: string) => void) \| undefined | — | Called when search text changes. |
| onValueChange | ((value: NoInfer<NormalizedItem<TItem>['value'] \| null>) => void) \| undefined | — | Called when the committed selection changes. |
| open | boolean \| undefined | — | Controlled popup state. |
| openOnControlClick | boolean \| undefined | false | Whether ordinary control/input pointer clicks open the popup. |
| overflowPadding | number \| undefined | 4 | Collision padding in pixels. |
| placeholder | string \| undefined | — | Placeholder shown when there is no selected value or query. |
| readOnly | boolean \| undefined | false | Whether the input is read-only. |
| required | boolean \| undefined | false | Whether the input is required. |
| scrollBottomThreshold | number \| undefined | 20 | Bottom threshold in pixels. |
| scrollToItem | ((item: NormalizedItem<TItem>, entryIndex: number) => void) \| undefined | — | Scroll a highlighted item to its logical entry index. |
| searchMaxLength | number \| undefined | — | Maximum committed search length. |
| searchValue | string \| undefined | — | Controlled search text. |
| size | 'sm' \| 'md' \| 'lg' \| undefined | 'md' | Visual size of the component. |
| trailingIcon | IconT.Name \| undefined | 'icon-chevron-down' | Popup toggle icon. |
| value | NormalizedItem<TItem>['value'] \| null \| undefined | — | The current value of the input (controlled). |
| variant | 'outline' \| 'subtle' \| 'ghost' \| 'none' \| undefined | 'outline' | Visual treatment of the component. |
| virtualRender | Component<SelectVirtualRenderProps<NormalizedItem<TItem>>> \| undefined | — | Virtual rendering adapter. |
| class | SlotClassValue \| undefined | — | Class applied to the component root or trigger element. |
| classes | Classes \| undefined | — | Family slot class defaults for this instance. |
| style | SlotStyleValue \| undefined | — | Style applied to the component root or trigger element. |
| styles | Styles \| undefined | — | Family slot style defaults for this instance. |
