Skip to content

Combobox

A text input paired with a listbox, narrowed as the user types.

View source View as Markdown

Combobox pairs a text input with a listbox, so the user narrows the list by typing and then either picks a match or keeps what they typed. Useful when typing beats scrolling, or when the options come from a server.

App.vue
<template>
  <Combobox.Root
    v-model="value"
    v-model:input-value="query"
    :options="options"
  >
    <Combobox.InputGroup
      class="group border-surface rounded-component-lg has-[:focus-visible]:focus-ring data-[popup-open=true]:focus-ring relative isolate flex h-12 w-64 cursor-text items-center justify-between gap-1.5 border-2 px-3.5 outline-4 outline-transparent transition-all duration-100 ease-linear"
    >
      <label
        class="flex max-h-full w-full cursor-text flex-col-reverse justify-center gap-0 px-1 transition-all duration-100 ease-linear group-has-[:focus]:gap-1 group-has-[input:not(:placeholder-shown)]:gap-1 before:absolute before:-inset-0.5 before:-z-10 before:content-['']"
      >
        <span
          class="relative flex h-0 w-full items-end transition-all duration-100 ease-linear group-has-[:focus]:h-[0.9375rem] group-has-[input:not(:placeholder-shown)]:h-[0.9375rem]"
        >
          <Combobox.Input
            class="text-primary-solid type-component-lg -my-1.25 box-content block h-[1lh] w-full min-w-0 bg-transparent py-1.25 [line-height:normal]! transition-colors duration-100 ease-linear outline-none placeholder:text-transparent"
            placeholder=" "
          />
        </span>
        <span
          class="text-primary-muted type-component-lg group-has-[:focus]:type-component-2xs group-has-[input:not(:placeholder-shown)]:type-component-2xs flex w-full items-center [line-height:normal]! transition-all duration-100 ease-linear"
        >
          City
        </span>
      </label>

      <Combobox.Clear
        class="text-primary-muted hover:text-primary-solid flex shrink-0 items-center transition-all duration-100 ease-linear"
      >
        <svg class="size-4.5" viewBox="0 0 16 16" aria-hidden="true">
          <path
            d="m4.5 4.5 7 7m0-7-7 7"
            fill="none"
            stroke="currentColor"
            stroke-width="1.5"
            stroke-linecap="round"
          />
        </svg>
      </Combobox.Clear>

      <Combobox.Trigger
        class="text-primary-muted group-has-[input:not(:placeholder-shown)]:text-primary-solid flex shrink-0 items-center transition-all duration-100 ease-linear"
      >
        <svg class="size-4.5" viewBox="0 0 16 16" aria-hidden="true">
          <path
            d="m4 6.5 4 4 4-4"
            fill="none"
            stroke="currentColor"
            stroke-width="1.5"
            stroke-linecap="round"
            stroke-linejoin="round"
          />
        </svg>
      </Combobox.Trigger>
    </Combobox.InputGroup>

    <Combobox.Content
      class="rounded-component-2xl bg-primary-inverted shadow-component-high w-(--rack-floating-anchor-width) overflow-y-auto p-1.5 backdrop-blur-[8rem]"
    >
      <Combobox.Item
        v-for="city in matches"
        :key="city"
        class="group/item rounded-component-md type-component-lg text-primary-on-subtle hover:bg-primary-subtle active:bg-primary-subtle-hover data-[highlighted=true]:bg-primary-subtle data-[disabled=true]:text-disabled-on-subtle flex h-12 w-full items-center justify-start gap-4 px-1 transition-colors duration-100 ease-linear select-none [--mirror-combobox-item-cursor:pointer]"
        :value="city"
      >
        <Combobox.ItemText
          class="inline-flex h-full min-w-0 flex-1 items-center overflow-hidden pr-1.5 pl-5.5"
        >
          <span class="truncate">{{ city }}</span>
        </Combobox.ItemText>

        <Combobox.ItemIndicator
          class="text-primary-muted group-hover/item:text-primary-solid group-data-[highlighted=true]/item:text-primary-solid flex shrink-0 items-center pr-4 leading-none transition-colors duration-100 ease-linear"
        >
          <svg class="size-4.5" viewBox="0 0 16 16" aria-hidden="true">
            <path
              d="m3.5 8.5 3 3 6-6.5"
              fill="none"
              stroke="currentColor"
              stroke-width="1.5"
              stroke-linecap="round"
              stroke-linejoin="round"
            />
          </svg>
        </Combobox.ItemIndicator>
      </Combobox.Item>

      <Combobox.Empty
        class="type-component-lg text-primary-muted flex h-12 items-center pl-6.5"
      >
        No matches.
      </Combobox.Empty>
    </Combobox.Content>
  </Combobox.Root>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue'
import { Combobox } from '@maas/mirror/vue'

const options = {
  modal: false,
  portal: { disabled: true },
}

const cities = ['Glasgow', 'Bath', 'Nuremberg', 'Munich', 'Rome']

const value = ref<string | null>(null)
const query = ref('')

const matches = computed(() =>
  cities.filter((city) =>
    city.toLowerCase().includes(query.value.toLowerCase())
  )
)
</script>

Usage guidelines

  • We do not filter for you. Bind the text with v-model:input-value and render your own filtered array, so a search against a server works exactly like a local one.
  • Make sure the combobox has an accessible name, either through a Label inside a Field or an aria-label on the input.
  • Combobox.Content teleports to the end of <body> unless you turn that off with options.portal.disabled. Leave it on where the field sits inside anything with overflow: hidden.
  • The content resolves everything but side and align once, during setup, so change the rest before the popup first renders or key the root to remount it.

Anatomy

Assemble the parts. Combobox.Content is one part over four boxes: the teleport, the positioned element, the popup and the listbox. Everything you put inside it renders in the listbox.

NameRequiredDescription
Combobox.Root true Renders its children plus one hidden input per selected value.
Combobox.InputGroupOptional. Renders a <div> around the input and its buttons, carrying the popup state.
Combobox.ChipsOptional. Renders a <div> holding one chip per selection under multiple.
Combobox.ChipRenders a <div> standing for one selected value.
Combobox.ChipRemoveRenders a <button> that drops its chip’s value.
Combobox.Input true Renders an <input role="combobox">. Holds focus for the whole interaction.
Combobox.ValueRenders a <span> with the selected labels.
Combobox.ClearRenders a <button>. Clears the value and the input text.
Combobox.TriggerRenders a <button>. Toggles the popup without ever taking focus.
Combobox.IconRenders a <span aria-hidden="true"> for the disclosure marker.
Combobox.StatusOptional. Renders a <div role="status"> for loading and result counts.
Combobox.Content true Renders the teleport, the floating box, the popup and the <div role="listbox">.
Combobox.GroupRenders a <div role="group"> around a run of items.
Combobox.GroupLabelRenders a <div> and names its group.
Combobox.Item true Renders a <div role="option">.
Combobox.ItemTextRenders a <span> and registers its text as the item’s label.
Combobox.ItemIndicatorRenders a <span> while the item is selected.
Combobox.SeparatorRenders a <div role="separator"> between groups.
Combobox.EmptyRenders a polite live region, filled while no item is registered.
<script setup lang="ts">
import { Combobox, Field, Label } from '@maas/mirror/vue'
</script>

<template>
  <Field.Root :options="{ name: 'city' }">
    <Label>City</Label>
    <Combobox.Root v-model="city" v-model:input-value="query">
      <Combobox.Input placeholder=" " />
      <Combobox.Clear />
      <Combobox.Trigger />
      <Combobox.Content>
        <Combobox.Item v-for="match in matches" :key="match" :value="match">
          <Combobox.ItemText>{{ match }}</Combobox.ItemText>
          <Combobox.ItemIndicator />
        </Combobox.Item>
        <Combobox.Empty>No matches.</Combobox.Empty>
      </Combobox.Content>
    </Combobox.Root>
  </Field.Root>
</template>

Examples

Filtering

Bind the text with v-model:input-value and render whatever your own filtering produces.

<script setup lang="ts">
const city = ref<string | null>(null)
const query = ref('')

const matches = computed(() =>
  cities.filter((entry) =>
    entry.toLowerCase().startsWith(query.value.toLowerCase())
  )
)
</script>

<template>
  <Combobox.Root v-model="city" v-model:input-value="query">
    <Combobox.Input />
    <Combobox.Content>
      <Combobox.Item v-for="match in matches" :key="match" :value="match">
        <Combobox.ItemText>{{ match }}</Combobox.ItemText>
      </Combobox.Item>
      <Combobox.Empty>No matches.</Combobox.Empty>
    </Combobox.Content>
  </Combobox.Root>
</template>

Items register as they mount, so the highlight follows your filtered array: once the highlighted item leaves the set, the highlight moves to the selected item, or to the first enabled one while autoHighlight is on. Combobox.Empty reads the registered items rather than your array, which keeps it right once the items are grouped or virtualised.

When the popup opens

A handful of options decide that, and they are the only ones Combobox adds beyond allowCustomValue.

<template>
  <Combobox.Root
    :options="{
      openOnInput: true,
      openOnInputClick: true,
      openOnFocus: false,
      autoHighlight: true,
      clearOnEscape: false,
    }"
  />
</template>

openOnInput opens on the first keystroke, openOnInputClick opens on a pointer click so an already filled input reopens without typing, and openOnFocus opens as soon as the input takes focus, before the reader has done anything. autoHighlight seeds the first enabled item so Enter always has something to commit, and clearOnEscape lets an Escape on an already-closed combobox empty both the value and the text.

Custom values

By default the value has to come from the list, so text that matches nothing is discarded on Enter. Set allowCustomValue and the typed text becomes the value instead.

<template>
  <Combobox.Root v-model="tag" :options="{ allowCustomValue: true }" />
</template>

The input then takes data-custom-value until an item is picked or the combobox is cleared, so style the difference rather than tracking it yourself.

.input[data-custom-value='true'] {
  font-style: italic;
}

Multiple selection

Set options.multiple and the value becomes an array: picking an item toggles it, the popup stays open, and the input text is left alone so the query survives the selection.

<script setup lang="ts">
const tags = ref<Array<string>>([])
const query = ref('')
</script>

<template>
  <Combobox.Root
    v-model="tags"
    v-model:input-value="query"
    :options="{ multiple: true }"
  />
</template>

The value and options.multiple have to agree, or the root throws invalid_multiple_value.

Chips

We find a multiple selection easier to read as chips inside the control than as a list beside it. Combobox.Chips hands out one entry per selection, each already carrying its key and its label, and Combobox.ChipRemove drops the chip it sits in. Backspace on an empty input removes the last one, so the keyboard never needs the buttons.

<script setup lang="ts">
const tags = ref<Array<string>>([])
const query = ref('')
</script>

<template>
  <Combobox.Root
    v-model="tags"
    v-model:input-value="query"
    :options="{ multiple: true }"
  >
    <Combobox.InputGroup>
      <Combobox.Chips v-slot="{ entries }">
        <Combobox.Chip
          v-for="entry in entries"
          :key="entry.key"
          :value="entry.value"
        >
          {{ entry.label }}
          <Combobox.ChipRemove :aria-label="`Remove ${entry.label}`" />
        </Combobox.Chip>
        <Combobox.Input />
      </Combobox.Chips>
      <Combobox.Clear />
      <Combobox.Trigger />
    </Combobox.InputGroup>
  </Combobox.Root>
</template>

The chips are not in the tab order: the input keeps focus for the whole interaction, the way it does everywhere else in this component.

App.vue
<template>
  <Combobox.Root
    v-model="value"
    v-model:input-value="query"
    :options="options"
  >
    <Combobox.InputGroup
      class="group border-surface rounded-component-lg has-[:focus-visible]:focus-ring data-[popup-open=true]:focus-ring relative isolate flex w-64 cursor-text items-center justify-between gap-1.5 border-2 px-3.5 py-2.5 outline-4 outline-transparent transition-all duration-100 ease-linear"
    >
      <Combobox.Chips
        v-slot="{ entries }"
        class="flex min-w-0 flex-1 flex-wrap items-center gap-1.5"
      >
        <Combobox.Chip
          v-for="entry in entries"
          :key="entry.key"
          class="rounded-component-md bg-primary-subtle text-primary-on-subtle type-component-2xs inline-flex h-7 max-w-full shrink-0 items-center gap-1 pr-1 pl-2 transition-all duration-100 ease-linear"
          :value="entry.value"
        >
          <span class="truncate">{{ entry.label }}</span>

          <Combobox.ChipRemove
            :aria-label="`Remove ${entry.label}`"
            class="text-primary-muted hover:text-primary-solid flex size-5 shrink-0 items-center justify-center transition-colors duration-100 ease-linear"
          >
            <svg class="size-3.5" viewBox="0 0 16 16" aria-hidden="true">
              <path
                d="m4.5 4.5 7 7m0-7-7 7"
                fill="none"
                stroke="currentColor"
                stroke-width="1.5"
                stroke-linecap="round"
              />
            </svg>
          </Combobox.ChipRemove>
        </Combobox.Chip>

        <Combobox.Input
          class="text-primary-solid type-component-lg placeholder:text-primary-muted h-7 min-w-16 flex-1 bg-transparent [line-height:normal]! transition-colors duration-100 ease-linear outline-none"
          placeholder="Add a city"
        />
      </Combobox.Chips>

      <Combobox.Trigger
        class="text-primary-muted group-data-[popup-open=true]:text-primary-solid flex shrink-0 items-center self-start pt-1 transition-all duration-100 ease-linear"
      >
        <svg class="size-4.5" viewBox="0 0 16 16" aria-hidden="true">
          <path
            d="m4 6.5 4 4 4-4"
            fill="none"
            stroke="currentColor"
            stroke-width="1.5"
            stroke-linecap="round"
            stroke-linejoin="round"
          />
        </svg>
      </Combobox.Trigger>
    </Combobox.InputGroup>

    <Combobox.Content
      class="rounded-component-2xl bg-primary-inverted shadow-component-high w-(--rack-floating-anchor-width) overflow-y-auto p-1.5 backdrop-blur-[8rem]"
    >
      <Combobox.Item
        v-for="city in matches"
        :key="city"
        class="group/item rounded-component-md type-component-lg text-primary-on-subtle hover:bg-primary-subtle active:bg-primary-subtle-hover data-[highlighted=true]:bg-primary-subtle flex h-12 w-full items-center justify-start gap-4 px-1 transition-colors duration-100 ease-linear select-none [--mirror-combobox-item-cursor:pointer]"
        :value="city"
      >
        <Combobox.ItemText
          class="inline-flex h-full min-w-0 flex-1 items-center overflow-hidden pr-1.5 pl-5.5"
        >
          <span class="truncate">{{ city }}</span>
        </Combobox.ItemText>

        <Combobox.ItemIndicator
          class="text-primary-muted group-hover/item:text-primary-solid group-data-[highlighted=true]/item:text-primary-solid flex shrink-0 items-center pr-4 leading-none transition-colors duration-100 ease-linear"
        >
          <svg class="size-4.5" viewBox="0 0 16 16" aria-hidden="true">
            <path
              d="m3.5 8.5 3 3 6-6.5"
              fill="none"
              stroke="currentColor"
              stroke-width="1.5"
              stroke-linecap="round"
              stroke-linejoin="round"
            />
          </svg>
        </Combobox.ItemIndicator>
      </Combobox.Item>

      <Combobox.Empty
        class="type-component-lg text-primary-muted flex h-12 items-center pl-6.5"
      >
        No matches.
      </Combobox.Empty>
    </Combobox.Content>
  </Combobox.Root>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue'
import { Combobox } from '@maas/mirror/vue'

const options = {
  modal: false,
  multiple: true,
  portal: { disabled: true },
}

const cities = ['Glasgow', 'Bath', 'Nuremberg', 'Munich', 'Rome']

const value = ref<Array<string>>([])
const query = ref('')

const matches = computed(() =>
  cities.filter((city) =>
    city.toLowerCase().includes(query.value.toLowerCase())
  )
)
</script>

Object values

An item value does not have to be a string. Hand Combobox.Item the object you already hold and tell the combobox how to read it, exactly as Select does.

<script setup lang="ts">
interface City {
  code: string
  name: string
}

const city = ref<City | null>(null)
</script>

<template>
  <Combobox.Root
    v-model="city"
    v-model:input-value="query"
    name="city"
    :options="{
      isItemEqualToValue: (item, value) => item.code === value.code,
      itemToStringLabel: (item) => item.name,
      itemToStringValue: (item) => item.code,
    }"
  >
    <Combobox.Input />
  </Combobox.Root>
</template>

itemToStringLabel is also the text a selection commits to the input, and itemToStringValue is what the hidden inputs submit. An object shaped { value, label } needs neither.

Announcing an empty or loading list

Combobox.Empty is a polite live region that stays mounted, so a filter that finds nothing is announced rather than silently rendered. Combobox.Status is the same idea for everything else the reader should hear, which for a server list is usually the loading state.

<template>
  <Combobox.Root v-model="city" v-model:input-value="query">
    <Combobox.Input />
    <Combobox.Status v-slot="{ count }">
      {{ loading ? 'Searching' : `${count} cities` }}
    </Combobox.Status>
    <Combobox.Content>
      <Combobox.Item v-for="match in matches" :key="match" :value="match">
        <Combobox.ItemText>{{ match }}</Combobox.ItemText>
      </Combobox.Item>
      <Combobox.Empty>No matches.</Combobox.Empty>
    </Combobox.Content>
  </Combobox.Root>
</template>

Inside a field

Combobox.Input is a field control, and Combobox.Root renders one hidden input per selected value once it has a name, so the form sees it.

<template>
  <Field.Root :options="{ name: 'city' }">
    <Label>City</Label>
    <Combobox.Root v-model="city" v-model:input-value="query">
      <Combobox.Input />
    </Combobox.Root>
    <Field.Error />
  </Field.Root>
</template>

The hidden inputs hold the value rather than the text, so what the user typed reaches the form only once it has become a value.

Positioning

Combobox.Content positions itself against the input, steered by the floating keys on the root’s options; the options table below lists every one of them. The resolved placement comes back as data-side and data-align.

<template>
  <Combobox.Root
    :options="{
      floating: { side: 'top', align: 'center', sideOffset: 8, flip: false },
    }"
  />
</template>

Wrap the control in a Combobox.InputGroup and the popup anchors on the group instead, which is what it needs when the frame the user sees is wider than the input inside it. Pass anchor to position against something else again, and pass any of the eight floating keys straight to the content to override the options for one instance.

<template>
  <Combobox.Content :anchor="() => frame" side="top" :side-offset="12" />
</template>

The content writes --rack-floating-anchor-width, --rack-floating-anchor-height, --rack-floating-available-width, --rack-floating-available-height and --rack-floating-transform-origin onto the floating box, so the popup is already as wide as the anchor and never taller than the space around it.

.popup {
  min-width: var(--mirror-combobox-popup-min-width);
  max-height: var(--mirror-combobox-popup-max-height);
}

Where the popup renders

The content teleports to the end of <body>, which is what keeps a popup out of an ancestor’s overflow: hidden and above the rest of the page. Turn it off to render in place, or name a target of your own.

<template>
  <Combobox.Root :options="{ portal: { to: '#overlays' } }" />
  <Combobox.Root :options="{ portal: { disabled: true } }" />
</template>

options.backdrop adds a fixed <div role="presentation"> behind the popup, for the modal case. It has no dismiss behaviour of its own: the popup’s layer already closes on a pointer down outside.

<template>
  <Combobox.Root :options="{ modal: true, backdrop: true }" />
</template>

Reaching the combobox from anywhere

Give the root an id and useMirrorCombobox(id) opens, clears and reads that combobox from anywhere in the app.

<script setup lang="ts">
import { useMirrorCombobox } from '@maas/mirror/vue'
import { ComboboxId } from '~/constants/comboboxIds'

const { isOpen, open, clear } = useMirrorCombobox(ComboboxId.City)
</script>

<template>
  <Button :aria-expanded="isOpen" @click="open()">Search cities</Button>
  <Button @click="clear()">Reset</Button>
</template>

The state is created by whichever side asks for it first, so the toolbar may mount before the combobox does.

Styling from state

Every part writes its state to data-*, so the styling is a matter of attribute selectors.

.item[data-highlighted='true'] {
  background: var(--app-color-primary-bg-subtle);
}

.item[data-disabled='true'] {
  opacity: 0.5;
}

.trigger[data-popup-open='true'] {
  transform: rotate(180deg);
}

The content’s four boxes are not parts, so they are reached by their data-scope or by the class each one ships. A class on Combobox.Content is applied to the popup, the box you paint.

<template>
  <Combobox.Content class="popup" />
</template>
.mirror-combobox-floating {
  --mirror-combobox-floating-z-index: 80;
}

.popup[data-list-empty='true'] {
  min-height: 0;
}

[data-scope='list'][data-multiple='true'] {
  padding-inline-start: 0;
}

API reference

Module. A bundled options object on the root, and useMirrorCombobox(id) as the programmatic API. Every part that reads the combobox takes id to resolve one it is not nested inside.

Combobox.Root

Renders its children plus one visually hidden <input> per selected value.

Props

PropTypeDefault
id
stringgenerated
modelValue
string | number | object | Array | undefinedundefined
defaultValue
string | number | object | Array | nullnull
inputValue
string | undefinedundefined
defaultInputValue
string''
open
boolean | undefinedundefined
defaultOpen
booleanfalse
options
ComboboxOptionssee below

disabled, readOnly, required and name resolve in one order everywhere: the options object first, then the surrounding Field, then false. Leave the option unset and the field answers; set it, false included, and it wins.

Control is decided once, on the first render, for modelValue, inputValue and open alike. A binding that later turns undefined does not hand control back.

Options

ComboboxOptions is SelectOptions without typeahead, plus the flags of its own listed first below.

OptionTypeDefault
openOnInput
booleantrue
openOnInputClick
booleantrue
openOnFocus
booleanfalse
autoHighlight
booleantrue
clearOnEscape
booleanfalse
allowCustomValue
booleanfalse
multiple
booleanfalse
modal
booleanfalse
loop
booleantrue
disabled
booleanundefined
readOnly
booleanundefined
required
booleanundefined
name
stringundefined
form
stringundefined
isItemEqualToValue
(item, value) => booleanObject.is
itemToStringLabel
(item) => stringsee Select
itemToStringValue
(item) => stringsee Select
highlightOnHover
booleantrue
dismiss.escape
booleantrue
dismiss.pointerDownOutside
booleantrue
dismiss.focusOutside
booleantrue
focus.trapped
booleanfalse
focus.restore
booleanfalse
backdrop
booleanfalse
portal.to
string | HTMLElementbody
portal.disabled
booleanfalse
portal.defer
booleanfalse
floating.side
'top' | 'right' | 'bottom' | 'left''bottom'
floating.sideOffset
number4
floating.align
'start' | 'center' | 'end''start'
floating.alignOffset
number0
floating.strategy
'absolute' | 'fixed''absolute'
floating.flip
booleantrue
floating.shift
booleantrue
floating.collisionPadding
number8
forceMount.popup
booleanfalse
forceMount.empty
booleanfalse
forceMount.clear
booleanfalse
transition.popup
string'mirror-combobox-popup'
transition.backdrop
string'mirror-combobox-backdrop'

Emits

EmitPayload
update:modelValue
string | number | object | Array | null
update:inputValue
string
update:open
boolean
highlightChange
string | number | object | null

A disabled combobox emits nothing, and a read-only one refuses opening, selecting and clearing before any of them reach the state.

Combobox.Input

Renders an <input role="combobox" type="text" autocomplete="off"> with aria-haspopup="listbox", the focused element for the whole interaction. It is a void element, so there is no default slot.

Props

PropTypeDefault
placeholder
stringundefined
disabled
booleanoptions.disabled
readOnly
booleanoptions.readOnly
required
booleanoptions.required

Data attributes

The field state set, plus the combobox-only attributes below.

Combobox.InputGroup

Optional. Renders a <div> around the input and whatever sits beside it, republishing the popup state so the whole control can be styled from one element. It also becomes the anchor Combobox.Content measures.

Slot props

PropType
open
boolean

Combobox.Value

Renders a <span> with the selected labels, joined with a comma. Useful beside a Combobox.Input that holds a search query rather than the selection, and as the source for a chip loop.

Props

PropTypeDefault
placeholder
string''

Slot props

PropType
value
ComboboxModelValue | null
entries
Array<ComboboxValueEntry>
label
string

Combobox.Icon

Renders a <span aria-hidden="true"> with data-popup-open, for the disclosure marker. No props beyond id and the primitive ones.

Combobox.Trigger

Renders a <button type="button" tabindex="-1" aria-hidden="true"> that toggles the popup and hands focus straight back to the input.

Props

PropTypeDefault
disabled
booleanoptions.disabled

Slot props

PropType
open
boolean
pressed
boolean

Combobox.Clear

Renders a <button type="button" tabindex="-1"> that clears the value and the input text, then returns focus to the input. It is not rendered while there is nothing to clear, and refuses to act while the combobox is disabled or read-only.

One clear button per combobox, so options.forceMount.clear is the only home for keeping it mounted with nothing to clear.

Slot props

PropType
visible
boolean

Combobox.Chips

Renders a <div> holding one Combobox.Chip per selection, plus the input. Only meaningful under options.multiple.

Slot props

PropType
entries
Array<ComboboxValueEntry>

Combobox.Chip

Renders a <div> standing for one selected value, and tells the Combobox.ChipRemove inside it which value to drop.

Props

PropTypeDefault
value
string | number | objectnone
label
stringfrom itemToStringLabel

Slot props

PropType
value
ComboboxValueType
label
string

Combobox.ChipRemove

Renders a <button type="button" tabindex="-1"> that drops its chip’s value and returns focus to the input. Throws missing_context outside a Combobox.Chip.

Combobox.Empty

Renders a <div aria-live="polite" aria-atomic="true"> that stays mounted for the whole interaction. Only its content comes and goes, and while there is something to show it is hidden.

One empty state per combobox, so options.forceMount.empty is the only home for filling it regardless of the open state and the item count.

Combobox.Status

Renders a <div role="status" aria-live="polite" aria-atomic="true"> for loading text and result counts. It says nothing on its own.

Slot props

PropType
count
number
empty
boolean
open
boolean

Combobox.Content

Renders everything that floats: the teleport, the floating box, the popup and the listbox. Everything in its default slot renders inside the listbox. It anchors on Combobox.InputGroup when there is one and on Combobox.Input otherwise, and a pointer down or a focus move inside the input, the group or the trigger is not treated as an outside dismissal.

Layers

None of the four is a part, so each is styled through its data-scope or the class it ships. A class on the content itself is applied to the popup.

LayerElementPublishes
portal
nonenothing
backdrop
div[role=presentation]data-combobox, data-state
floating
divdata-side, data-align, data-anchor-hidden, data-state
popup
divdata-combobox, data-side, data-align, data-state, data-list-empty, data-multiple
list
div[role=listbox]data-list-empty, data-multiple

Props

Every prop below but anchor is an escape hatch over an option, forceMount over options.forceMount.popup and the rest over the matching options.floating key, and only side and align stay reactive after mount.

PropTypeDefault
forceMount
booleanoptions.forceMount.popup
anchor
HTMLElement | (() => HTMLElement | null)the input group, then the input
side
'top' | 'right' | 'bottom' | 'left''bottom'
sideOffset
number4
align
'start' | 'center' | 'end''start'
alignOffset
number0
strategy
'absolute' | 'fixed''absolute'
flip
booleantrue
shift
booleantrue
collisionPadding
number8

Slots

SlotRenders
defaultInside the listbox. Items, groups, separators and the empty state.
headerInside the popup, above the listbox. Anything that is not an option.
footerInside the popup, below the listbox.

Slot props

All three slots take the same props.

PropType
open
boolean
side
ComboboxSide
align
ComboboxAlign

Combobox.Item

Renders a <div role="option">. Items register in document order, so grouping and portals cannot scramble the arrow-key sequence. Naming a <button> in as gets the button semantics with it, including the native disabled attribute.

Props

PropTypeDefault
value
string | number | objectnone
label
stringthe Combobox.ItemText content
disabled
booleanfalse

Slot props

PropType
selected
boolean
highlighted
boolean
disabled
boolean
index
number

Combobox.Group

Renders a <div role="group"> around a run of items, pointing aria-labelledby at the Combobox.GroupLabel inside it. No props beyond the primitive ones.

Combobox.GroupLabel

Renders a <div> and names its group. Throws missing_context outside a Combobox.Group.

Combobox.Separator

Renders a <div role="separator"> between groups.

Props

PropTypeDefault
orientation
'horizontal' | 'vertical''horizontal'

Combobox.ItemText

Renders a <span> and registers its text as the item’s label when no label prop was given.

Combobox.ItemIndicator

Renders a <span aria-hidden="true"> while the item is selected.

Props

One indicator per item, so forceMount is configured on the part and nowhere else. Set it on the indicator you want kept in the tree while its item is unselected. Every other indicator is left where it was.

PropTypeDefault
forceMount
booleanfalse

Slot props

PropType
selected
boolean

Composable

useMirrorCombobox(id) reaches a combobox from anywhere in the app.

KeyType
isOpen
ComputedRef<boolean>
open
() => void
close
(restoreFocus?: boolean) => void
toggle
() => void
clear
() => void
value
ComputedRef<ComboboxModelValue | null>
setValue
(next: ComboboxModelValue | null) => void
inputValue
ComputedRef<string>
setInputValue
(next: string, customValue?: boolean) => void
highlightedValue
ComputedRef<ComboboxValueType | null>

Data attributes

An input has a native placeholder, so data-placeholder from Select never appears here.

PartAttributeValue
Combobox.Input, Combobox.Trigger, Combobox.Clear, Combobox.Item
data-disabled
true
Combobox.Input
data-readonly
true
Combobox.Input
data-required
true
Combobox.Input
data-valid
true
Combobox.Input
data-invalid
true
Combobox.Input
data-dirty
true
Combobox.Input
data-touched
true
Combobox.Input
data-filled
true
Combobox.Input
data-focused
true
Combobox.Input, Combobox.Content
data-combobox
the instance ID
Combobox.Input
data-custom-value
true
Combobox.Input, Combobox.InputGroup, Combobox.Trigger, Combobox.Clear, Combobox.Icon, Combobox.Status
data-popup-open
true
Combobox.Trigger
data-pressed
true
Combobox.Clear
data-visible
true
Combobox.Content
data-scope
floating | popup | list | backdrop
Combobox.Content, Combobox.Empty, Combobox.ItemIndicator
data-state
open | closed
Combobox.Input, Combobox.InputGroup, Combobox.Trigger, Combobox.Content
data-side
top | right | bottom | left
Combobox.Input, Combobox.InputGroup, Combobox.Trigger, Combobox.Content
data-align
start | center | end
Combobox.Content
data-anchor-hidden
true
Combobox.Input, Combobox.InputGroup, Combobox.Trigger, Combobox.Content, Combobox.Empty, Combobox.Status
data-list-empty
true
Combobox.Input, Combobox.InputGroup, Combobox.Value, Combobox.Chips, Combobox.Content, Combobox.Item
data-multiple
true
Combobox.Value
data-placeholder
true
Combobox.Item, Combobox.ItemIndicator
data-selected
true
Combobox.Item
data-highlighted
true
Combobox.Item
data-index
the index

CSS variables

VariableDefault
--mirror-combobox-popup-max-height
var(--rack-floating-available-height, none)
--mirror-combobox-popup-min-width
var(--rack-floating-anchor-width, auto)
--mirror-combobox-floating-z-index
50
--mirror-combobox-backdrop-position
fixed
--mirror-combobox-backdrop-z-index
50
--mirror-combobox-trigger-cursor
pointer
--mirror-combobox-clear-cursor
pointer
--mirror-combobox-chip-remove-cursor
pointer
--mirror-combobox-item-cursor
default
--mirror-combobox-item-disabled-cursor
not-allowed

Errors

Code
missing_combobox_context
missing_combobox_input
duplicate_combobox_input
missing_context
missing_item_value
duplicate_item_value
invalid_multiple_value

Accessibility

The input takes role="combobox", aria-autocomplete="list", aria-expanded, aria-controls and aria-activedescendant, and focus never leaves it, so every key below is handled there and all of them are ignored while the combobox is disabled.

KeyBehaviour
Printable charactersNative. Types into the input, and openOnInput opens the popup on the resulting change. A pointer click opens it too, under openOnInputClick.
ArrowDown / ArrowUpOpens the popup while it is closed: ArrowDown lands on the first item and ArrowUp on the last. While it is open, moves the highlight.
PageDown / PageUpMoves the highlight ten items while the popup is open. Clamps at the ends whatever loop says.
Home / EndNot bound. Native caret movement in the input.
EnterWhile open, selects the highlighted item. With allowCustomValue and nothing highlighted, commits the typed text. While closed it is native, so the form submits.
EscapeCloses the popup. With clearOnEscape, an Escape on an already-closed combobox clears the value and the text.
TabCommits the highlight the way Enter does, without preventing the default.
BackspaceNative while there is text. Under options.multiple on an empty input it drops the last selection instead, which is the chip nearest the caret.

Every keyboard move scrolls the highlighted item into view, so a popup capped by --mirror-combobox-popup-max-height cannot lose the highlight off screen. A pointer highlight leaves the scroll position where the reader put it.

Typing goes into the input rather than into a typeahead search, which is why there is no typeahead option here. The trigger, the chips and the items suppress pointerdown, so clicking any of them never pulls focus out of the input.

Combobox.Empty and Combobox.Status are both polite live regions, and they divide the work: Empty announces that a filter found nothing, Status announces whatever else the consumer wants said, such as a loading state or a result count.