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-[content-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/Combobox'

const options = {
  modal: false,
  teleport: { 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.must
  • Make sure the combobox has an accessible name, either through a Label inside a Field or an aria-label on the input.must
  • Combobox.Content teleports to the end of <body> unless you turn that off with options.teleport.disabled. Leave it on where the field sits inside anything with overflow: hidden.should
  • 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.must