Listbox
A select menu for picking one value, where each option can show an icon, an avatar or a second line of text.
'use client'
import { useState } from 'react'
import { Field, Label } from '@/components/fieldset'
import { Listbox, ListboxLabel, ListboxOption } from '@/components/listbox'
let terms = ['Due on receipt', 'Net 15', 'Net 30', 'Net 60']
export default function Example() {
let [term, setTerm] = useState('Net 30')
return (
<Field>
<Label>Payment terms</Label>
<Listbox value={term} onChange={setTerm}>
{terms.map((option) => (
<ListboxOption key={option} value={option}>
<ListboxLabel>{option}</ListboxLabel>
</ListboxOption>
))}
</Listbox>
</Field>
)
}<script setup lang="ts">
import Field from '@/components/field.vue'
import Label from '@/components/label.vue'
import Listbox from '@/components/listbox.vue'
import ListboxLabel from '@/components/listbox-label.vue'
import ListboxOption from '@/components/listbox-option.vue'
import { ref } from 'vue'
const terms = ['Due on receipt', 'Net 15', 'Net 30', 'Net 60']
const term = ref('Net 30')
</script>
<template>
<Field>
<Label>Payment terms</Label>
<Listbox v-model="term">
<ListboxOption v-for="option in terms" :key="option" :value="option">
<ListboxLabel>{{ option }}</ListboxLabel>
</ListboxOption>
</Listbox>
</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 { Fragment } from 'react'
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/16/solid'
const buttonBackground = [
'before:absolute before:inset-px before:bg-white before:shadow-sm',
'before:rounded-[calc(var(--radius-lg)-1px)] dark:before:hidden',
]
const buttonFocus = [
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg',
'after:ring-inset after:ring-transparent data-focus:after:ring-2 data-focus:after:ring-accent-500',
]
const buttonDisabled = 'data-disabled:opacity-50 data-disabled:before:shadow-none data-disabled:before:bg-zinc-950/5'
const trigger = ['group relative block w-full focus:outline-hidden', buttonBackground, buttonFocus, buttonDisabled]
const valueSpacing = [
'min-h-11 sm:min-h-9',
'pl-[calc(--spacing(3.5)-1px)] pr-[calc(--spacing(7)-1px)] py-[calc(--spacing(2.5)-1px)]',
'sm:pl-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
]
const valueBorder = [
'border border-zinc-950/10 dark:border-white/10',
'group-data-hover:border-zinc-950/20 group-data-active:border-zinc-950/20',
'dark:group-data-hover:border-white/20 dark:group-data-active:border-white/20',
]
const valueInvalid = [
'group-data-invalid:border-red-500 group-data-hover:group-data-invalid:border-red-500',
'dark:group-data-invalid:border-red-600 dark:data-hover:group-data-invalid:border-red-600',
]
const valueDisabled = [
'group-data-disabled:opacity-100 group-data-disabled:border-zinc-950/20',
'dark:group-data-disabled:bg-white/2.5 dark:group-data-disabled:border-white/15',
'dark:group-data-disabled:data-hover:border-white/15',
]
const selectedValue = [
'relative block w-full rounded-lg text-left bg-transparent dark:bg-white/5',
'text-zinc-950 dark:text-white forced-colors:text-[CanvasText] text-base/6 sm:text-sm/6',
valueSpacing,
valueBorder,
valueInvalid,
valueDisabled,
]
const panel = [
'isolate w-max min-w-[calc(var(--button-width)+1.75rem)] rounded-xl p-1 select-none',
'[--anchor-padding:--spacing(4)] [--anchor-offset:-1.625rem] sm:[--anchor-offset:-1.375rem]',
'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 items-baseline gap-x-2 rounded-lg cursor-default outline-hidden',
'grid-cols-[--spacing(5)_1fr] sm:grid-cols-[--spacing(4)_1fr]',
'pl-2 pr-3.5 py-2.5 sm:pl-1.5 sm:pr-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]
type ListboxProps<T> = {
className?: string
placeholder?: React.ReactNode
autoFocus?: boolean
'aria-label'?: string
children?: React.ReactNode
} & Omit<Headless.ListboxProps<typeof Fragment, T>, 'as' | 'multiple'>
export function Listbox<T>({
className,
placeholder,
autoFocus,
'aria-label': ariaLabel,
children: options,
...props
}: ListboxProps<T>) {
return (
<Headless.Listbox {...props} multiple={false}>
<Headless.ListboxButton autoFocus={autoFocus} data-slot="control" aria-label={ariaLabel} className={clsx(className, trigger)}>
<Headless.ListboxSelectedOption
as="span"
options={options}
placeholder={placeholder && <span className="block truncate text-zinc-500">{placeholder}</span>}
className={clsx(selectedValue)}
/>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<ChevronUpDownIcon className="size-5 sm:size-4 text-zinc-500 dark:text-zinc-400 group-data-disabled:text-zinc-600 forced-colors:text-[CanvasText]" />
</span>
</Headless.ListboxButton>
<Headless.ListboxOptions transition anchor="selection start" className={clsx(panel)}>
{options}
</Headless.ListboxOptions>
</Headless.Listbox>
)
}
type ListboxOptionProps<T> = { className?: string; children?: React.ReactNode } & Omit<
Headless.ListboxOptionProps<'div', T>,
'as' | 'className'
>
export function ListboxOption<T>({ children, className, ...props }: ListboxOptionProps<T>) {
return (
<Headless.ListboxOption as={Fragment} {...props}>
{({ selectedOption }) =>
selectedOption ? (
<div className={clsx(className, optionContent)}>{children}</div>
) : (
<div className={clsx(option)}>
<CheckIcon className="relative hidden self-center size-5 sm:size-4 group-data-selected/option:inline" />
<span className={clsx(className, optionContent, 'col-start-2')}>{children}</span>
</div>
)
}
</Headless.ListboxOption>
)
}
export function ListboxLabel({ 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 ListboxDescription({ 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 lang="ts">
const buttonBackground = [
'before:absolute before:inset-px before:bg-white before:shadow-sm',
'before:rounded-[calc(var(--radius-lg)-1px)] dark:before:hidden',
]
const buttonFocus = [
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg',
'after:ring-inset after:ring-transparent data-focus:after:ring-2 data-focus:after:ring-accent-500',
]
const buttonDisabled = 'data-disabled:opacity-50 data-disabled:before:shadow-none data-disabled:before:bg-zinc-950/5'
const trigger = ['group relative block w-full focus:outline-hidden', buttonBackground, buttonFocus, buttonDisabled]
const valueSpacing = [
'min-h-11 sm:min-h-9',
'pl-[calc(--spacing(3.5)-1px)] pr-[calc(--spacing(7)-1px)] py-[calc(--spacing(2.5)-1px)]',
'sm:pl-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
]
const valueBorder = [
'border border-zinc-950/10 dark:border-white/10',
'group-data-hover:border-zinc-950/20 group-data-active:border-zinc-950/20',
'dark:group-data-hover:border-white/20 dark:group-data-active:border-white/20',
]
const valueInvalid = [
'group-data-invalid:border-red-500 group-data-hover:group-data-invalid:border-red-500',
'dark:group-data-invalid:border-red-600 dark:data-hover:group-data-invalid:border-red-600',
]
const valueDisabled = [
'group-data-disabled:opacity-100 group-data-disabled:border-zinc-950/20',
'dark:group-data-disabled:bg-white/2.5 dark:group-data-disabled:border-white/15',
'dark:group-data-disabled:data-hover:border-white/15',
]
const selectedValue = [
'relative block w-full rounded-lg text-left bg-transparent dark:bg-white/5',
'text-zinc-950 dark:text-white forced-colors:text-[CanvasText] text-base/6 sm:text-sm/6',
valueSpacing,
valueBorder,
valueInvalid,
valueDisabled,
]
const panel = [
'isolate w-max rounded-xl 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 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]
</script>
<script setup lang="ts" generic="T extends AcceptableValue">
import {
SelectContent,
SelectPortal,
SelectRoot,
SelectTrigger,
SelectValue,
SelectViewport,
type AcceptableValue,
} from 'reka-ui'
import { ChevronUpDownIcon } from '@heroicons/vue/16/solid'
import { computed, h, ref, toRaw, useAttrs, useSlots, useTemplateRef, type ComponentPublicInstance, type VNode } from 'vue'
import { Fragment } from 'vue'
import { useDisabled, useFieldControl } from './fields'
import { vInteractions } from './interactions'
defineOptions({ inheritAttrs: false })
const props = defineProps<{
name?: string
placeholder?: string
autofocus?: boolean
disabled?: boolean
invalid?: boolean
by?: string
}>()
const model = defineModel<T>()
const attrs = useAttrs()
const slots = useSlots()
const { id, describedBy } = useFieldControl(attrs)
const isDisabled = useDisabled(() => props.disabled)
const hasSelection = computed(() => model.value !== undefined && model.value !== null)
function flattenVNodes(nodes: VNode[]): VNode[] {
return nodes.flatMap((node) =>
node.type === Fragment && Array.isArray(node.children) ? flattenVNodes(node.children as VNode[]) : [node]
)
}
function valuesEqual(a: unknown, b: unknown) {
if (props.by && a !== null && b !== null && typeof a === 'object' && typeof b === 'object') {
return (a as Record<string, unknown>)[props.by] === (b as Record<string, unknown>)[props.by]
}
return toRaw(a) === toRaw(b)
}
const SelectedContent = () => {
const nodes = flattenVNodes(slots.default?.() ?? [])
const match = nodes.find((node) => node.props && 'value' in node.props && valuesEqual(node.props.value, model.value))
const children =
match && typeof match.children === 'object' && match.children !== null && 'default' in match.children
? (match.children as { default?: () => VNode[] }).default?.()
: undefined
return h('div', { class: optionContent }, children)
}
const innerAttrs = computed(() => {
const { class: _class, ...rest } = attrs
return rest
})
const triggerElement = useTemplateRef<ComponentPublicInstance>('triggerElement')
const contentMinWidth = ref<string>()
function onOpenChange(open: boolean) {
if (!open) return
const el = triggerElement.value?.$el as HTMLElement | undefined
if (el) contentMinWidth.value = `calc(${el.getBoundingClientRect().width}px + 1.75rem)`
}
</script>
<template>
<SelectRoot v-model="model" :name="name" :disabled="isDisabled" :by="by" @update:open="onOpenChange">
<SelectTrigger
ref="triggerElement"
v-bind="innerAttrs"
v-interactions
data-slot="control"
:autofocus="autofocus"
:id="id"
:aria-describedby="describedBy"
:data-disabled="isDisabled ? '' : undefined"
:data-invalid="invalid ? '' : undefined"
:class="[attrs.class, trigger]"
>
<SelectValue as-child>
<span :class="selectedValue">
<SelectedContent v-if="hasSelection" />
<span v-else-if="placeholder" class="block truncate text-zinc-500">{{ placeholder }}</span>
</span>
</SelectValue>
<span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<ChevronUpDownIcon class="size-5 sm:size-4 text-zinc-500 dark:text-zinc-400 group-data-disabled:text-zinc-600 forced-colors:text-[CanvasText]" />
</span>
</SelectTrigger>
<SelectPortal>
<SelectContent
position="item-aligned"
:style="{ minWidth: contentMinWidth }"
:class="panel"
>
<SelectViewport class="scroll-py-1 p-1 select-none">
<slot />
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</template>Component API
| Prop | Default | Description |
|---|---|---|
Listboxbuilt on the Headless UI <Headless.Listbox> component | ||
value | - | Selected value when you control it yourself. |
onChange | - | Receives the value of the option that was just picked. |
placeholder | - | Shown in the button until something is selected. |
by | - | Property used to match object values, such as id. |
name | - | Submits the selected value with the surrounding form under this name. |
invalid | false | Turns the border red to flag an error. |
disabled | false | Blocks the button and dims it. |
autoFocus | false | Focuses the button when the component mounts. |
aria-label | - | Accessible name when the listbox sits outside a `Field` with a `Label`. |
ListboxOptionbuilt on the Headless UI <Headless.ListboxOption> component | ||
value | - | What the listbox reports once this option is picked. |
ListboxLabelrenders a <span> element | ||
| This component does not expose any component-specific props. | ||
ListboxDescriptionrenders a <span> element | ||
| This component does not expose any component-specific props. | ||
| Prop | Default | Description |
|---|---|---|
Listboxbuilt on the Reka UI <SelectRoot> component | ||
v-model | - | Two-way binding of the selected value. |
by | - | Property used to match object values, such as id. |
name | - | Submits the selected value with the surrounding form under this name. |
placeholder | - | Shown in the button until something is selected. |
invalid | false | Turns the border red to flag an error. |
disabled | false | Blocks the button and dims it. |
autofocus | false | Focuses the button when the component mounts. |
ListboxOptionbuilt on the Reka UI <SelectItem> component | ||
value | - | What the listbox reports once this option is picked. |
disabled | false | Keeps this option in the menu but prevents picking it. |
ListboxLabelrenders a <span> element | ||
| This component does not expose any component-specific props. | ||
ListboxDescriptionrenders a <span> element | ||
| This component does not expose any component-specific props. | ||
Usage
A listbox fits a single choice among more options than radio buttons can show comfortably, or a choice that needs more than plain text to recognize. The button repeats the full content of the selected option, icon or avatar included, so the field reads the same closed as open.
Label the field
Put the Label and the Listbox in a Field and they are linked for you, so the label names the button for screen readers:
'use client'
import { useState } from 'react'
import { Field, Label } from '@/components/fieldset'
import { Listbox, ListboxLabel, ListboxOption } from '@/components/listbox'
let terms = ['Due on receipt', 'Net 15', 'Net 30', 'Net 60']
export default function Example() {
let [term, setTerm] = useState('Net 30')
return (
<Field>
<Label>Payment terms</Label>
<Listbox value={term} onChange={setTerm}>
{terms.map((option) => (
<ListboxOption key={option} value={option}>
<ListboxLabel>{option}</ListboxLabel>
</ListboxOption>
))}
</Listbox>
</Field>
)
}<script setup lang="ts">
import Field from '@/components/field.vue'
import Label from '@/components/label.vue'
import Listbox from '@/components/listbox.vue'
import ListboxLabel from '@/components/listbox-label.vue'
import ListboxOption from '@/components/listbox-option.vue'
import { ref } from 'vue'
const terms = ['Due on receipt', 'Net 15', 'Net 30', 'Net 60']
const term = ref('Net 30')
</script>
<template>
<Field>
<Label>Payment terms</Label>
<Listbox v-model="term">
<ListboxOption v-for="option in terms" :key="option" :value="option">
<ListboxLabel>{{ option }}</ListboxLabel>
</ListboxOption>
</Listbox>
</Field>
</template>Add a hint under the label
A Description placed between the label and the listbox explains where the value is used or who sees it:
Printed on new invoices. Each customer can override it.
<Field>
<Label>Payment terms</Label>
<Description>Printed on new invoices. Each customer can override it.</Description>
<Listbox value={term} onChange={setTerm}>
{/* ... */}
</Listbox>
</Field><Field>
<Label>Payment terms</Label>
<Description>Printed on new invoices. Each customer can override it.</Description>
<Listbox v-model="term">
<!-- ... -->
</Listbox>
</Field>Ask for a first choice
Start with an empty value and a placeholder when no option is a safe default and the user has to decide:
let [term, setTerm] = useState(null)
<Listbox value={term} onChange={setTerm} placeholder="Choose terms…">
{/* ... */}
</Listbox><script setup lang="ts">
import { ref } from 'vue'
const term = ref<string | null>(null)
</script>
<template>
<Listbox v-model="term" placeholder="Choose terms…">
<!-- ... -->
</Listbox>
</template>Use the selection elsewhere
Hold the value in state with value and onChange to update a summary or a preview as the choice changes:
Hold the value in a ref with v-model to update a summary or a preview as the choice changes:
INV-2043 will use Net 30.
'use client'
import { useState } from 'react'
import { Field, Label } from '@/components/fieldset'
import { Listbox, ListboxLabel, ListboxOption } from '@/components/listbox'
let terms = ['Due on receipt', 'Net 15', 'Net 30', 'Net 60']
export default function Example() {
let [term, setTerm] = useState('Net 30')
return (
<>
<Field>
<Label>Payment terms</Label>
<Listbox value={term} onChange={setTerm}>
{terms.map((option) => (
<ListboxOption key={option} value={option}>
<ListboxLabel>{option}</ListboxLabel>
</ListboxOption>
))}
</Listbox>
</Field>
<p>INV-2043 will use {term}.</p>
</>
)
}<script setup lang="ts">
import Field from '@/components/field.vue'
import Label from '@/components/label.vue'
import Listbox from '@/components/listbox.vue'
import ListboxLabel from '@/components/listbox-label.vue'
import ListboxOption from '@/components/listbox-option.vue'
import { ref } from 'vue'
const terms = ['Due on receipt', 'Net 15', 'Net 30', 'Net 60']
const term = ref('Net 30')
</script>
<template>
<Field>
<Label>Payment terms</Label>
<Listbox v-model="term">
<ListboxOption v-for="option in terms" :key="option" :value="option">
<ListboxLabel>{{ option }}</ListboxLabel>
</ListboxOption>
</Listbox>
</Field>
<p>INV-2043 will use {{ term }}.</p>
</template>Option content
Anything placed before the ListboxLabel lines up at the start of the option. Heroicons at 16×16 fit as they are; for another icon set, add data-slot="icon" so it gets the same size and color.
Show who is assigned
An Avatar before the name makes a list of teammates quicker to scan. With object values, pass by so the listbox matches options on a stable key:
<Field>
<Label>Account owner</Label>
<Listbox value={user} onChange={setUser} by="id">
{users.map((user) => (
<ListboxOption key={user.id} value={user}>
<Avatar src={user.avatarUrl} initials={user.initials} className="bg-purple-500 text-white" alt="" />
<ListboxLabel>{user.name}</ListboxLabel>
</ListboxOption>
))}
</Listbox>
</Field><script setup lang="ts">
import Avatar from '@/components/avatar.vue'
import Field from '@/components/field.vue'
import Label from '@/components/label.vue'
import Listbox from '@/components/listbox.vue'
import ListboxLabel from '@/components/listbox-label.vue'
import ListboxOption from '@/components/listbox-option.vue'
import { ref } from 'vue'
const props = defineProps<{ users: { id: number; name: string; avatarUrl?: string; initials?: string }[] }>()
const user = ref(props.users[0])
</script>
<template>
<Field>
<Label>Account owner</Label>
<Listbox v-model="user" by="id">
<ListboxOption v-for="u in users" :key="u.id" :value="u">
<Avatar :src="u.avatarUrl" :initials="u.initials" class="bg-purple-500 text-white" alt="" />
<ListboxLabel>{{ u.name }}</ListboxLabel>
</ListboxOption>
</Listbox>
</Field>
</template>Mark each option with an icon
Icons help when the options are types or channels people recognize at a glance:
import { BellIcon, DevicePhoneMobileIcon, EnvelopeIcon } from '@heroicons/react/16/solid'
<Field>
<Label>Alert channel</Label>
<Listbox value={channel} onChange={setChannel}>
<ListboxOption value="email">
<EnvelopeIcon />
<ListboxLabel>Email</ListboxLabel>
</ListboxOption>
<ListboxOption value="sms">
<DevicePhoneMobileIcon />
<ListboxLabel>SMS</ListboxLabel>
</ListboxOption>
<ListboxOption value="in_app">
<BellIcon />
<ListboxLabel>In-app</ListboxLabel>
</ListboxOption>
</Listbox>
</Field><script setup lang="ts">
import Field from '@/components/field.vue'
import Label from '@/components/label.vue'
import Listbox from '@/components/listbox.vue'
import ListboxLabel from '@/components/listbox-label.vue'
import ListboxOption from '@/components/listbox-option.vue'
import { BellIcon, DevicePhoneMobileIcon, EnvelopeIcon } from '@heroicons/vue/16/solid'
import { ref } from 'vue'
const channel = ref('email')
</script>
<template>
<Field>
<Label>Alert channel</Label>
<Listbox v-model="channel">
<ListboxOption value="email">
<EnvelopeIcon />
<ListboxLabel>Email</ListboxLabel>
</ListboxOption>
<ListboxOption value="sms">
<DevicePhoneMobileIcon />
<ListboxLabel>SMS</ListboxLabel>
</ListboxOption>
<ListboxOption value="in_app">
<BellIcon />
<ListboxLabel>In-app</ListboxLabel>
</ListboxOption>
</Listbox>
</Field>
</template>Show country flags
A flag is an image like any other. Leave its alt empty since the country name already follows it:
<Field>
<Label>Billing country</Label>
<Listbox value={country} onChange={setCountry}>
{countries.map((country) => (
<ListboxOption key={country.code} value={country.code}>
<img className="w-5 sm:w-4" src={country.flag} alt="" />
<ListboxLabel>{country.name}</ListboxLabel>
</ListboxOption>
))}
</Listbox>
</Field><Field>
<Label>Billing country</Label>
<Listbox v-model="country">
<ListboxOption v-for="c in countries" :key="c.code" :value="c.code">
<img class="w-5 sm:w-4" :src="c.flag" alt="" />
<ListboxLabel>{{ c.name }}</ListboxLabel>
</ListboxOption>
</Listbox>
</Field>Add a detail next to the name
ListboxDescription sits after the label in a muted color. Use it for the detail that tells close options apart, such as a price:
let plans = [
{ id: 'starter', name: 'Starter', price: '$29 / month' },
{ id: 'growth', name: 'Growth', price: '$99 / month' },
{ id: 'scale', name: 'Scale', price: '$399 / month' },
]
<Field>
<Label>Plan</Label>
<Listbox value={plan} onChange={setPlan} by="id">
{plans.map((plan) => (
<ListboxOption key={plan.id} value={plan}>
<ListboxLabel>{plan.name}</ListboxLabel>
<ListboxDescription>{plan.price}</ListboxDescription>
</ListboxOption>
))}
</Listbox>
</Field><Field>
<Label>Plan</Label>
<Listbox v-model="plan" by="id">
<ListboxOption v-for="p in plans" :key="p.id" :value="p">
<ListboxLabel>{{ p.name }}</ListboxLabel>
<ListboxDescription>{{ p.price }}</ListboxDescription>
</ListboxOption>
</Listbox>
</Field>States
Lock the value
disabled on the Field blocks the listbox and dims its label and description. The value stays visible, which is what people need when it can no longer change:
Locked after the first payout.
<Field disabled>
<Label>Settlement currency</Label>
<Description>Locked after the first payout.</Description>
<Listbox value={currency} onChange={setCurrency}>
{/* ... */}
</Listbox>
</Field><Field disabled>
<Label>Settlement currency</Label>
<Description>Locked after the first payout.</Description>
<Listbox v-model="currency">
<!-- ... -->
</Listbox>
</Field>Without a Field, put disabled on the Listbox itself.
Keep an option out of reach
disabled on a ListboxOption leaves it in the menu, dimmed, but it cannot be picked. A short ListboxDescription can say why:
<Listbox value={term} onChange={setTerm}>
{/* ... */}
<ListboxOption value="Net 60" disabled>
<ListboxLabel>Net 60</ListboxLabel>
<ListboxDescription>Scale plan</ListboxDescription>
</ListboxOption>
</Listbox><Listbox v-model="term">
<!-- ... -->
<ListboxOption value="Net 60" disabled>
<ListboxLabel>Net 60</ListboxLabel>
<ListboxDescription>Scale plan</ListboxDescription>
</ListboxOption>
</Listbox>Flag a missing choice
Set invalid to turn the border red and add an ErrorMessage under the listbox that says what to do next:
Choose payment terms before sending INV-2043.
<Field>
<Label>Payment terms</Label>
<Listbox value={term} onChange={setTerm} invalid placeholder="Choose terms…">
{/* ... */}
</Listbox>
<ErrorMessage>Choose payment terms before sending INV-2043.</ErrorMessage>
</Field><Field>
<Label>Payment terms</Label>
<Listbox v-model="term" invalid placeholder="Choose terms…">
<!-- ... -->
</Listbox>
<ErrorMessage>Choose payment terms before sending INV-2043.</ErrorMessage>
</Field>Layout
Size the button to its values
The listbox fills its container. When every value is short, a max width keeps the button from stretching across the form:
<Field>
<Label>Log retention</Label>
<Listbox value={period} onChange={setPeriod} className="max-w-40">
{/* ... */}
</Listbox>
</Field><Field>
<Label>Log retention</Label>
<Listbox v-model="period" class="max-w-40">
<!-- ... -->
</Listbox>
</Field>Classes passed through classNameclass land on the button next to its own styles. Stick to layout utilities like width and margin so they do not fight the built-in look.
Place the label beside the control
In a toolbar, the text and the listbox often share one line. Render the text yourself and name the listbox with aria-label, since nothing links the two:
<div className="flex items-baseline gap-6">
<span className="text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white">Sort invoices</span>
<Listbox value={sort} onChange={setSort} aria-label="Sort invoices" className="max-w-48">
{/* ... */}
</Listbox>
</div><div class="flex items-baseline gap-6">
<span class="text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white">Sort invoices</span>
<Listbox v-model="sort" aria-label="Sort invoices" class="max-w-48">
<!-- ... -->
</Listbox>
</div>