{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "multi-select",
  "title": "Multi Select",
  "description": "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.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/multi-select.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Check, ChevronDown, X } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface MultiSelectOption {\n  value: string\n  label: string\n  disabled?: boolean\n}\n\ninterface MultiSelectProps {\n  options: MultiSelectOption[]\n  /** Controlled list of selected values. Pair with `onChange` to own the state. */\n  value?: string[]\n  /** Initial selection when uncontrolled. */\n  defaultValue?: string[]\n  /** Called with the next selection whenever an option is toggled or removed. */\n  onChange?: (values: string[]) => void\n  placeholder?: string\n  searchPlaceholder?: string\n  emptyMessage?: string\n  /** Cap the number of selected values; unselected options disable at the cap. */\n  max?: number\n  /** Hide the search box (useful for short lists). */\n  hideSearch?: boolean\n  disabled?: boolean\n  className?: string\n  /** Accessible name for the control (or wire `aria-labelledby` to a form label). */\n  \"aria-label\"?: string\n  \"aria-labelledby\"?: string\n}\n\nexport function MultiSelect({\n  options,\n  value,\n  defaultValue,\n  onChange,\n  placeholder = \"Select…\",\n  searchPlaceholder = \"Search…\",\n  emptyMessage = \"No options found.\",\n  max,\n  hideSearch = false,\n  disabled = false,\n  className,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledby,\n}: MultiSelectProps) {\n  const isControlled = value !== undefined\n  const [internal, setInternal] = React.useState<string[]>(\n    () => (isControlled ? value : defaultValue) ?? []\n  )\n  const selected = isControlled ? (value as string[]) : internal\n\n  const [open, setOpen] = React.useState(false)\n  const [query, setQuery] = React.useState(\"\")\n  const [active, setActive] = React.useState(0)\n  // Visually-hidden message so screen readers hear each toggle.\n  const [announce, setAnnounce] = React.useState(\"\")\n\n  const id = React.useId()\n  const listboxId = `${id}-listbox`\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const triggerRef = React.useRef<HTMLDivElement>(null)\n  const searchRef = React.useRef<HTMLInputElement>(null)\n  const listRef = React.useRef<HTMLUListElement>(null)\n\n  const atCap = max !== undefined && selected.length >= max\n\n  const filtered = React.useMemo(() => {\n    const q = query.trim().toLowerCase()\n    if (!q) return options\n    return options.filter((o) => o.label.toLowerCase().includes(q))\n  }, [options, query])\n\n  const byValue = React.useMemo(() => {\n    const m = new Map<string, MultiSelectOption>()\n    for (const o of options) m.set(o.value, o)\n    return m\n  }, [options])\n\n  function commit(next: string[], message: string) {\n    if (!isControlled) setInternal(next)\n    setAnnounce(message)\n    onChange?.(next)\n  }\n\n  function toggle(option: MultiSelectOption) {\n    if (disabled || option.disabled) return\n    if (selected.includes(option.value)) {\n      commit(\n        selected.filter((v) => v !== option.value),\n        `${option.label} deselected`\n      )\n    } else {\n      if (atCap) return\n      commit([...selected, option.value], `${option.label} selected`)\n    }\n  }\n\n  const openPanel = React.useCallback(() => {\n    if (disabled) return\n    setOpen(true)\n    setQuery(\"\")\n    setActive(0)\n  }, [disabled])\n\n  const closePanel = React.useCallback((refocus: boolean) => {\n    setOpen(false)\n    if (refocus) triggerRef.current?.focus()\n  }, [])\n\n  // Focus the search box when the panel opens (fall back to the list for hideSearch).\n  React.useEffect(() => {\n    if (!open) return\n    const el = hideSearch ? listRef.current : searchRef.current\n    const t = window.setTimeout(() => el?.focus(), 0)\n    return () => window.clearTimeout(t)\n  }, [open, hideSearch])\n\n  // Close on an outside pointer press (capture pointerdown so it beats focus moves).\n  React.useEffect(() => {\n    if (!open) return\n    function onPointerDown(e: PointerEvent) {\n      if (!rootRef.current?.contains(e.target as Node)) closePanel(false)\n    }\n    document.addEventListener(\"pointerdown\", onPointerDown, true)\n    return () => document.removeEventListener(\"pointerdown\", onPointerDown, true)\n  }, [open, closePanel])\n\n  // Clamp the active row when the filter shrinks the list.\n  React.useEffect(() => {\n    setActive((a) => Math.min(a, Math.max(0, filtered.length - 1)))\n  }, [filtered.length])\n\n  // Keep the active row visible while arrowing through a scrolled list.\n  React.useEffect(() => {\n    if (!open) return\n    listRef.current\n      ?.querySelector(`#${CSS.escape(`${id}-opt-${active}`)}`)\n      ?.scrollIntoView({ block: \"nearest\" })\n  }, [active, open, id])\n\n  function handleTriggerKeyDown(e: React.KeyboardEvent) {\n    if (disabled) return\n    if (e.key === \"Enter\" || e.key === \" \" || e.key === \"ArrowDown\") {\n      e.preventDefault()\n      openPanel()\n    } else if (e.key === \"Backspace\" && selected.length > 0) {\n      const last = byValue.get(selected[selected.length - 1])\n      commit(selected.slice(0, -1), `${last?.label ?? \"option\"} deselected`)\n    }\n  }\n\n  function handlePanelKeyDown(e: React.KeyboardEvent) {\n    if (e.key === \"ArrowDown\") {\n      e.preventDefault()\n      // Floor at 0 so an empty list can't leave the index negative and dead.\n      setActive((a) => Math.max(0, Math.min(a + 1, filtered.length - 1)))\n    } else if (e.key === \"ArrowUp\") {\n      e.preventDefault()\n      setActive((a) => Math.max(a - 1, 0))\n    } else if (e.key === \"Enter\") {\n      e.preventDefault()\n      const option = filtered[active]\n      if (option) toggle(option)\n    } else if (e.key === \"Escape\") {\n      e.preventDefault()\n      closePanel(true)\n    } else if (e.key === \"Tab\") {\n      closePanel(false)\n    } else if (e.key === \"Backspace\" && query === \"\" && selected.length > 0) {\n      const last = byValue.get(selected[selected.length - 1])\n      commit(selected.slice(0, -1), `${last?.label ?? \"option\"} deselected`)\n    }\n  }\n\n  return (\n    <div ref={rootRef} className={cn(\"relative\", className)}>\n      {/* Trigger is a div combobox (not <button>) so the badge remove buttons stay valid HTML. */}\n      <div\n        ref={triggerRef}\n        role=\"combobox\"\n        tabIndex={disabled ? -1 : 0}\n        aria-expanded={open}\n        aria-haspopup=\"listbox\"\n        aria-controls={open ? listboxId : undefined}\n        aria-label={ariaLabel}\n        aria-labelledby={ariaLabelledby}\n        aria-disabled={disabled || undefined}\n        onClick={() => (open ? closePanel(false) : openPanel())}\n        onKeyDown={handleTriggerKeyDown}\n        className={cn(\n          \"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\",\n          disabled && \"pointer-events-none opacity-50\"\n        )}\n      >\n        {selected.length === 0 && (\n          <span className=\"px-1 text-muted-foreground\">{placeholder}</span>\n        )}\n        {selected.map((v) => {\n          const option = byValue.get(v)\n          const label = option?.label ?? v\n          return (\n            <span\n              key={v}\n              className=\"inline-flex items-center gap-1 rounded bg-secondary py-0.5 pl-2 pr-1 text-xs font-medium text-secondary-foreground\"\n            >\n              {label}\n              <button\n                type=\"button\"\n                tabIndex={-1}\n                disabled={disabled}\n                aria-label={`Remove ${label}`}\n                onClick={(e) => {\n                  e.stopPropagation() // keep a badge removal from toggling the panel\n                  commit(\n                    selected.filter((s) => s !== v),\n                    `${label} deselected`\n                  )\n                }}\n                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\"\n              >\n                <X className=\"h-3 w-3\" aria-hidden=\"true\" />\n              </button>\n            </span>\n          )\n        })}\n        <ChevronDown\n          className={cn(\n            \"absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground transition-transform\",\n            open && \"rotate-180\"\n          )}\n          aria-hidden=\"true\"\n        />\n      </div>\n\n      {open && (\n        <div\n          onKeyDown={handlePanelKeyDown}\n          className=\"absolute z-50 mt-1 w-full min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md\"\n        >\n          {!hideSearch && (\n            <input\n              ref={searchRef}\n              type=\"text\"\n              role=\"searchbox\"\n              value={query}\n              onChange={(e) => {\n                setQuery(e.target.value)\n                setActive(0)\n              }}\n              placeholder={searchPlaceholder}\n              aria-label={searchPlaceholder}\n              aria-controls={listboxId}\n              aria-activedescendant={\n                filtered.length > 0 ? `${id}-opt-${active}` : undefined\n              }\n              className=\"w-full border-b bg-transparent px-3 py-2 text-sm outline-none placeholder:text-muted-foreground\"\n            />\n          )}\n          <ul\n            ref={listRef}\n            id={listboxId}\n            role=\"listbox\"\n            aria-multiselectable=\"true\"\n            tabIndex={hideSearch ? 0 : -1}\n            aria-activedescendant={\n              hideSearch && filtered.length > 0 ? `${id}-opt-${active}` : undefined\n            }\n            className=\"max-h-60 overflow-y-auto p-1 focus-visible:outline-none\"\n          >\n            {filtered.length === 0 && (\n              // Not an option, so keep it out of the listbox's owned children.\n              <li\n                role=\"presentation\"\n                className=\"px-2 py-4 text-center text-sm text-muted-foreground\"\n              >\n                {emptyMessage}\n              </li>\n            )}\n            {filtered.map((option, index) => {\n              const isSelected = selected.includes(option.value)\n              const isBlocked = option.disabled || (atCap && !isSelected)\n              return (\n                <li\n                  key={option.value}\n                  id={`${id}-opt-${index}`}\n                  role=\"option\"\n                  aria-selected={isSelected}\n                  aria-disabled={isBlocked || undefined}\n                  onPointerMove={() => setActive(index)}\n                  // Keep focus in the search box so arrow keys still work after a click.\n                  onPointerDown={(e) => e.preventDefault()}\n                  onClick={() => toggle(option)}\n                  className={cn(\n                    \"flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm\",\n                    index === active && \"bg-accent text-accent-foreground\",\n                    isBlocked && \"cursor-not-allowed opacity-50\"\n                  )}\n                >\n                  <span\n                    className={cn(\n                      \"flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border border-primary\",\n                      isSelected\n                        ? \"bg-primary text-primary-foreground\"\n                        : \"opacity-50\"\n                    )}\n                    aria-hidden=\"true\"\n                  >\n                    {isSelected && <Check className=\"h-3 w-3\" />}\n                  </span>\n                  {option.label}\n                </li>\n              )\n            })}\n          </ul>\n        </div>\n      )}\n\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </span>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}