Multi-select
A checkable list in a popover, for picking several values without free-text input.
'use client'
import { MultiSelect } from '@/components/multi-select'
import { useState } from 'react'
const statusOptions = [
{ value: 'succeeded', label: 'Succeeded' },
{ value: 'pending', label: 'Pending' },
{ value: 'refunded', label: 'Refunded' },
{ value: 'failed', label: 'Failed' },
{ value: 'disputed', label: 'Disputed' },
]
export default function Example() {
let [statuses, setStatuses] = useState<string[]>([])
return (
<MultiSelect
value={statuses}
onChange={setStatuses}
options={statusOptions}
placeholder="All statuses"
/>
)
}<script setup lang="ts">
import MultiSelect from '@/components/multi-select.vue'
import { ref } from 'vue'
const statuses = ref<string[]>([])
const statusOptions = [
{ value: 'succeeded', label: 'Succeeded' },
{ value: 'pending', label: 'Pending' },
{ value: 'refunded', label: 'Refunded' },
{ value: 'failed', label: 'Failed' },
{ value: 'disputed', label: 'Disputed' },
]
</script>
<template>
<MultiSelect v-model="statuses" :options="statusOptions" placeholder="All statuses" />
</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 { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
import { ChevronUpDownIcon, MagnifyingGlassIcon, PlusIcon } from '@heroicons/react/16/solid'
import clsx from 'clsx'
import type React from 'react'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { Checkbox } from './checkbox'
export type MultiSelectOption = { value: string; label: string }
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 control = ['relative block w-full', controlBackground, controlFocus]
function normalize(value: string) {
return value
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/gu, '')
}
function useFieldLabel(control: React.RefObject<HTMLElement | null>) {
let [field, setField] = useState<{ id?: string; labelId?: string; descriptionId?: string }>({})
useEffect(() => {
let parent = control.current?.closest('[data-slot=control]')?.parentElement
let label = parent?.querySelector<HTMLLabelElement>(':scope > [data-slot=label]')
let description = parent?.querySelector<HTMLElement>(':scope > [data-slot=description]')
setField({ id: label?.htmlFor || undefined, labelId: label?.id, descriptionId: description?.id })
}, [control])
return field
}
export function MultiSelect({
value,
onChange,
options,
placeholder,
searchable = false,
creatable = false,
createLabel = 'Create',
transform,
className,
...props
}: {
value: string[]
onChange: (value: string[]) => void
options: MultiSelectOption[]
placeholder: string
searchable?: boolean
creatable?: boolean
createLabel?: string
transform?: (value: string) => string
} & Omit<React.ComponentPropsWithoutRef<'div'>, 'onChange' | 'children'>) {
let [query, setQuery] = useState('')
let [created, setCreated] = useState<string[]>([])
let button = useRef<HTMLButtonElement>(null)
let field = useFieldLabel(button)
let valueId = useId()
let optionRefs = useRef<(HTMLButtonElement | null)[]>([])
let [width, setWidth] = useState<number>()
useEffect(() => {
let element = button.current
if (!element) return
let observer = new ResizeObserver(() => setWidth(element.getBoundingClientRect().width))
observer.observe(element)
return () => observer.disconnect()
}, [])
let allOptions = useMemo(() => {
let known = new Set(options.map((option) => option.value))
let extra = [...created, ...value].filter((item) => !known.has(item)).map((item) => ({ value: item, label: item }))
let seen = new Set<string>()
return [...options, ...extra].filter((option) => {
if (seen.has(option.value)) return false
seen.add(option.value)
return true
})
}, [options, created, value])
let visibleOptions = useMemo(() => {
if (!searchable && !creatable) return allOptions
let normalized = normalize(query.trim())
if (normalized === '') return allOptions
return allOptions.filter(
(option) => normalize(option.label).includes(normalized) || normalize(option.value).includes(normalized)
)
}, [allOptions, query, searchable, creatable])
let candidate = useMemo(() => {
if (!creatable) return ''
let trimmed = query.trim()
if (trimmed === '') return ''
let normalized = transform ? transform(trimmed) : trimmed
return allOptions.some((option) => option.value === normalized) ? '' : normalized
}, [creatable, query, transform, allOptions])
let display = useMemo(() => {
if (value.length === 0) return placeholder
let labels = value.map((item) => allOptions.find((option) => option.value === item)?.label ?? item)
return labels.length <= 2 ? labels.join(', ') : `${labels.length} selected`
}, [value, allOptions, placeholder])
function toggle(item: string) {
onChange(value.includes(item) ? value.filter((current) => current !== item) : [...value, item])
}
function moveFocus(index: number) {
let options = optionRefs.current.filter(Boolean)
if (options.length === 0) return
options[Math.min(Math.max(index, 0), options.length - 1)]?.focus()
}
function create() {
if (!candidate) return
setCreated((current) => [...current, candidate])
onChange([...value, candidate])
setQuery('')
}
return (
<div
data-slot="control"
{...props}
className={clsx(className, control)}
>
<Popover>
<PopoverButton
ref={button}
id={field.id}
aria-haspopup="listbox"
aria-labelledby={field.labelId ? `${field.labelId} ${valueId}` : undefined}
aria-describedby={field.descriptionId}
className="relative flex w-full cursor-pointer items-center gap-2 rounded-lg border border-zinc-950/10 bg-transparent py-[calc(--spacing(2.5)-1px)] pr-[calc(--spacing(2)-1px)] pl-[calc(--spacing(3.5)-1px)] text-left text-base/6 text-zinc-950 hover:border-zinc-950/20 focus:outline-hidden sm:py-[calc(--spacing(1.5)-1px)] sm:pl-[calc(--spacing(3)-1px)] sm:text-sm/6 dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:border-white/20"
>
<span id={valueId} className={clsx('min-w-0 flex-1 truncate', value.length === 0 && 'text-zinc-500')}>
{display}
</span>
<ChevronUpDownIcon className="size-5 shrink-0 text-zinc-500 sm:size-4 dark:text-zinc-400 forced-colors:text-[CanvasText]" />
</PopoverButton>
<PopoverPanel
anchor={{ to: 'bottom start', gap: 8, padding: 8 }}
style={{ width }}
className="z-50 min-w-52 rounded-xl border border-zinc-950/10 bg-white p-1 shadow-lg outline-none dark:border-white/10 dark:bg-zinc-800"
>
{(searchable || creatable) && (
<div className="flex items-center gap-2 border-b border-zinc-950/5 px-2.5 py-2 dark:border-white/5">
<MagnifyingGlassIcon className="size-4 shrink-0 fill-zinc-500 dark:fill-zinc-400" />
<input
type="search"
value={query}
aria-label={creatable ? 'Search or create an option' : 'Search options'}
placeholder={creatable ? 'Search or create…' : 'Search…'}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
create()
} else if (event.key === 'ArrowDown') {
event.preventDefault()
moveFocus(0)
}
}}
className="h-7 w-full min-w-0 bg-transparent text-base/6 text-zinc-950 outline-none placeholder:text-zinc-500 sm:text-sm/6 dark:text-white"
/>
</div>
)}
<div role="listbox" aria-multiselectable="true" aria-label={placeholder} className="max-h-64 overflow-y-auto">
{visibleOptions.map((option, index) => (
<button
key={option.value}
ref={(element) => {
optionRefs.current[index] = element
}}
type="button"
role="option"
aria-selected={value.includes(option.value)}
onClick={() => toggle(option.value)}
onKeyDown={(event) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
moveFocus(index + 1)
} else if (event.key === 'ArrowUp') {
event.preventDefault()
moveFocus(index - 1)
}
}}
className="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-left hover:bg-zinc-950/5 focus:outline-hidden focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent-500 sm:py-1.5 dark:hover:bg-white/5"
>
<Checkbox
className="pointer-events-none"
checked={value.includes(option.value)}
onChange={() => toggle(option.value)}
tabIndex={-1}
aria-hidden="true"
/>
<span className="min-w-0 flex-1 truncate text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white">
{option.label}
</span>
</button>
))}
{visibleOptions.length === 0 && !candidate && (
<p className="px-2.5 py-4 text-center text-zinc-500 dark:text-zinc-400 text-base/6 sm:text-sm/6">
Nothing found.
</p>
)}
</div>
{candidate && (
<button
type="button"
onClick={create}
className="flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 text-left text-base/6 text-zinc-950 hover:bg-zinc-950/5 sm:py-1.5 sm:text-sm/6 dark:text-white dark:hover:bg-white/5"
>
<PlusIcon className="size-4 shrink-0 fill-zinc-500" />
{createLabel} “{candidate}”
</button>
)}
{value.length > 0 && (
<div className="mt-1 border-t border-zinc-950/5 pt-1 dark:border-white/5">
<button
type="button"
onClick={() => onChange([])}
className="w-full cursor-pointer rounded-lg px-2.5 py-2 text-left text-base/6 text-zinc-500 hover:bg-zinc-950/5 hover:text-zinc-950 sm:py-1.5 sm:text-sm/6 dark:text-zinc-400 dark:hover:bg-white/5 dark:hover:text-white"
>
Clear selection
</button>
</div>
)}
</PopoverPanel>
</Popover>
</div>
)
}Also copy Checkbox.
<script setup lang="ts">
import { ChevronUpDownIcon, MagnifyingGlassIcon, PlusIcon } from '@heroicons/vue/16/solid'
import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from 'reka-ui'
import { computed, ref, useAttrs, useId } from 'vue'
import Checkbox from './checkbox.vue'
import { useFieldControl } from './fields'
defineOptions({ inheritAttrs: false })
export type MultiSelectOption = { value: string; label: string }
const props = withDefaults(
defineProps<{
placeholder: string
options: MultiSelectOption[]
searchable?: boolean
creatable?: boolean
createLabel?: string
transform?: (value: string) => string
}>(),
{ searchable: false, creatable: false, createLabel: 'Create' },
)
const model = defineModel<string[]>({ default: () => [] })
const attrs = useAttrs()
const { field, id, describedBy } = useFieldControl(attrs)
const valueId = useId()
const triggerAttrs = computed(() => {
const { class: _class, ...rest } = attrs
return rest
})
const labelledBy = computed(
() => (attrs['aria-labelledby'] as string | undefined) ?? (field?.labelledBy.value ? `${field.labelledBy.value} ${valueId}` : undefined)
)
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 control = ['relative block w-full', controlBackground, controlFocus]
const query = ref('')
const created = ref<string[]>([])
function normalize(value: string): string {
return value
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/gu, '')
}
const allOptions = computed<MultiSelectOption[]>(() => {
const known = new Set(props.options.map((option) => option.value))
const extra = [...created.value, ...model.value]
.filter((value) => !known.has(value))
.map((value) => ({ value, label: value }))
const seen = new Set<string>()
return [...props.options, ...extra].filter((option) => {
if (seen.has(option.value)) return false
seen.add(option.value)
return true
})
})
const visibleOptions = computed(() => {
if (!props.searchable && !props.creatable) return allOptions.value
const normalized = normalize(query.value.trim())
if (normalized === '') return allOptions.value
return allOptions.value.filter(
(option) => normalize(option.label).includes(normalized) || normalize(option.value).includes(normalized),
)
})
const candidate = computed(() => {
if (!props.creatable) return ''
const value = query.value.trim()
if (value === '') return ''
const normalized = props.transform ? props.transform(value) : value
return allOptions.value.some((option) => option.value === normalized) ? '' : normalized
})
const display = computed(() => {
if (model.value.length === 0) return props.placeholder
const labels = model.value.map(
(value) => allOptions.value.find((option) => option.value === value)?.label ?? value,
)
return labels.length <= 2 ? labels.join(', ') : `${labels.length} selected`
})
function toggle(value: string): void {
model.value = model.value.includes(value)
? model.value.filter((item) => item !== value)
: [...model.value, value]
}
const optionRefs: (HTMLButtonElement | null)[] = []
function moveFocus(index: number): void {
const options = optionRefs.filter(Boolean)
if (options.length === 0) return
options[Math.min(Math.max(index, 0), options.length - 1)]?.focus()
}
function create(): void {
if (!candidate.value) return
created.value = [...created.value, candidate.value]
model.value = [...model.value, candidate.value]
query.value = ''
}
</script>
<template>
<span
data-slot="control"
:class="[attrs.class, control]"
>
<PopoverRoot>
<PopoverTrigger
v-bind="triggerAttrs"
type="button"
:id="id"
aria-haspopup="listbox"
:aria-labelledby="labelledBy"
:aria-describedby="describedBy"
class="relative flex w-full cursor-pointer items-center gap-2 rounded-lg border border-zinc-950/10 bg-transparent py-[calc(--spacing(2.5)-1px)] pr-[calc(--spacing(2)-1px)] pl-[calc(--spacing(3.5)-1px)] text-left text-base/6 text-zinc-950 hover:border-zinc-950/20 focus:outline-hidden sm:py-[calc(--spacing(1.5)-1px)] sm:pl-[calc(--spacing(3)-1px)] sm:text-sm/6 dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:border-white/20"
>
<span :id="valueId" class="min-w-0 flex-1 truncate" :class="model.length ? '' : 'text-zinc-500'">
{{ display }}
</span>
<ChevronUpDownIcon class="size-5 shrink-0 text-zinc-500 sm:size-4 dark:text-zinc-400 forced-colors:text-[CanvasText]" />
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
align="start"
:side-offset="8"
:collision-padding="8"
class="z-50 w-(--reka-popover-trigger-width) min-w-52 rounded-xl border border-zinc-950/10 bg-white p-1 shadow-lg outline-none dark:border-white/10 dark:bg-zinc-800"
>
<div
v-if="searchable || creatable"
class="flex items-center gap-2 border-b border-zinc-950/5 px-2.5 py-2 dark:border-white/5"
>
<MagnifyingGlassIcon class="size-4 shrink-0 fill-zinc-500 dark:fill-zinc-400" />
<input
v-model="query"
type="search"
:aria-label="creatable ? 'Search or create an option' : 'Search options'"
:placeholder="creatable ? 'Search or create…' : 'Search…'"
class="h-7 w-full min-w-0 bg-transparent text-base/6 text-zinc-950 outline-none placeholder:text-zinc-500 sm:text-sm/6 dark:text-white"
@keydown.enter.prevent="create"
@keydown.down.prevent="moveFocus(0)"
/>
</div>
<div role="listbox" aria-multiselectable="true" :aria-label="placeholder" class="max-h-64 overflow-y-auto">
<button
v-for="(option, index) in visibleOptions"
:key="option.value"
:ref="(element) => (optionRefs[index] = element as HTMLButtonElement | null)"
type="button"
role="option"
:aria-selected="model.includes(option.value)"
class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-left hover:bg-zinc-950/5 focus:outline-hidden focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent-500 sm:py-1.5 dark:hover:bg-white/5"
@click="toggle(option.value)"
@keydown.down.prevent="moveFocus(index + 1)"
@keydown.up.prevent="moveFocus(index - 1)"
>
<Checkbox
class="pointer-events-none"
:model-value="model.includes(option.value)"
:tabindex="-1"
aria-hidden="true"
/>
<span class="min-w-0 flex-1 truncate text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white">
{{ option.label }}
</span>
</button>
<p
v-if="visibleOptions.length === 0 && !candidate"
class="px-2.5 py-4 text-center text-zinc-500 dark:text-zinc-400 text-base/6 sm:text-sm/6"
>
Nothing found.
</p>
</div>
<button
v-if="candidate"
type="button"
class="flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 text-left text-base/6 text-zinc-950 hover:bg-zinc-950/5 sm:py-1.5 sm:text-sm/6 dark:text-white dark:hover:bg-white/5"
@click="create"
>
<PlusIcon class="size-4 shrink-0 fill-zinc-500" />
{{ createLabel }} “{{ candidate }}”
</button>
<div v-if="model.length" class="mt-1 border-t border-zinc-950/5 pt-1 dark:border-white/5">
<button
type="button"
class="w-full cursor-pointer rounded-lg px-2.5 py-2 text-left text-base/6 text-zinc-500 hover:bg-zinc-950/5 hover:text-zinc-950 sm:py-1.5 sm:text-sm/6 dark:text-zinc-400 dark:hover:bg-white/5 dark:hover:text-white"
@click="model = []"
>
Clear selection
</button>
</div>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</span>
</template>Also copy Checkbox.
Component API
| Prop | Default | Description |
|---|---|---|
MultiSelectrenders a <Popover> element | ||
value | - | Array of selected values. |
onChange | - | Called with the new array whenever a value is toggled. |
options | - | Array of `{ value, label }`. |
placeholder | - | Shown when nothing is selected. Inside a `Field`, the `Label` names the button and the selection is read after it. |
searchable | false | Add a search box above the list. |
creatable | false | Allow adding a value that is not in the list. |
createLabel | 'Create' | Wording of the create row. |
transform | - | Normalizes a created value before it is added, lowercasing a tag for example. |
| Prop | Default | Description |
|---|---|---|
MultiSelectbuilt on the Reka UI <PopoverRoot> component | ||
v-model | - | Array of selected values. |
options | - | Array of `{ value, label }`. |
placeholder | - | Shown when nothing is selected. Inside a `Field`, the `Label` names the button and the selection is read after it. |
searchable | false | Add a search box above the list. |
creatable | false | Allow adding a value that is not in the list. |
createLabel | 'Create' | Wording of the create row. |
transform | - | Normalizes a created value before it is added, lowercasing a tag for example. |
Examples
Basic example
The trigger lists the selection up to two values, then falls back to a count. A row at the bottom clears everything:
Selected: —
'use client'
import { MultiSelect } from '@/components/multi-select'
import { useState } from 'react'
const statusOptions = [
{ value: 'succeeded', label: 'Succeeded' },
{ value: 'pending', label: 'Pending' },
{ value: 'refunded', label: 'Refunded' },
{ value: 'failed', label: 'Failed' },
{ value: 'disputed', label: 'Disputed' },
]
export default function Example() {
let [statuses, setStatuses] = useState<string[]>([])
return (
<MultiSelect
value={statuses}
onChange={setStatuses}
options={statusOptions}
placeholder="All statuses"
/>
)
}<script setup lang="ts">
import MultiSelect from '@/components/multi-select.vue'
import { ref } from 'vue'
const statuses = ref<string[]>([])
const statusOptions = [
{ value: 'succeeded', label: 'Succeeded' },
{ value: 'pending', label: 'Pending' },
{ value: 'refunded', label: 'Refunded' },
{ value: 'failed', label: 'Failed' },
{ value: 'disputed', label: 'Disputed' },
]
</script>
<template>
<MultiSelect v-model="statuses" :options="statusOptions" placeholder="All statuses" />
</template>With search
Add searchable for longer lists. The search ignores case and accents, so typing geneve finds Genève:
<MultiSelect value={cities} onChange={setCities} options={cityOptions} placeholder="All cities" searchable /><template>
<MultiSelect v-model="cities" :options="cityOptions" placeholder="All cities" searchable />
</template>Creating values
With creatable, anything typed that matches no option can be added to the selection. Use transform to normalize it first:
<MultiSelect
value={tags}
onChange={setTags}
options={tagOptions}
placeholder="No tag"
creatable
createLabel="Add tag"
transform={(value) => value.toLowerCase().replace(/\s+/g, '-')}
/><template>
<MultiSelect
v-model="tags"
:options="tagOptions"
placeholder="No tag"
creatable
create-label="Add tag"
:transform="(value) => value.toLowerCase().replace(/\s+/g, '-')"
/>
</template>In a form
It sits on the same control base as Input and Select, so it lines up with the rest of a fieldset:
We only email you about the statuses you pick here.
<Field>
<Label>Notify on</Label>
<Description>We only email you about the statuses you pick here.</Description>
<MultiSelect value={statuses} onChange={setStatuses} options={statusOptions} placeholder="Nothing selected" />
</Field><template>
<Field>
<Label>Notify on</Label>
<Description>We only email you about the statuses you pick here.</Description>
<MultiSelect v-model="statuses" :options="statusOptions" placeholder="Nothing selected" />
</Field>
</template>