Skip to content
  1. Selection
  2. Multi-select

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} &ldquo;{candidate}&rdquo;
            </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 }} &ldquo;{{ candidate }}&rdquo;
          </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

PropDefaultDescription
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.
searchablefalseAdd a search box above the list.
creatablefalseAllow 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.
PropDefaultDescription
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.
searchablefalseAdd a search box above the list.
creatablefalseAllow 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>

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>