Combobox
A text input that filters a list of options as people type and keeps one selection.
'use client'
import { useState } from 'react'
import { Combobox, ComboboxLabel, ComboboxOption } from '@/components/combobox'
import { Field, Label } from '@/components/fieldset'
export default function Example({ members }) {
let [owner, setOwner] = useState(members[0])
return (
<Field>
<Label>Account owner</Label>
<Combobox options={members} displayValue={(member) => member?.name} value={owner} onChange={setOwner}>
{(member) => (
<ComboboxOption value={member}>
<ComboboxLabel>{member.name}</ComboboxLabel>
</ComboboxOption>
)}
</Combobox>
</Field>
)
}<script setup lang="ts">
import { ref } from 'vue'
import Combobox from '@/components/combobox.vue'
import ComboboxLabel from '@/components/combobox-label.vue'
import ComboboxOption from '@/components/combobox-option.vue'
import Field from '@/components/field.vue'
import Label from '@/components/label.vue'
type Member = { id: number; name: string }
const props = defineProps<{ members: Member[] }>()
const owner = ref(props.members[0])
</script>
<template>
<Field>
<Label>Account owner</Label>
<Combobox v-model="owner" :options="members" :display-value="(member) => member?.name">
<template #default="{ option }">
<ComboboxOption :value="option">
<ComboboxLabel>{{ option.name }}</ComboboxLabel>
</ComboboxOption>
</template>
</Combobox>
</Field>
</template>Installation
Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.
'use client'
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import { useState } from 'react'
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/16/solid'
const controlBackground = [
'before:absolute before:inset-px before:bg-white before:shadow-sm',
'before:rounded-[calc(var(--radius-lg)-1px)] dark:before:hidden',
]
const controlFocus = [
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg',
'after:ring-inset after:ring-transparent sm:focus-within:after:ring-2 sm:focus-within:after:ring-accent-500',
]
const controlState = [
'has-data-disabled:opacity-50 has-data-disabled:before:shadow-none has-data-disabled:before:bg-zinc-950/5',
'has-data-invalid:before:shadow-red-500/10',
]
const control = ['relative block w-full', controlBackground, controlFocus, controlState]
const inputSpacing = [
'pl-[calc(--spacing(3.5)-1px)] pr-[calc(--spacing(10)-1px)] py-[calc(--spacing(2.5)-1px)]',
'sm:pl-[calc(--spacing(3)-1px)] sm:pr-[calc(--spacing(9)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
]
const inputBorder = [
'border border-zinc-950/10 dark:border-white/10',
'data-hover:border-zinc-950/20 dark:data-hover:border-white/20',
]
const inputInvalid = [
'data-invalid:border-red-500 dark:data-invalid:border-red-500',
'data-invalid:data-hover:border-red-500 dark:data-invalid:data-hover:border-red-500',
]
const inputDisabled = [
'data-disabled:border-zinc-950/20 dark:data-disabled:bg-white/2.5',
'dark:data-disabled:border-white/15 dark:data-hover:data-disabled:border-white/15',
]
const input = [
'relative block w-full appearance-none rounded-lg focus:outline-hidden dark:scheme-dark',
'bg-transparent dark:bg-white/5 text-zinc-950 dark:text-white placeholder:text-zinc-500 text-base/6 sm:text-sm/6',
inputSpacing,
inputBorder,
inputInvalid,
inputDisabled,
]
const panel = [
'isolate min-w-[calc(var(--input-width)+8px)] rounded-xl p-1 select-none empty:invisible',
'[--anchor-padding:--spacing(4)] [--anchor-gap:--spacing(2)] sm:data-[anchor~=start]:[--anchor-offset:-4px]',
'overflow-y-scroll overscroll-contain scroll-py-1 focus:outline-hidden outline outline-transparent',
'bg-white/75 dark:bg-zinc-800/75 backdrop-blur-xl shadow-lg',
'ring-1 ring-zinc-950/10 dark:ring-inset dark:ring-white/10',
'transition-opacity ease-in duration-100 data-transition:pointer-events-none data-closed:data-leave:opacity-0',
]
const option = [
'group/option grid w-full items-baseline gap-x-2 rounded-lg cursor-default outline-hidden',
'grid-cols-[1fr_--spacing(5)] sm:grid-cols-[1fr_--spacing(4)]',
'pl-3.5 pr-2 py-2.5 sm:pl-3 sm:py-1.5',
'text-zinc-950 dark:text-white text-base/6 sm:text-sm/6',
'data-focus:text-white data-focus:bg-accent-500 data-disabled:opacity-50',
'forced-color-adjust-none forced-colors:text-[CanvasText]',
'forced-colors:data-focus:text-[HighlightText] forced-colors:data-focus:bg-[Highlight]',
]
const optionIcon = [
'*:data-[slot=icon]:shrink-0 *:data-[slot=icon]:size-5 sm:*:data-[slot=icon]:size-4',
'*:data-[slot=icon]:text-zinc-500 forced-colors:*:data-[slot=icon]:text-[CanvasText] dark:*:data-[slot=icon]:text-zinc-400',
'group-data-focus/option:*:data-[slot=icon]:text-white forced-colors:group-data-focus/option:*:data-[slot=icon]:text-[Canvas]',
]
const optionAvatar = '*:data-[slot=avatar]:size-6 sm:*:data-[slot=avatar]:size-5 *:data-[slot=avatar]:-mx-0.5'
const optionContent = ['flex min-w-0 items-center', optionIcon, optionAvatar]
function normalize(value: string) {
return value
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/gu, '')
}
type ComboboxProps<T> = {
options: T[]
displayValue: (value: T | null) => string | undefined
filter?: (value: T, query: string) => boolean
className?: string
placeholder?: string
autoFocus?: boolean
'aria-label'?: string
children: (value: NonNullable<T>) => React.ReactElement
anchor?: 'top' | 'bottom'
} & Omit<Headless.ComboboxProps<T, false>, 'as' | 'multiple' | 'children' | 'anchor'>
export function Combobox<T>({
options,
displayValue,
filter,
anchor = 'bottom',
className,
placeholder,
autoFocus,
'aria-label': ariaLabel,
children,
onClose,
...props
}: ComboboxProps<T>) {
let [query, setQuery] = useState('')
let matches = (option: T) =>
filter ? filter(option, query) : normalize(displayValue(option) ?? '').includes(normalize(query))
let filteredOptions = query ? options.filter(matches) : options
return (
<Headless.Combobox
{...props}
multiple={false}
virtual={{ options: filteredOptions }}
onClose={() => {
setQuery('')
onClose?.()
}}
>
<span data-slot="control" className={clsx(className, control)}>
<Headless.ComboboxInput
autoFocus={autoFocus}
data-slot="control"
aria-label={ariaLabel}
displayValue={(option: T) => displayValue(option) ?? ''}
onChange={(event) => setQuery(event.target.value)}
placeholder={placeholder}
className={clsx(input)}
/>
<Headless.ComboboxButton className="group absolute inset-y-0 right-0 px-2 flex items-center">
<ChevronUpDownIcon
className={clsx(
'size-5 sm:size-4 text-zinc-500 dark:text-zinc-400 forced-colors:text-[CanvasText]',
'group-data-hover:text-zinc-700 dark:group-data-hover:text-zinc-300 group-data-disabled:text-zinc-600'
)}
/>
</Headless.ComboboxButton>
</span>
<Headless.ComboboxOptions transition anchor={anchor} className={clsx(panel)}>
{({ option }) => children(option)}
</Headless.ComboboxOptions>
</Headless.Combobox>
)
}
type ComboboxOptionProps<T> = { className?: string; children?: React.ReactNode } & Omit<
Headless.ComboboxOptionProps<'div', T>,
'as' | 'className'
>
export function ComboboxOption<T>({ children, className, ...props }: ComboboxOptionProps<T>) {
return (
<Headless.ComboboxOption {...props} className={clsx(option)}>
<span className={clsx(className, optionContent)}>{children}</span>
<CheckIcon className="relative hidden col-start-2 self-center size-5 sm:size-4 group-data-selected/option:inline" />
</Headless.ComboboxOption>
)
}
export function ComboboxLabel({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) {
return <span {...props} className={clsx(className, 'truncate ml-2.5 sm:ml-2 first:ml-0 sm:first:ml-0')} />
}
export function ComboboxDescription({ className, children, ...props }: React.ComponentPropsWithoutRef<'span'>) {
return (
<span
{...props}
className={clsx(
className,
'flex flex-1 overflow-hidden text-zinc-500 dark:text-zinc-400 group-data-focus/option:text-white before:w-2 before:min-w-0 before:shrink'
)}
>
<span className="flex-1 truncate">{children}</span>
</span>
)
}<script setup lang="ts" generic="T extends AcceptableValue">
import {
type AcceptableValue,
ComboboxAnchor,
ComboboxContent,
ComboboxInput,
ComboboxPortal,
ComboboxRoot,
ComboboxTrigger,
} from 'reka-ui'
import { ChevronUpDownIcon } from '@heroicons/vue/16/solid'
import { computed, ref, useAttrs } from 'vue'
import { useDisabled, useFieldControl } from './fields'
import { vInteractions } from './interactions'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
options: T[]
name?: string
displayValue: (value: T | null) => string | undefined
filter?: (value: T, query: string) => boolean
anchor?: 'top' | 'bottom'
placeholder?: string
autofocus?: boolean
disabled?: boolean
invalid?: boolean
}>(),
{ anchor: 'bottom' }
)
const model = defineModel<T>()
const attrs = useAttrs()
const { id, describedBy } = useFieldControl(attrs)
const controlBackground = [
'before:absolute before:inset-px before:bg-white before:shadow-sm',
'before:rounded-[calc(var(--radius-lg)-1px)] dark:before:hidden',
]
const controlFocus = [
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg',
'after:ring-inset after:ring-transparent sm:focus-within:after:ring-2 sm:focus-within:after:ring-accent-500',
]
const controlState = [
'has-data-disabled:opacity-50 has-data-disabled:before:shadow-none has-data-disabled:before:bg-zinc-950/5',
'has-data-invalid:before:shadow-red-500/10',
]
const control = ['relative block w-full', controlBackground, controlFocus, controlState]
const inputSpacing = [
'pl-[calc(--spacing(3.5)-1px)] pr-[calc(--spacing(10)-1px)] py-[calc(--spacing(2.5)-1px)]',
'sm:pl-[calc(--spacing(3)-1px)] sm:pr-[calc(--spacing(9)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
]
const inputBorder = [
'border border-zinc-950/10 dark:border-white/10',
'data-hover:border-zinc-950/20 dark:data-hover:border-white/20',
]
const inputInvalid = [
'data-invalid:border-red-500 dark:data-invalid:border-red-500',
'data-invalid:data-hover:border-red-500 dark:data-invalid:data-hover:border-red-500',
]
const inputDisabled = [
'data-disabled:border-zinc-950/20 dark:data-disabled:bg-white/2.5',
'dark:data-disabled:border-white/15 dark:data-hover:data-disabled:border-white/15',
]
const input = [
'relative block w-full appearance-none rounded-lg focus:outline-hidden dark:scheme-dark',
'bg-transparent dark:bg-white/5 text-zinc-950 dark:text-white placeholder:text-zinc-500 text-base/6 sm:text-sm/6',
inputSpacing,
inputBorder,
inputInvalid,
inputDisabled,
]
const panel = [
'isolate min-w-[calc(var(--reka-combobox-trigger-width)+8px)] rounded-xl p-1 select-none empty:invisible',
'max-h-(--reka-combobox-content-available-height)',
'overflow-y-scroll overscroll-contain scroll-py-1 focus:outline-hidden outline outline-transparent',
'bg-white/75 dark:bg-zinc-800/75 backdrop-blur-xl shadow-lg',
'ring-1 ring-zinc-950/10 dark:ring-inset dark:ring-white/10',
]
const query = ref('')
function normalize(value: string) {
return value
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/gu, '')
}
const matches = (option: T) =>
props.filter ? props.filter(option, query.value) : normalize(props.displayValue(option) ?? '').includes(normalize(query.value))
const filteredOptions = computed(() => (query.value ? props.options.filter(matches) : props.options))
const isDisabled = useDisabled(() => props.disabled)
const innerAttrs = computed(() => {
const { class: _class, ...rest } = attrs
return rest
})
</script>
<template>
<ComboboxRoot
v-model="model"
as-child
ignore-filter
:name="name"
:disabled="isDisabled"
@update:open="(open) => !open && (query = '')"
>
<ComboboxAnchor as-child>
<span data-slot="control" :class="[attrs.class, control]">
<ComboboxInput
v-bind="innerAttrs"
v-interactions
data-slot="control"
:display-value="(option) => displayValue(option as T | null) ?? ''"
:placeholder="placeholder"
:autofocus="autofocus"
:id="id"
:aria-describedby="describedBy"
:disabled="isDisabled"
:data-disabled="isDisabled ? '' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:data-invalid="invalid ? '' : undefined"
:class="input"
@input="query = ($event.target as HTMLInputElement).value"
/>
<ComboboxTrigger class="group absolute inset-y-0 right-0 px-2 flex items-center">
<ChevronUpDownIcon
:class="[
'size-5 sm:size-4 text-zinc-500 dark:text-zinc-400 forced-colors:text-[CanvasText]',
'group-data-hover:text-zinc-700 dark:group-data-hover:text-zinc-300 group-data-disabled:text-zinc-600',
]"
/>
</ComboboxTrigger>
<ComboboxPortal>
<ComboboxContent
position="popper"
:side="anchor"
:side-offset="8"
:class="panel"
>
<slot v-for="(option, index) in filteredOptions" :key="index" :option="option" />
</ComboboxContent>
</ComboboxPortal>
</span>
</ComboboxAnchor>
</ComboboxRoot>
</template>Component API
| Prop | Default | Description |
|---|---|---|
Comboboxbuilt on the Headless UI <Headless.Combobox> component | ||
value | - | The option currently selected, or null. |
onChange | - | Receives the option people pick. |
options | - | Every option to search. The render function in children draws each one. |
displayValue | - | Turns an option into the text shown in the input and matched by the default search. |
filter | - | Replaces the default search, which ignores case and accents, with your own (option, query) => boolean test. |
placeholder | - | Hint shown while the input is empty. |
invalid | false | Red border that flags a validation error. |
disabled | false | Blocks typing and opening the list. |
anchor | bottom | Opens the list below the input, or above it with top. |
name | - | Submits the selected option with the surrounding form under this name. |
onClose | - | Called when the list closes. The search text is cleared first. |
autoFocus | false | Focuses the input when the component mounts. |
aria-label | - | Accessible name when the combobox sits outside a `Field` with a `Label`. |
ComboboxOptionbuilt on the Headless UI <Headless.ComboboxOption> component | ||
value | - | The option this row selects. |
ComboboxLabelrenders a <span> element | ||
| This component does not expose any component-specific props. | ||
ComboboxDescriptionrenders a <span> element | ||
| This component does not expose any component-specific props. | ||
| Prop | Default | Description |
|---|---|---|
Comboboxbuilt on the Reka UI <ComboboxRoot> component | ||
v-model | - | The option currently selected, kept in sync both ways. |
options | - | Every option to search. The default slot draws each one. |
name | - | Submits the selected option with the surrounding form under this name. |
displayValue | - | Turns an option into the text shown in the input and matched by the default search. |
filter | - | Replaces the default search with your own (option, query) => boolean test. |
placeholder | - | Hint shown while the input is empty. |
invalid | false | Red border that flags a validation error. |
disabled | false | Blocks typing and opening the list. |
anchor | bottom | Opens the list below the input, or above it with top. |
ComboboxOptionbuilt on the Reka UI <ComboboxItem> component | ||
value | - | The option this row selects. |
disabled | false | Shows the row faded and prevents selecting it. |
ComboboxLabelrenders a <span> element | ||
| This component does not expose any component-specific props. | ||
ComboboxDescriptionrenders a <span> element | ||
| This component does not expose any component-specific props. | ||
Usage
Reach for a combobox when the list is long or people already know the name they want: members, customers, countries. For a handful of fixed choices, a Listbox shows everything at once and needs no typing.
Choose a team member
Pass the options, tell displayValue which text to show, and render each row as a ComboboxOption. value and onChange keep the selection in your state, and a Field connects the Label to the input:
Pass the options, tell displayValue which text to show, and render each row as a ComboboxOption in the default slot. v-model keeps the selection in your state, and a Field connects the Label to the input:
'use client'
import { useState } from 'react'
import { Combobox, ComboboxLabel, ComboboxOption } from '@/components/combobox'
import { Field, Label } from '@/components/fieldset'
export default function Example({ members }) {
let [owner, setOwner] = useState(members[0])
return (
<Field>
<Label>Account owner</Label>
<Combobox options={members} displayValue={(member) => member?.name} value={owner} onChange={setOwner}>
{(member) => (
<ComboboxOption value={member}>
<ComboboxLabel>{member.name}</ComboboxLabel>
</ComboboxOption>
)}
</Combobox>
</Field>
)
}<script setup lang="ts">
import { ref } from 'vue'
import Combobox from '@/components/combobox.vue'
import ComboboxLabel from '@/components/combobox-label.vue'
import ComboboxOption from '@/components/combobox-option.vue'
import Field from '@/components/field.vue'
import Label from '@/components/label.vue'
type Member = { id: number; name: string }
const props = defineProps<{ members: Member[] }>()
const owner = ref(props.members[0])
</script>
<template>
<Field>
<Label>Account owner</Label>
<Combobox v-model="owner" :options="members" :display-value="(member) => member?.name">
<template #default="{ option }">
<ComboboxOption :value="option">
<ComboboxLabel>{{ option.name }}</ComboboxLabel>
</ComboboxOption>
</template>
</Combobox>
</Field>
</template>Explain what the choice does
A Description inside the same Field tells people what the selection changes. Screen readers read it along with the label:
Receives renewal reminders and the monthly usage report.
<Field>
<Label>Account owner</Label>
<Description>Receives renewal reminders and the monthly usage report.</Description>
<Combobox options={members} displayValue={(member) => member?.name} value={owner} onChange={setOwner}>
{/* ... */}
</Combobox>
</Field><Field>
<Label>Account owner</Label>
<Description>Receives renewal reminders and the monthly usage report.</Description>
<Combobox v-model="owner" :options="members" :display-value="(member) => member?.name">
<!-- ... -->
</Combobox>
</Field>Options
Everything inside a ComboboxOption sits on one line, and the check mark of the selected row stays aligned on the right. Wrap the main text in ComboboxLabel so it truncates cleanly when space runs out.
People with photos
An Avatar before the label is sized to the row automatically. Faces help when two teammates share a first name:
<Combobox options={members} displayValue={(member) => member?.name} value={reviewer} onChange={setReviewer}>
{(member) => (
<ComboboxOption value={member}>
<Avatar src={member.avatar} alt="" />
<ComboboxLabel>{member.name}</ComboboxLabel>
</ComboboxOption>
)}
</Combobox><Combobox v-model="reviewer" :options="members" :display-value="(member) => member?.name">
<template #default="{ option }">
<ComboboxOption :value="option">
<Avatar :src="option.avatar" alt="" />
<ComboboxLabel>{{ option.name }}</ComboboxLabel>
</ComboboxOption>
</template>
</Combobox>Countries with flags
Any image placed first works the same way. Give flags an empty alt, since the country name follows:
<Combobox options={countries} displayValue={(country) => country?.name} value={country} onChange={setCountry}>
{(country) => (
<ComboboxOption value={country}>
<img className="w-5 sm:w-4" src={country.flag} alt="" />
<ComboboxLabel>{country.name}</ComboboxLabel>
</ComboboxOption>
)}
</Combobox><Combobox v-model="country" :options="countries" :display-value="(country) => country?.name">
<template #default="{ option }">
<ComboboxOption :value="option">
<img class="w-5 sm:w-4" :src="option.flag" alt="" />
<ComboboxLabel>{{ option.name }}</ComboboxLabel>
</ComboboxOption>
</template>
</Combobox>A second line of detail
ComboboxDescription adds muted text after the label, such as the address an invoice goes to, so people can tell two similar names apart:
<Combobox options={members} displayValue={(member) => member?.name} value={recipient} onChange={setRecipient}>
{(member) => (
<ComboboxOption value={member}>
<ComboboxLabel>{member.name}</ComboboxLabel>
<ComboboxDescription>{member.email}</ComboboxDescription>
</ComboboxOption>
)}
</Combobox><Combobox v-model="recipient" :options="members" :display-value="(member) => member?.name">
<template #default="{ option }">
<ComboboxOption :value="option">
<ComboboxLabel>{{ option.name }}</ComboboxLabel>
<ComboboxDescription>{{ option.email }}</ComboboxDescription>
</ComboboxOption>
</template>
</Combobox>Search
By default, an option matches when its displayValue contains the query, ignoring case. The query clears when the list closes, so the next search starts from the full list.
Match on more than the name
filter replaces the default test. Here the query also matches email addresses, so typing a domain narrows the list to one company:
function matches(member, query) {
let search = query.toLowerCase()
return member.name.toLowerCase().includes(search) || member.email.toLowerCase().includes(search)
}
<Combobox
options={members}
displayValue={(member) => member?.name}
filter={matches}
value={recipient}
onChange={setRecipient}
>
{/* ... */}
</Combobox><script setup lang="ts">
function matches(member: Member, query: string) {
const search = query.toLowerCase()
return member.name.toLowerCase().includes(search) || member.email.toLowerCase().includes(search)
}
</script>
<template>
<Combobox
v-model="recipient"
:options="members"
:display-value="(member) => member?.name"
:filter="matches"
>
<!-- ... -->
</Combobox>
</template>States
Nothing selected yet
Start from null and set a placeholder that says what to type. Keep it short: it disappears as soon as people type:
let [owner, setOwner] = useState(null)
<Combobox
options={members}
displayValue={(member) => member?.name}
value={owner}
onChange={setOwner}
placeholder="Search by name…"
>
{/* ... */}
</Combobox>const owner = ref<Member | null>(null)
<Combobox
v-model="owner"
:options="members"
:display-value="(member) => member?.name"
placeholder="Search by name…"
>
<!-- ... -->
</Combobox>Required but empty
invalid turns the border red. Pair it with an ErrorMessage in the same Field that says how to fix the problem, not only that there is one:
Choose an account owner before saving.
<Field>
<Label>Account owner</Label>
<Combobox
options={members}
displayValue={(member) => member?.name}
value={owner}
onChange={setOwner}
placeholder="Search by name…"
invalid={errors.owner !== undefined}
>
{/* ... */}
</Combobox>
{errors.owner && <ErrorMessage>{errors.owner}</ErrorMessage>}
</Field><Field>
<Label>Account owner</Label>
<Combobox
v-model="owner"
:options="members"
:display-value="(member) => member?.name"
placeholder="Search by name…"
:invalid="errors.owner !== undefined"
>
<!-- ... -->
</Combobox>
<ErrorMessage v-if="errors.owner">{{ errors.owner }}</ErrorMessage>
</Field>Locked value
disabled on the Field fades the label and the input together. Say why the value is locked, so nobody goes looking for a way to change it:
Closed accounts keep the owner they had on the closing date.
<Field disabled>
<Label>Account owner</Label>
<Description>Closed accounts keep the owner they had on the closing date.</Description>
<Combobox options={members} displayValue={(member) => member?.name} value={owner} onChange={setOwner}>
{/* ... */}
</Combobox>
</Field><Field disabled>
<Label>Account owner</Label>
<Description>Closed accounts keep the owner they had on the closing date.</Description>
<Combobox v-model="owner" :options="members" :display-value="(member) => member?.name">
<!-- ... -->
</Combobox>
</Field>Without a Field, set disabled on the Combobox itself.
Layout
Narrower input
The combobox fills its container. A max-w-* class keeps it proportionate to the names it holds; the list still opens at least as wide as the input:
<Combobox
options={members}
displayValue={(member) => member?.name}
value={owner}
onChange={setOwner}
className="max-w-48"
>
{/* ... */}
</Combobox><Combobox
v-model="owner"
:options="members"
:display-value="(member) => member?.name"
class="max-w-48"
>
<!-- ... -->
</Combobox>Inline in a filter bar
Outside a Field there is no Label to connect, so name the input with aria-label. The visible text next to it does not count for screen readers:
<div className="flex items-baseline gap-4">
<span className="text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white">Owner</span>
<Combobox
options={members}
displayValue={(member) => member?.name}
value={owner}
onChange={setOwner}
aria-label="Filter customers by owner"
className="max-w-48"
>
{/* ... */}
</Combobox>
</div><div class="flex items-baseline gap-4">
<span class="text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white">Owner</span>
<Combobox
v-model="owner"
:options="members"
:display-value="(member) => member?.name"
aria-label="Filter customers by owner"
class="max-w-48"
>
<!-- ... -->
</Combobox>
</div>Accessibility
Keyboard handling and the listbox roles come from Headless UI Combobox in React and Reka UI Combobox in Vue. In the Vue kit, the text from displayValue also keys each option in the list, so keep it unique across options.