# Multi Select

> Searchable multi-value dropdown with removable chips, keyboard navigation, select all/clear and a max-selection limit.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/multi-select
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/multi-select.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/multi-select-demo.tsx"
"use client"

import * as React from "react"

import { MultiSelect, type MultiSelectOption } from "@/components/multi-select"

const people: MultiSelectOption[] = [
  { value: "ava", label: "Ava Chen", description: "Design lead" },
  { value: "liam", label: "Liam Novak", description: "Frontend engineer" },
  { value: "noor", label: "Noor Haddad", description: "Product manager" },
  { value: "kai", label: "Kai Tanaka", description: "Backend engineer" },
  { value: "sofia", label: "Sofia Rossi", description: "Data analyst" },
  { value: "omar", label: "Omar Reyes", description: "QA engineer" },
  { value: "ines", label: "Inês Duarte", description: "Researcher" },
  { value: "theo", label: "Theo Larsen", description: "Support (on leave)", disabled: true },
]

const tags: MultiSelectOption[] = [
  { value: "bug", label: "Bug" },
  { value: "feature", label: "Feature" },
  { value: "docs", label: "Documentation" },
  { value: "perf", label: "Performance" },
  { value: "a11y", label: "Accessibility" },
  { value: "design", label: "Design" },
]

export default function MultiSelectDemo() {
  const [assignees, setAssignees] = React.useState<string[]>(["ava", "kai"])

  return (
    <div className="grid w-full max-w-md grid-cols-[minmax(0,1fr)] gap-8">
      <div className="grid min-w-0 gap-2">
        <span className="text-sm font-medium">Assignees</span>
        <MultiSelect
          aria-label="Assignees"
          options={people}
          value={assignees}
          onValueChange={setAssignees}
          placeholder="Search teammates…"
        />
        <p className="text-xs text-muted-foreground">
          {assignees.length === 0 ? "Nobody assigned yet." : `${assignees.length} teammate${assignees.length > 1 ? "s" : ""} will be notified.`}
        </p>
      </div>
      <div className="grid min-w-0 gap-2">
        <span className="text-sm font-medium">Labels (max 3)</span>
        <MultiSelect aria-label="Labels" options={tags} max={3} placeholder="Add labels…" defaultValue={["bug"]} />
      </div>
    </div>
  )
}
```

## Source

```tsx title="components/multi-select.tsx"
"use client"

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

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

export type MultiSelectOption = {
  value: string
  label: string
  /** Secondary text shown under the label */
  description?: string
  disabled?: boolean
}

export interface MultiSelectProps
  extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  options: MultiSelectOption[]
  value?: string[]
  defaultValue?: string[]
  onValueChange?: (value: string[]) => void
  placeholder?: string
  emptyText?: string
  /** Maximum number of selectable options */
  max?: number
  disabled?: boolean
  /** Accessible name for the combobox input */
  "aria-label"?: string
  /** Opens without moving focus, handy for static previews */
  defaultOpen?: boolean
}

export function MultiSelect({
  options,
  value,
  defaultValue = [],
  onValueChange,
  placeholder = "Select options…",
  emptyText = "No matching options.",
  max,
  disabled = false,
  "aria-label": ariaLabel = "Select options",
  defaultOpen = false,
  className,
  ...props
}: MultiSelectProps) {
  const [inner, setInner] = React.useState(defaultValue)
  const selected = value ?? inner
  const [open, setOpen] = React.useState(defaultOpen)
  const [query, setQuery] = React.useState("")
  const [active, setActive] = React.useState(0)
  const root = React.useRef<HTMLDivElement>(null)
  const input = React.useRef<HTMLInputElement>(null)
  const baseId = React.useId()
  const listId = `${baseId}-listbox`
  const optionId = (v: string) => `${baseId}-opt-${v}`

  const q = query.trim().toLowerCase()
  const filtered = options.filter(
    (o) => !q || o.label.toLowerCase().includes(q) || o.description?.toLowerCase().includes(q)
  )
  const activeIndex = Math.min(active, Math.max(filtered.length - 1, 0))
  const activeOption = open ? filtered[activeIndex] : undefined
  const atMax = max !== undefined && selected.length >= max

  function commit(next: string[]) {
    if (value === undefined) setInner(next)
    onValueChange?.(next)
  }

  function isBlocked(option: MultiSelectOption) {
    return option.disabled || (atMax && !selected.includes(option.value))
  }

  function toggle(option: MultiSelectOption) {
    if (selected.includes(option.value)) commit(selected.filter((v) => v !== option.value))
    else if (!isBlocked(option)) commit([...selected, option.value])
  }

  function selectAll() {
    const next = [...selected]
    for (const o of filtered) {
      if (o.disabled || next.includes(o.value)) continue
      if (max !== undefined && next.length >= max) break
      next.push(o.value)
    }
    commit(next)
    input.current?.focus()
  }

  function clearAll() {
    commit(selected.filter((v) => options.find((o) => o.value === v)?.disabled))
    input.current?.focus()
  }

  function close() {
    setOpen(false)
    setQuery("")
  }

  React.useEffect(() => {
    if (!open) return
    function onPointerDown(event: PointerEvent) {
      if (root.current && !root.current.contains(event.target as Node)) {
        setOpen(false)
        setQuery("")
      }
    }
    document.addEventListener("pointerdown", onPointerDown)
    return () => document.removeEventListener("pointerdown", onPointerDown)
  }, [open])

  const activeValue = activeOption?.value
  React.useEffect(() => {
    if (!open || !activeValue) return
    document.getElementById(`${baseId}-opt-${activeValue}`)?.scrollIntoView({ block: "nearest" })
  }, [open, activeValue, baseId])

  function onKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
    switch (event.key) {
      case "ArrowDown":
        event.preventDefault()
        if (!open) setOpen(true)
        else setActive(Math.min(activeIndex + 1, filtered.length - 1))
        break
      case "ArrowUp":
        event.preventDefault()
        if (!open) setOpen(true)
        else setActive(Math.max(activeIndex - 1, 0))
        break
      case "Home":
        if (open) {
          event.preventDefault()
          setActive(0)
        }
        break
      case "End":
        if (open) {
          event.preventDefault()
          setActive(Math.max(filtered.length - 1, 0))
        }
        break
      case "Enter":
        event.preventDefault()
        if (!open) setOpen(true)
        else if (activeOption) toggle(activeOption)
        break
      case "Backspace":
        if (!query && selected.length > 0) {
          const last = options.find((o) => o.value === selected[selected.length - 1])
          if (!last?.disabled) commit(selected.slice(0, -1))
        }
        break
      case "Escape":
        if (open) {
          event.preventDefault()
          close()
        }
        break
      case "Tab":
        close()
        break
    }
  }

  return (
    <div ref={root} className={cn("relative w-full", className)} {...props}>
      <div
        onClick={() => {
          if (disabled) return
          input.current?.focus()
          setOpen(true)
        }}
        className={cn(
          "flex min-h-10 w-full cursor-text items-center gap-1.5 rounded-lg border bg-background py-1.5 pr-2 pl-2 text-sm shadow-xs transition-[box-shadow,border-color] focus-within:border-ring focus-within:ring-4 focus-within:ring-ring/40",
          disabled && "pointer-events-none opacity-50"
        )}
      >
        <div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
          {selected.map((v) => {
            const option = options.find((o) => o.value === v)
            if (!option) return null
            return (
              <span
                key={v}
                className="inline-flex max-w-full items-center gap-1 rounded-md bg-secondary py-0.5 pr-0.5 pl-2 text-xs font-medium text-secondary-foreground"
              >
                <span className="truncate">{option.label}</span>
                <button
                  type="button"
                  aria-label={`Remove ${option.label}`}
                  disabled={option.disabled || disabled}
                  onClick={(event) => {
                    event.stopPropagation()
                    commit(selected.filter((x) => x !== v))
                    input.current?.focus()
                  }}
                  className="inline-flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-foreground/10 hover:text-foreground focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40 disabled:opacity-40"
                >
                  <X className="size-3" aria-hidden />
                </button>
              </span>
            )
          })}
          <input
            ref={input}
            type="text"
            role="combobox"
            aria-label={ariaLabel}
            aria-expanded={open}
            aria-controls={listId}
            aria-autocomplete="list"
            aria-activedescendant={activeOption ? optionId(activeOption.value) : undefined}
            autoComplete="off"
            disabled={disabled}
            value={query}
            placeholder={selected.length === 0 ? placeholder : ""}
            onChange={(event) => {
              setQuery(event.target.value)
              setActive(0)
              setOpen(true)
            }}
            onKeyDown={onKeyDown}
            className="h-6 min-w-16 flex-1 bg-transparent px-1 outline-none placeholder:text-muted-foreground"
          />
        </div>
        {max !== undefined && (
          <span className="shrink-0 text-xs text-muted-foreground tabular-nums" aria-hidden>
            {selected.length}/{max}
          </span>
        )}
        <ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" aria-hidden />
      </div>

      {open && (
        <div
          onMouseDown={(event) => event.preventDefault()}
          className="absolute top-full right-0 left-0 z-50 mt-2 max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-lg"
        >
          <div className="flex items-center justify-between gap-2 border-b px-3 py-1.5 text-xs text-muted-foreground">
            <span aria-live="polite">
              {selected.length} selected{max !== undefined && atMax ? " (max reached)" : ""}
            </span>
            <span className="flex items-center gap-1">
              <button
                type="button"
                onClick={selectAll}
                disabled={atMax}
                className="rounded px-1.5 py-0.5 font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-40"
              >
                Select all
              </button>
              <button
                type="button"
                onClick={clearAll}
                disabled={selected.length === 0}
                className="rounded px-1.5 py-0.5 font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-40"
              >
                Clear
              </button>
            </span>
          </div>
          <ul
            id={listId}
            role="listbox"
            aria-multiselectable
            aria-label={ariaLabel}
            className="max-h-56 overflow-y-auto p-1"
          >
            {filtered.map((option, index) => {
              const isSelected = selected.includes(option.value)
              const blocked = isBlocked(option)
              return (
                <li
                  key={option.value}
                  id={optionId(option.value)}
                  role="option"
                  aria-selected={isSelected}
                  aria-disabled={blocked || undefined}
                  onClick={() => toggle(option)}
                  onMouseMove={() => setActive(index)}
                  className={cn(
                    "flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm",
                    index === activeIndex && "bg-accent text-accent-foreground",
                    blocked && "cursor-not-allowed opacity-50"
                  )}
                >
                  <span
                    aria-hidden
                    className={cn(
                      "flex size-4 shrink-0 items-center justify-center rounded border",
                      isSelected ? "border-primary bg-primary text-primary-foreground" : "bg-background"
                    )}
                  >
                    {isSelected && <Check className="size-3" />}
                  </span>
                  <span className="min-w-0 flex-1">
                    <span className="block truncate">{option.label}</span>
                    {option.description && (
                      <span className="block truncate text-xs text-muted-foreground">{option.description}</span>
                    )}
                  </span>
                </li>
              )
            })}
            {filtered.length === 0 && (
              <li role="presentation" className="px-2 py-6 text-center text-sm text-muted-foreground">
                {emptyText}
              </li>
            )}
          </ul>
        </div>
      )}
    </div>
  )
}
```
