Select
A native dropdown for picking one value, or several, from a short fixed list.
import { Field, Label } from '@/components/fieldset'
import { Select } from '@/components/select'
export default function Example() {
return (
<Field>
<Label>Billing interval</Label>
<Select name="interval">
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="yearly">Yearly</option>
</Select>
</Field>
)
}<script setup lang="ts">
import { ref } from 'vue'
import Field from '@/components/field.vue'
import Label from '@/components/label.vue'
import Select from '@/components/select.vue'
const interval = ref('monthly')
</script>
<template>
<Field>
<Label>Billing interval</Label>
<Select name="interval" v-model="interval">
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="yearly">Yearly</option>
</Select>
</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.
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
import { ChevronUpDownIcon } from '@heroicons/react/16/solid'
const wrapper = {
layout: 'group relative block w-full',
background: 'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-sm before:bg-white dark:before:hidden',
focus: [
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-inset after:ring-transparent',
'has-data-focus:after:ring-2 has-data-focus:after:ring-accent-500',
],
disabled: 'has-data-disabled:opacity-50 has-data-disabled:before:shadow-none has-data-disabled:before:bg-zinc-950/5',
}
const field = {
layout: 'relative block w-full appearance-none rounded-lg focus:outline-hidden',
padding: 'py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
typography: 'text-base/6 sm:text-sm/6 text-zinc-950 [&_optgroup]:font-semibold',
background: 'bg-transparent',
border: 'border border-zinc-950/10 data-hover:border-zinc-950/20',
invalid: 'data-invalid:border-red-500 data-invalid:data-hover:border-red-500',
disabled: 'data-disabled:opacity-100 data-disabled:border-zinc-950/20',
dark: [
'dark:text-white dark:*:text-white dark:bg-white/5 dark:*:bg-zinc-800 dark:border-white/10 dark:data-hover:border-white/20',
'dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600',
'dark:data-disabled:bg-white/2.5 dark:data-disabled:border-white/15 dark:data-hover:data-disabled:border-white/15',
],
}
const padding = {
single: 'pl-[calc(--spacing(3.5)-1px)] pr-[calc(--spacing(10)-1px)] sm:pl-[calc(--spacing(3)-1px)] sm:pr-[calc(--spacing(9)-1px)]',
multiple: 'sm:px-[calc(--spacing(3)-1px)] px-[calc(--spacing(3.5)-1px)]',
}
const chevron = {
wrapper: 'absolute inset-y-0 right-0 pr-2 flex items-center pointer-events-none',
icon: [
'size-5 sm:size-4 text-zinc-500 dark:text-zinc-400',
'group-has-data-disabled:text-zinc-600 forced-colors:text-[CanvasText]',
],
}
type SelectProps = { className?: string } & Omit<Headless.SelectProps, 'as' | 'className'>
export const Select = forwardRef(function Select(
{ className, multiple, ...props }: SelectProps,
ref: React.ForwardedRef<HTMLSelectElement>
) {
return (
<span data-slot="control" className={clsx(className, Object.values(wrapper))}>
<Headless.Select
ref={ref}
multiple={multiple}
{...props}
className={clsx(Object.values(field), multiple ? padding.multiple : padding.single)}
/>
{!multiple && (
<span className={chevron.wrapper}>
<ChevronUpDownIcon className={clsx(chevron.icon)} />
</span>
)}
</span>
)
})<script setup lang="ts">
import { ChevronUpDownIcon } from '@heroicons/vue/16/solid'
import { computed, getCurrentInstance, useAttrs } from 'vue'
import { useDisabled, useFieldControl } from './fields'
import { vInteractions } from './interactions'
defineOptions({ inheritAttrs: false })
const props = defineProps<{
multiple?: boolean
invalid?: boolean
disabled?: boolean
}>()
const model = defineModel<string | number | string[] | number[]>()
const hasModel = 'onUpdate:modelValue' in (getCurrentInstance()?.vnode.props ?? {})
const attrs = useAttrs()
const fieldControl = useFieldControl(attrs)
const wrapper = {
layout: 'group relative block w-full',
background: 'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-sm before:bg-white dark:before:hidden',
focus: [
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-inset after:ring-transparent',
'has-data-focus:after:ring-2 has-data-focus:after:ring-accent-500',
],
disabled: 'has-data-disabled:opacity-50 has-data-disabled:before:shadow-none has-data-disabled:before:bg-zinc-950/5',
}
const field = {
layout: 'relative block w-full appearance-none rounded-lg focus:outline-hidden',
padding: 'py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
typography: 'text-base/6 sm:text-sm/6 text-zinc-950 [&_optgroup]:font-semibold',
background: 'bg-transparent',
border: 'border border-zinc-950/10 data-hover:border-zinc-950/20',
invalid: 'data-invalid:border-red-500 data-invalid:data-hover:border-red-500',
disabled: 'data-disabled:opacity-100 data-disabled:border-zinc-950/20',
dark: [
'dark:text-white dark:*:text-white dark:bg-white/5 dark:*:bg-zinc-800 dark:border-white/10 dark:data-hover:border-white/20',
'dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600',
'dark:data-disabled:bg-white/2.5 dark:data-disabled:border-white/15 dark:data-hover:data-disabled:border-white/15',
],
}
const padding = {
single: 'pl-[calc(--spacing(3.5)-1px)] pr-[calc(--spacing(10)-1px)] sm:pl-[calc(--spacing(3)-1px)] sm:pr-[calc(--spacing(9)-1px)]',
multiple: 'sm:px-[calc(--spacing(3)-1px)] px-[calc(--spacing(3.5)-1px)]',
}
const chevron = {
wrapper: 'absolute inset-y-0 right-0 pr-2 flex items-center pointer-events-none',
icon: [
'size-5 sm:size-4 text-zinc-500 dark:text-zinc-400',
'group-has-data-disabled:text-zinc-600 forced-colors:text-[CanvasText]',
],
}
const isDisabled = useDisabled(() => props.disabled)
const bindings = computed(() => {
const { class: _class, ...rest } = attrs
return {
...rest,
multiple: props.multiple,
id: fieldControl.id.value,
'aria-describedby': fieldControl.describedBy.value,
disabled: isDisabled.value,
'data-disabled': isDisabled.value ? '' : undefined,
'aria-invalid': props.invalid || undefined,
'data-invalid': props.invalid ? '' : undefined,
class: [Object.values(field), props.multiple ? padding.multiple : padding.single],
}
})
</script>
<template>
<span data-slot="control" :class="[attrs.class, Object.values(wrapper)]">
<select v-if="hasModel" v-model="model" v-interactions v-bind="bindings">
<slot />
</select>
<select v-else v-interactions v-bind="bindings">
<slot />
</select>
<span v-if="!multiple" :class="chevron.wrapper">
<ChevronUpDownIcon :class="chevron.icon" />
</span>
</span>
</template>Component API
| Prop | Default | Description |
|---|---|---|
Selectrenders a <select> element | ||
multiple | false | Shows the options as a list where several can be selected. Hides the chevron. |
invalid | false | Draws the red border and sets aria-invalid. |
disabled | false | Turns the select off when it is not inside a disabled Field. |
Fieldbuilt on the Headless UI <Headless.Field> component | ||
disabled | false | Turns off the control and dims its label and help text. |
Labelbuilt on the Headless UI <Headless.Label> component | ||
| This component does not expose any component-specific props. | ||
Descriptionbuilt on the Headless UI <Headless.Description> component | ||
| This component does not expose any component-specific props. | ||
ErrorMessagebuilt on the Headless UI <Headless.Description> component | ||
| This component does not expose any component-specific props. | ||
| Prop | Default | Description |
|---|---|---|
Selectrenders a <select> element | ||
v-model | - | The selected value, or an array of values with multiple. |
multiple | false | Shows the options as a list where several can be selected. Hides the chevron. |
invalid | false | Draws the red border and sets aria-invalid. |
disabled | false | Turns the select off when it is not inside a disabled Field. |
Fieldrenders a <div> element | ||
disabled | false | Turns off the control and dims its label and help text. |
Labelrenders a <label> element | ||
| This component does not expose any component-specific props. | ||
Descriptionrenders a <p> element | ||
| This component does not expose any component-specific props. | ||
ErrorMessagerenders a <p> element | ||
| This component does not expose any component-specific props. | ||
Usage
The select renders the browser's own menu, so it works with the keyboard, on touch screens and with form submission out of the box. It fits short lists of plain text options that people already know. Once the list needs search, avatars or a second line per option, move to a Listbox or a Combobox.
Pick one of a few options
A Field ties the Label to the Select. Options are plain <option> elements:
import { Field, Label } from '@/components/fieldset'
import { Select } from '@/components/select'
export default function Example() {
return (
<Field>
<Label>Billing interval</Label>
<Select name="interval">
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="yearly">Yearly</option>
</Select>
</Field>
)
}<script setup lang="ts">
import { ref } from 'vue'
import Field from '@/components/field.vue'
import Label from '@/components/label.vue'
import Select from '@/components/select.vue'
const interval = ref('monthly')
</script>
<template>
<Field>
<Label>Billing interval</Label>
<Select name="interval" v-model="interval">
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="yearly">Yearly</option>
</Select>
</Field>
</template>Warn about the effect of a change
When switching a value has consequences people cannot see from the list, say so in a Description before the control:
Only new invoices use it. Invoices already sent keep their currency.
import { Description, Field, Label } from '@/components/fieldset'
import { Select } from '@/components/select'
export default function Example() {
return (
<Field>
<Label>Invoice currency</Label>
<Description>Only new invoices use it. Invoices already sent keep their currency.</Description>
<Select name="currency" defaultValue="usd">
<option value="usd">USD, US dollar</option>
<option value="eur">EUR, Euro</option>
<option value="cad">CAD, Canadian dollar</option>
<option value="xaf">XAF, Central African CFA franc</option>
</Select>
</Field>
)
}<script setup lang="ts">
import { ref } from 'vue'
import Description from '@/components/description.vue'
import Field from '@/components/field.vue'
import Label from '@/components/label.vue'
import Select from '@/components/select.vue'
const currency = ref('usd')
</script>
<template>
<Field>
<Label>Invoice currency</Label>
<Description>Only new invoices use it. Invoices already sent keep their currency.</Description>
<Select name="currency" v-model="currency">
<option value="usd">USD, US dollar</option>
<option value="eur">EUR, Euro</option>
<option value="cad">CAD, Canadian dollar</option>
<option value="xaf">XAF, Central African CFA franc</option>
</Select>
</Field>
</template>Sort a long list into sections
Group options with <optgroup>. Group labels are set in semibold so they read as headings and cannot be picked:
<Field>
<Label>Event to replay</Label>
<Select name="event" defaultValue="invoice.paid">
<optgroup label="Invoices">
<option value="invoice.created">invoice.created</option>
<option value="invoice.paid">invoice.paid</option>
<option value="invoice.voided">invoice.voided</option>
</optgroup>
<optgroup label="Payouts">
<option value="payout.paid">payout.paid</option>
<option value="payout.failed">payout.failed</option>
</optgroup>
<optgroup label="Customers">
<option value="customer.created">customer.created</option>
</optgroup>
</Select>
</Field><Field>
<Label>Event to replay</Label>
<Select name="event" v-model="event">
<optgroup label="Invoices">
<option value="invoice.created">invoice.created</option>
<option value="invoice.paid">invoice.paid</option>
<option value="invoice.voided">invoice.voided</option>
</optgroup>
<optgroup label="Payouts">
<option value="payout.paid">payout.paid</option>
<option value="payout.failed">payout.failed</option>
</optgroup>
<optgroup label="Customers">
<option value="customer.created">customer.created</option>
</optgroup>
</Select>
</Field>Layout
Fit the longest option
The select stretches to its container. When every option is short, cap the width with a class in className:
The select stretches to its container. When every option is short, cap the width with a class in class:
<Field>
<Label>Payment terms</Label>
<Select className="max-w-40" name="terms" defaultValue="net_30">
<option value="receipt">Due on receipt</option>
<option value="net_15">Net 15</option>
<option value="net_30">Net 30</option>
<option value="net_60">Net 60</option>
</Select>
</Field><Field>
<Label>Payment terms</Label>
<Select class="max-w-40" name="terms" v-model="terms">
<option value="receipt">Due on receipt</option>
<option value="net_15">Net 15</option>
<option value="net_30">Net 30</option>
<option value="net_60">Net 60</option>
</Select>
</Field>Keep classNameclass for width and spacing. The component already sets colors, borders and padding, and a competing class does not reliably override them.
Next to its label
Table footers and toolbars often need the label on the same line. Write the <label> yourself and point its htmlFor at the id of the Select:
Table footers and toolbars often need the label on the same line. Write the <label> yourself and point its for at the id of the Select:
import { Select } from '@/components/select'
export default function Example() {
return (
<div className="flex items-center gap-3">
<label htmlFor="per_page" className="text-base/6 text-zinc-950 select-none sm:text-sm/6 dark:text-white">
Rows per page
</label>
<Select id="per_page" name="per_page" className="max-w-24" defaultValue="50">
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</Select>
</div>
)
}<script setup lang="ts">
import { ref } from 'vue'
import Select from '@/components/select.vue'
const perPage = ref('50')
</script>
<template>
<div class="flex items-center gap-3">
<label for="per_page" class="text-base/6 text-zinc-950 select-none sm:text-sm/6 dark:text-white">
Rows per page
</label>
<Select id="per_page" name="per_page" class="max-w-24" v-model="perPage">
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</Select>
</div>
</template>States
Show a setting that cannot change
disabled on the Field turns off the select and dims its label and description. The current value stays visible, which is often all people need:
Chosen when the workspace was created. It cannot be moved.
<Field disabled>
<Label>Data region</Label>
<Description>Chosen when the workspace was created. It cannot be moved.</Description>
<Select name="region">
<option value="eu-central">Europe (Frankfurt)</option>
<option value="us-east">United States (Virginia)</option>
</Select>
</Field><Field disabled>
<Label>Data region</Label>
<Description>Chosen when the workspace was created. It cannot be moved.</Description>
<Select name="region" v-model="region">
<option value="eu-central">Europe (Frankfurt)</option>
<option value="us-east">United States (Virginia)</option>
</Select>
</Field>Without a Field, set disabled on the Select.
Validation
Force a deliberate choice
A preselected first option gets submitted by people who never looked at it. Start on a disabled empty option instead, then flag the Select with invalid and explain in an ErrorMessage if the form comes back without a value:
Pick a plan before sending the invitation.
import { ErrorMessage, Field, Label } from '@/components/fieldset'
import { Select } from '@/components/select'
export default function Example({ errors }) {
return (
<Field>
<Label>Plan</Label>
<Select name="plan" defaultValue="" invalid={errors.has('plan')}>
<option value="" disabled>
Choose a plan…
</option>
<option value="starter">Starter</option>
<option value="growth">Growth</option>
<option value="scale">Scale</option>
</Select>
{errors.has('plan') && <ErrorMessage>{errors.get('plan')}</ErrorMessage>}
</Field>
)
}<script setup lang="ts">
import { ref } from 'vue'
import ErrorMessage from '@/components/error-message.vue'
import Field from '@/components/field.vue'
import Label from '@/components/label.vue'
import Select from '@/components/select.vue'
defineProps<{ errors: Map<string, string> }>()
const plan = ref('')
</script>
<template>
<Field>
<Label>Plan</Label>
<Select name="plan" v-model="plan" :invalid="errors.has('plan')">
<option value="" disabled>Choose a plan…</option>
<option value="starter">Starter</option>
<option value="growth">Growth</option>
<option value="scale">Scale</option>
</Select>
<ErrorMessage v-if="errors.has('plan')">{{ errors.get('plan') }}</ErrorMessage>
</Field>
</template>Interaction
Select several values
multiple turns the control into an open list and drops the chevron. defaultValue or value then takes an array. Say how to pick more than one, since many people do not know the shortcut:
multiple turns the control into an open list and drops the chevron. v-model then holds an array. Say how to pick more than one, since many people do not know the shortcut:
Hold Cmd or Ctrl to pick more than one.
<Field>
<Label>Alert these members</Label>
<Description>Hold Cmd or Ctrl to pick more than one.</Description>
<Select name="members" multiple defaultValue={['ngozi', 'samba']}>
<option value="ngozi">Ngozi Adeyemi</option>
<option value="yao">Yao Kouassi</option>
<option value="grace">Grace Mbemba</option>
<option value="samba">Samba Ndiaye</option>
<option value="emma">Emma Weber</option>
</Select>
</Field><Field>
<Label>Alert these members</Label>
<Description>Hold Cmd or Ctrl to pick more than one.</Description>
<Select name="members" multiple v-model="members">
<option value="ngozi">Ngozi Adeyemi</option>
<option value="yao">Yao Kouassi</option>
<option value="grace">Grace Mbemba</option>
<option value="samba">Samba Ndiaye</option>
<option value="emma">Emma Weber</option>
</Select>
</Field>Update the page as the value changes
Control the value with value and onChange when the rest of the form reacts to it:
Bind the value with v-model when the rest of the form reacts to it:
Your balance is paid out every Monday.
'use client'
import { useState } from 'react'
import { Description, Field, Label } from '@/components/fieldset'
import { Select } from '@/components/select'
const when = { daily: 'every business day', weekly: 'every Monday', monthly: 'on the 1st of each month' }
export default function Example() {
let [schedule, setSchedule] = useState('weekly')
return (
<Field>
<Label>Payout schedule</Label>
<Select name="payout_schedule" value={schedule} onChange={(event) => setSchedule(event.target.value)}>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</Select>
<Description>Your balance is paid out {when[schedule]}.</Description>
</Field>
)
}<script setup lang="ts">
import { ref } from 'vue'
import Description from '@/components/description.vue'
import Field from '@/components/field.vue'
import Label from '@/components/label.vue'
import Select from '@/components/select.vue'
const when = { daily: 'every business day', weekly: 'every Monday', monthly: 'on the 1st of each month' }
const schedule = ref<keyof typeof when>('weekly')
</script>
<template>
<Field>
<Label>Payout schedule</Label>
<Select name="payout_schedule" v-model="schedule">
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</Select>
<Description>Your balance is paid out {{ when[schedule] }}.</Description>
</Field>
</template>