Skip to contentVibraUI
Inputs & filters

Multi select

A select that holds several values at once, showing them as chips over a searchable list.

Controlled only; the search text is the one thing it keeps to itself. The X on a trigger chip is a span rather than a button — a button nested inside the trigger would be closed early by the HTML parser and hydration would then disagree — and it is aria-hidden, so it is a pointer shortcut only. There are two keyboard paths to the same result: Enter on an already-checked option in the list, and Backspace on an empty search, which takes the last chip back off the way it works in an email To field. cmdk owns aria-selected for the highlighted row, so whether an option is chosen is its own aria-checked. The Create row is only offered when creatable is paired with an onCreate to receive it. Options carrying a group land under a heading, in the order the groups first appear.

Install

npx shadcn@latest add @vibra/multi-select

Needs the @vibra registry in your components.json — set it up once.

Examples

Creatable, with overflow

Adding a label that does not exist yet, with the chips collapsing past two.

Props

PropTypeDefaultDescription
options{ value: string; label: string; icon?: React.ReactNode; disabled?: boolean; group?: string }[]—The choices; a group puts the option under a heading.
valuestring[]—The values currently chosen, in chip order.
onValueChange(value: string[]) => void—Called with the whole new selection.
placeholderstring"Select options"Stands in for the chips while nothing is chosen.
searchPlaceholderstring"Search"Placeholder for the search field inside the popover.
maxDisplaynumber3How many chips fit on the trigger before the rest collapse into "+N".
creatablebooleanfalseOffers to add whatever was typed when no label matches it; needs onCreate to do anything.
onCreate(label: string) => void—Called with the typed text; add the option and select it yourself.
disabledbooleanfalseDims the trigger and stops it opening.
size"sm" | "default""default"sm drops the trigger to h-7 for dense toolbars.
emptyMessageReact.ReactNode"No results found."Shown when the search matches nothing.
aria-labelstring—Names the trigger, e.g. Teams; the chips alone read as a list of values.
classNamestring—Merged onto the trigger, which is the root; the remaining button props are spread onto it too.

Dependencies

Source

components/ui/multi-select.tsx
"use client"

import * as React from "react"
import { ChevronDownIcon, XIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"

export type MultiSelectOption = {
  value: string
  label: string
  /** Sits before the label in the list; sized to 4 unless it sets its own size. */
  icon?: React.ReactNode
  disabled?: boolean
  /** Puts the option under a heading; ungrouped options come first. */
  group?: string
}

// The trigger button is the root: className and the rest of the props land on
// it. The names below are re-declared because the item owns them — `value` is
// the chosen ids, and size is the item's density axis.
export type MultiSelectProps = Omit<
  React.ComponentProps<"button">,
  "value" | "size" | "disabled" | "aria-label" | "children" | "type"
> & {
  options: MultiSelectOption[]
  value: string[]
  onValueChange: (value: string[]) => void
  placeholder?: string
  searchPlaceholder?: string
  /** How many chips fit on the trigger before the rest collapse into "+N". */
  maxDisplay?: number
  /** Offers to add whatever was typed when nothing matches it. */
  creatable?: boolean
  onCreate?: (label: string) => void
  disabled?: boolean
  size?: "sm" | "default"
  className?: string
  emptyMessage?: React.ReactNode
  /** Names the trigger, e.g. "Teams"; the chips alone read as a list of values. */
  "aria-label"?: string
}

/** A select that holds several values at once, showing them as chips over a searchable list. */
function MultiSelect({
  options,
  value,
  onValueChange,
  placeholder = "Select options",
  searchPlaceholder = "Search",
  maxDisplay = 3,
  creatable = false,
  onCreate,
  disabled = false,
  size = "default",
  className,
  emptyMessage = "No results found.",
  "aria-label": ariaLabel,
  ...props
}: MultiSelectProps) {
  const [open, setOpen] = React.useState(false)
  const [search, setSearch] = React.useState("")

  const selected = new Set(value)
  const byValue = new Map(options.map((option) => [option.value, option]))
  const chosen = value.map((item) => byValue.get(item)?.label ?? item)
  const shown = chosen.slice(0, maxDisplay)
  const overflow = chosen.length - shown.length

  // Groups keep the order they first appear in, and ungrouped options lead.
  const groups: { name: string | undefined; options: MultiSelectOption[] }[] = []
  for (const option of options) {
    const group = groups.find((entry) => entry.name === option.group)
    if (group) group.options.push(option)
    else groups.push({ name: option.group, options: [option] })
  }

  const query = search.trim()
  // No onCreate means nothing can come of the row, so it is not offered.
  const canCreate =
    creatable &&
    onCreate !== undefined &&
    query.length > 0 &&
    !options.some((option) => option.label.toLowerCase() === query.toLowerCase())

  function toggle(optionValue: string) {
    onValueChange(
      selected.has(optionValue)
        ? value.filter((item) => item !== optionValue)
        : [...value, optionValue]
    )
  }

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger
        render={
          <button
            type="button"
            data-slot="multi-select"
            data-size={size}
            data-count={value.length}
            disabled={disabled}
            aria-label={ariaLabel}
            className={cn(
              "flex w-full min-w-0 items-center gap-1.5 overflow-hidden rounded-lg border border-input bg-transparent px-2.5 text-sm transition-colors focus-ring disabled:pointer-events-none disabled:opacity-50 dark:bg-input/30 dark:hover:bg-input/50",
              size === "sm" ? "h-7" : "h-8",
              className
            )}
            {...props}
          >
            {value.length === 0 ? (
              <span className="truncate text-muted-foreground">{placeholder}</span>
            ) : (
              <span className="flex min-w-0 items-center gap-1">
                {shown.map((label, index) => (
                  <Badge
                    key={value[index]}
                    variant="secondary"
                    className="max-w-28 gap-0.5 rounded-sm pe-0.5 font-normal"
                  >
                    <span className="truncate">{label}</span>
                    {/* A span, not a button: a <button> here would be nested
                        inside the trigger, which the HTML parser closes early
                        and hydration then disagrees about. The keyboard path to
                        the same result is the list itself, or Backspace in an
                        empty search. */}
                    <span
                      data-slot="multi-select-chip-remove"
                      aria-hidden="true"
                      className="inline-flex size-3.5 shrink-0 items-center justify-center rounded-[3px] text-muted-foreground transition-colors hover:bg-foreground/10 hover:text-foreground"
                      onPointerDown={(event) => event.stopPropagation()}
                      onClick={(event) => {
                        event.preventDefault()
                        event.stopPropagation()
                        toggle(value[index])
                      }}
                    >
                      <XIcon className="size-3!" />
                    </span>
                  </Badge>
                ))}
                {overflow > 0 ? (
                  <Badge variant="secondary" className="rounded-sm font-normal tabular-nums">
                    +{overflow}
                  </Badge>
                ) : null}
              </span>
            )}
            <ChevronDownIcon className="ms-auto size-4 shrink-0 text-muted-foreground" />
          </button>
        }
      />

      {/* COUPLED TO registry/vibra/ui/popover.tsx: the popover ships a padded
          w-72 panel; this one matches the trigger and lets the list pad itself. */}
      <PopoverContent align="start" className="w-(--anchor-width) min-w-52 gap-0 p-0">
        <Command>
          <CommandInput
            value={search}
            onValueChange={setSearch}
            placeholder={searchPlaceholder}
            onKeyDown={(event) => {
              // Backspace on an empty search takes the last chip back off,
              // the way it works in an email "To" field.
              if (event.key === "Backspace" && search === "" && value.length > 0) {
                event.preventDefault()
                onValueChange(value.slice(0, -1))
              }
            }}
          />
          <CommandList aria-multiselectable="true">
            {canCreate ? null : <CommandEmpty>{emptyMessage}</CommandEmpty>}
            {groups.map((group) => (
              <CommandGroup key={group.name ?? "__ungrouped"} heading={group.name}>
                {group.options.map((option) => {
                  const isSelected = selected.has(option.value)
                  return (
                    <CommandItem
                      key={option.value}
                      value={option.label}
                      disabled={option.disabled}
                      onSelect={() => toggle(option.value)}
                      // cmdk's aria-selected marks the highlighted row; whether
                      // an option is chosen is its own attribute.
                      aria-checked={isSelected}
                      data-checked={isSelected ? "true" : undefined}
                    >
                      {option.icon ? (
                        <span aria-hidden="true" className="text-muted-foreground">
                          {option.icon}
                        </span>
                      ) : null}
                      <span className="truncate">{option.label}</span>
                    </CommandItem>
                  )
                })}
              </CommandGroup>
            ))}
            {canCreate ? (
              <CommandGroup forceMount>
                <CommandItem
                  forceMount
                  value={`__create__${query}`}
                  onSelect={() => {
                    onCreate(query)
                    setSearch("")
                  }}
                >
                  <span className="truncate">
                    Create &quot;<span className="font-medium">{query}</span>&quot;
                  </span>
                </CommandItem>
              </CommandGroup>
            ) : null}
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  )
}

export { MultiSelect }