shadimage

Multi Select

A searchable multi-select: choose several options from a list you define, each shown as a removable badge in the trigger. Reach for it wherever a field stores an array rather than one value: assigning tags, labels, topics or categories to a post, product, ticket or issue; picking assignees, reviewers, attendees, team members or recipients; granting permissions, roles, scopes or groups; filtering a table, dashboard or report by several statuses, owners, channels, regions or vendors; choosing skills, industries, languages, integrations or notification channels; deciding which columns a table shows; and the "applies to" row on a rule, policy, coupon or automation. Common asks it answers: "multi select", "multiselect", "shadcn multi select", "shadcn multiselect combobox", "select multiple react", "multiple select dropdown", "multi select with search", "multi select with badges", "checkbox dropdown", "react-select alternative", "cmdk multi select", "combobox multiple", "assignee picker", "tag select", "accessible multi select", "select several options". Official shadcn/ui has no multi-select of any shape: across all sixty-three of its components the word "multiple" does not appear once, and its select, native-select and combobox are single-value by construction. The two things it has that hold more than one answer are a different tool — checkbox is one control per option, and toggle-group takes Radix's type="multiple" but is a row of always-visible buttons. Both stop working somewhere around a dozen options; this is the control for the list that has to be searched, and it is the gap that sends people to react-select or a hand-rolled cmdk popover. Distinct from pulld's tag-input, and the pair is worth knowing: this picks from a fixed set of options you supply and returns their values, while tag-input lets someone type new free-text tags that did not exist before. Choose by whether an unknown answer is allowed. Built on the select-only combobox pattern rather than a div with click handlers: the trigger is role="combobox" with aria-expanded and aria-haspopup, the panel is an aria-multiselectable listbox, the highlighted row is tracked with aria-activedescendant, chosen rows carry aria-selected with a check, and every add and remove is announced through a polite sr-only live region — the part a hand-rolled version always omits, which leaves a screen-reader user with no confirmation that anything happened. The keyboard is complete: Enter, Space or Down opens; arrows move; Enter toggles; Escape closes; Backspace removes the last badge from the trigger, and again from an empty search box; each badge's × removes just that one and never opens the panel. Type in the built-in search box to filter, or pass hideSearch for a short list and the listbox itself takes focus and the arrow keys. Works controlled (`value` + `onChange`) or uncontrolled (`defaultValue`), always a string[] of option values. `max` caps the selection and disables the unchosen rows at the cap rather than silently ignoring a click; per-option `disabled` blocks one row; `placeholder`, `searchPlaceholder` and `emptyMessage` are yours. Themed with shadcn tokens (secondary badges, accent highlight, popover surface, ring), so it follows light and dark mode. No Radix and no cmdk — one file, lucide-react for the three icons and your own cn util.

registry/ui/multi-select.tsx

"use client"

import * as React from "react"
import { Check, ChevronDown, X } from "lucide-react"

import { cn } from "@/lib/utils"

export interface MultiSelectOption {
  value: string
  label: string
  disabled?: boolean
}

interface MultiSelectProps {
  options: MultiSelectOption[]
  /** Controlled list of selected values. Pair with `onChange` to own the state. */
  value?: string[]
  /** Initial selection when uncontrolled. */
  defaultValue?: string[]
  /** Called with the next selection whenever an option is toggled or removed. */
  onChange?: (values: string[]) => void
  placeholder?: string
  searchPlaceholder?: string
  emptyMessage?: string
  /** Cap the number of selected values; unselected options disable at the cap. */
  max?: number
  /** Hide the search box (useful for short lists). */
  hideSearch?: boolean
  disabled?: boolean
  className?: string
  /** Accessible name for the control (or wire `aria-labelledby` to a form label). */
  "aria-label"?: string
  "aria-labelledby"?: string
}

export function MultiSelect({
  options,
  value,
  defaultValue,
  onChange,
  placeholder = "Select…",
  searchPlaceholder = "Search…",
  emptyMessage = "No options found.",
  max,
  hideSearch = false,
  disabled = false,
  className,
  "aria-label": ariaLabel,
  "aria-labelledby": ariaLabelledby,
}: MultiSelectProps) {
  const isControlled = value !== undefined
  const [internal, setInternal] = React.useState<string[]>(
    () => (isControlled ? value : defaultValue) ?? []
  )
  const selected = isControlled ? (value as string[]) : internal

  const [open, setOpen] = React.useState(false)
  const [query, setQuery] = React.useState("")
  const [active, setActive] = React.useState(0)
  // Visually-hidden message so screen readers hear each toggle.
  const [announce, setAnnounce] = React.useState("")

  const id = React.useId()
  const listboxId = `${id}-listbox`
  const rootRef = React.useRef<HTMLDivElement>(null)
  const triggerRef = React.useRef<HTMLDivElement>(null)
  const searchRef = React.useRef<HTMLInputElement>(null)
  const listRef = React.useRef<HTMLUListElement>(null)

  const atCap = max !== undefined && selected.length >= max

  const filtered = React.useMemo(() => {
    const q = query.trim().toLowerCase()
    if (!q) return options
    return options.filter((o) => o.label.toLowerCase().includes(q))
  }, [options, query])

  const byValue = React.useMemo(() => {
    const m = new Map<string, MultiSelectOption>()
    for (const o of options) m.set(o.value, o)
    return m
  }, [options])

  function commit(next: string[], message: string) {
    if (!isControlled) setInternal(next)
    setAnnounce(message)
    onChange?.(next)
  }

  function toggle(option: MultiSelectOption) {
    if (disabled || option.disabled) return
    if (selected.includes(option.value)) {
      commit(
        selected.filter((v) => v !== option.value),
        `${option.label} deselected`
      )
    } else {
      if (atCap) return
      commit([...selected, option.value], `${option.label} selected`)
    }
  }

  const openPanel = React.useCallback(() => {
    if (disabled) return
    setOpen(true)
    setQuery("")
    setActive(0)
  }, [disabled])

  const closePanel = React.useCallback((refocus: boolean) => {
    setOpen(false)
    if (refocus) triggerRef.current?.focus()
  }, [])

  // Focus the search box when the panel opens (fall back to the list for hideSearch).
  React.useEffect(() => {
    if (!open) return
    const el = hideSearch ? listRef.current : searchRef.current
    const t = window.setTimeout(() => el?.focus(), 0)
    return () => window.clearTimeout(t)
  }, [open, hideSearch])

  // Close on an outside pointer press (capture pointerdown so it beats focus moves).
  React.useEffect(() => {
    if (!open) return
    function onPointerDown(e: PointerEvent) {
      if (!rootRef.current?.contains(e.target as Node)) closePanel(false)
    }
    document.addEventListener("pointerdown", onPointerDown, true)
    return () => document.removeEventListener("pointerdown", onPointerDown, true)
  }, [open, closePanel])

  // Clamp the active row when the filter shrinks the list.
  React.useEffect(() => {
    setActive((a) => Math.min(a, Math.max(0, filtered.length - 1)))
  }, [filtered.length])

  // Keep the active row visible while arrowing through a scrolled list.
  React.useEffect(() => {
    if (!open) return
    listRef.current
      ?.querySelector(`#${CSS.escape(`${id}-opt-${active}`)}`)
      ?.scrollIntoView({ block: "nearest" })
  }, [active, open, id])

  function handleTriggerKeyDown(e: React.KeyboardEvent) {
    if (disabled) return
    if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
      e.preventDefault()
      openPanel()
    } else if (e.key === "Backspace" && selected.length > 0) {
      const last = byValue.get(selected[selected.length - 1])
      commit(selected.slice(0, -1), `${last?.label ?? "option"} deselected`)
    }
  }

  function handlePanelKeyDown(e: React.KeyboardEvent) {
    if (e.key === "ArrowDown") {
      e.preventDefault()
      // Floor at 0 so an empty list can't leave the index negative and dead.
      setActive((a) => Math.max(0, Math.min(a + 1, filtered.length - 1)))
    } else if (e.key === "ArrowUp") {
      e.preventDefault()
      setActive((a) => Math.max(a - 1, 0))
    } else if (e.key === "Enter") {
      e.preventDefault()
      const option = filtered[active]
      if (option) toggle(option)
    } else if (e.key === "Escape") {
      e.preventDefault()
      closePanel(true)
    } else if (e.key === "Tab") {
      closePanel(false)
    } else if (e.key === "Backspace" && query === "" && selected.length > 0) {
      const last = byValue.get(selected[selected.length - 1])
      commit(selected.slice(0, -1), `${last?.label ?? "option"} deselected`)
    }
  }

  return (
    <div ref={rootRef} className={cn("relative", className)}>
      {/* Trigger is a div combobox (not <button>) so the badge remove buttons stay valid HTML. */}
      <div
        ref={triggerRef}
        role="combobox"
        tabIndex={disabled ? -1 : 0}
        aria-expanded={open}
        aria-haspopup="listbox"
        aria-controls={open ? listboxId : undefined}
        aria-label={ariaLabel}
        aria-labelledby={ariaLabelledby}
        aria-disabled={disabled || undefined}
        onClick={() => (open ? closePanel(false) : openPanel())}
        onKeyDown={handleTriggerKeyDown}
        className={cn(
          "flex min-h-9 w-full cursor-pointer flex-wrap items-center gap-1.5 rounded-md border border-input bg-transparent py-1 pl-2 pr-8 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
          disabled && "pointer-events-none opacity-50"
        )}
      >
        {selected.length === 0 && (
          <span className="px-1 text-muted-foreground">{placeholder}</span>
        )}
        {selected.map((v) => {
          const option = byValue.get(v)
          const label = option?.label ?? v
          return (
            <span
              key={v}
              className="inline-flex items-center gap-1 rounded bg-secondary py-0.5 pl-2 pr-1 text-xs font-medium text-secondary-foreground"
            >
              {label}
              <button
                type="button"
                tabIndex={-1}
                disabled={disabled}
                aria-label={`Remove ${label}`}
                onClick={(e) => {
                  e.stopPropagation() // keep a badge removal from toggling the panel
                  commit(
                    selected.filter((s) => s !== v),
                    `${label} deselected`
                  )
                }}
                className="inline-flex h-4 w-4 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none"
              >
                <X className="h-3 w-3" aria-hidden="true" />
              </button>
            </span>
          )
        })}
        <ChevronDown
          className={cn(
            "absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground transition-transform",
            open && "rotate-180"
          )}
          aria-hidden="true"
        />
      </div>

      {open && (
        <div
          onKeyDown={handlePanelKeyDown}
          className="absolute z-50 mt-1 w-full min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md"
        >
          {!hideSearch && (
            <input
              ref={searchRef}
              type="text"
              role="searchbox"
              value={query}
              onChange={(e) => {
                setQuery(e.target.value)
                setActive(0)
              }}
              placeholder={searchPlaceholder}
              aria-label={searchPlaceholder}
              aria-controls={listboxId}
              aria-activedescendant={
                filtered.length > 0 ? `${id}-opt-${active}` : undefined
              }
              className="w-full border-b bg-transparent px-3 py-2 text-sm outline-none placeholder:text-muted-foreground"
            />
          )}
          <ul
            ref={listRef}
            id={listboxId}
            role="listbox"
            aria-multiselectable="true"
            tabIndex={hideSearch ? 0 : -1}
            aria-activedescendant={
              hideSearch && filtered.length > 0 ? `${id}-opt-${active}` : undefined
            }
            className="max-h-60 overflow-y-auto p-1 focus-visible:outline-none"
          >
            {filtered.length === 0 && (
              // Not an option, so keep it out of the listbox's owned children.
              <li
                role="presentation"
                className="px-2 py-4 text-center text-sm text-muted-foreground"
              >
                {emptyMessage}
              </li>
            )}
            {filtered.map((option, index) => {
              const isSelected = selected.includes(option.value)
              const isBlocked = option.disabled || (atCap && !isSelected)
              return (
                <li
                  key={option.value}
                  id={`${id}-opt-${index}`}
                  role="option"
                  aria-selected={isSelected}
                  aria-disabled={isBlocked || undefined}
                  onPointerMove={() => setActive(index)}
                  // Keep focus in the search box so arrow keys still work after a click.
                  onPointerDown={(e) => e.preventDefault()}
                  onClick={() => toggle(option)}
                  className={cn(
                    "flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm",
                    index === active && "bg-accent text-accent-foreground",
                    isBlocked && "cursor-not-allowed opacity-50"
                  )}
                >
                  <span
                    className={cn(
                      "flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border border-primary",
                      isSelected
                        ? "bg-primary text-primary-foreground"
                        : "opacity-50"
                    )}
                    aria-hidden="true"
                  >
                    {isSelected && <Check className="h-3 w-3" />}
                  </span>
                  {option.label}
                </li>
              )
            })}
          </ul>
        </div>
      )}

      <span aria-live="polite" className="sr-only">
        {announce}
      </span>
    </div>
  )
}
Where it goes
testimonialstatslogindata-tablekanbandashboard-shell
Visual style
3ddark-futuristic
Interaction
animated-counterscroll-revealmodaltooltipdropdown
Use cases
saas-landingdashboard

DependenciesBundle weight · light

icons
lucide-react