deepmoon

Multiselect

Searchable, chip-based multi-select combobox. Try arrow keys + Enter, and Backspace on an empty search to remove the last chip.

Default

ReactTypeScript

Selected: react, typescript

Max selection (2)

Create-new

Type a label that doesn't exist yet and pick "Create" to add it.

Async options

Options are fetched (simulated, 500ms) on every search change.

Installation

$ npx shadcn@latest add https://deepmoon.dev/r/multiselect.json

npm dependencies: lucide-react

Props

MultiselectProps
PropTypeRequired
optionsMultiselectOption[]Yes
valuestring[]Yes
onChange(value: string[]) => voidYes
onSearchChange(search: string) => voidNo
loadingbooleanNo
placeholderstringNo
maxSelectednumberNo
creatablebooleanNo
onCreateOption(label: string) => voidNo
disabledbooleanNo
classNamestringNo
aria-labelstringNo

Code

multiselect.tsx
"use client";

import * as React from "react";
import { ChevronDown, Loader2, Plus, X } from "lucide-react";

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

export interface MultiselectOption {
  value: string;
  label: string;
  disabled?: boolean;
}

export interface MultiselectProps {
  options: MultiselectOption[];
  value: string[];
  onChange: (value: string[]) => void;
  /** Fires on every keystroke — debounce/fetch in the caller for async options. */
  onSearchChange?: (search: string) => void;
  loading?: boolean;
  placeholder?: string;
  maxSelected?: number;
  creatable?: boolean;
  onCreateOption?: (label: string) => void;
  disabled?: boolean;
  className?: string;
  "aria-label"?: string;
}

const CREATE_VALUE = "__deepmoon_create__";

/**
 * WAI-ARIA "combobox with listbox popup" pattern, adapted for multi-select:
 * focus stays on the text input the whole time; ArrowUp/Down move
 * aria-activedescendant across the popup's role="option" items (never real
 * DOM focus); Enter activates whichever option aria-activedescendant
 * points at. Selected options are removed from the popup and rendered as
 * chips instead, so aria-selected marks "the option Enter would pick"
 * rather than "already chosen" — aria-multiselectable is intentionally
 * omitted since this listbox never shows more than one item as active at
 * once, unlike a true simultaneous-multi-select listbox.
 */
export function Multiselect({
  options,
  value,
  onChange,
  onSearchChange,
  loading = false,
  placeholder = "Select...",
  maxSelected,
  creatable = false,
  onCreateOption,
  disabled = false,
  className,
  "aria-label": ariaLabel = "Select options",
}: MultiselectProps) {
  const [search, setSearch] = React.useState("");
  const [open, setOpen] = React.useState(false);
  const [activeIndex, setActiveIndex] = React.useState(-1);
  const [announcement, setAnnouncement] = React.useState("");

  const rootRef = React.useRef<HTMLDivElement>(null);
  const inputRef = React.useRef<HTMLInputElement>(null);
  const baseId = React.useId();
  const listboxId = `${baseId}-listbox`;

  const selectedOptions = options.filter((o) => value.includes(o.value));
  const atMax = maxSelected !== undefined && value.length >= maxSelected;

  const rawVisibleOptions = options.filter(
    (o) => !value.includes(o.value) && o.label.toLowerCase().includes(search.trim().toLowerCase()),
  );
  const exactMatch = options.some((o) => o.label.toLowerCase() === search.trim().toLowerCase());
  const showCreate = creatable && search.trim() !== "" && !exactMatch;

  type Item = MultiselectOption & { create?: boolean };
  const items: Item[] = atMax
    ? []
    : showCreate
      ? [...rawVisibleOptions, { value: CREATE_VALUE, label: search.trim(), create: true }]
      : rawVisibleOptions;

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

  React.useEffect(() => {
    setActiveIndex(items.length > 0 ? 0 : -1);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [search, options.length, value.length, atMax]);

  const selectOption = (option: Item) => {
    if (option.disabled) return;
    if (option.create) {
      onCreateOption?.(option.label);
      setAnnouncement(`${option.label} created and added.`);
    } else {
      onChange([...value, option.value]);
      setAnnouncement(`${option.label} added.`);
    }
    setSearch("");
    onSearchChange?.("");
    inputRef.current?.focus();
  };

  const removeValue = (optionValue: string) => {
    const removed = options.find((o) => o.value === optionValue);
    onChange(value.filter((v) => v !== optionValue));
    if (removed) setAnnouncement(`${removed.label} removed.`);
    inputRef.current?.focus();
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    if (disabled) return;
    if (event.key === "ArrowDown") {
      event.preventDefault();
      setOpen(true);
      setActiveIndex((i) => (items.length === 0 ? -1 : (i + 1) % items.length));
    } else if (event.key === "ArrowUp") {
      event.preventDefault();
      setOpen(true);
      setActiveIndex((i) => (items.length === 0 ? -1 : (i - 1 + items.length) % items.length));
    } else if (event.key === "Enter") {
      event.preventDefault();
      if (open && activeIndex >= 0 && items[activeIndex]) {
        selectOption(items[activeIndex]);
      }
    } else if (event.key === "Escape") {
      setOpen(false);
    } else if (event.key === "Backspace" && search === "" && value.length > 0) {
      removeValue(value[value.length - 1]);
    }
  };

  const activeOption = activeIndex >= 0 ? items[activeIndex] : undefined;

  return (
    <div
      ref={rootRef}
      className={cn("relative w-full", className)}
      onBlur={(event) => {
        if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false);
      }}
    >
      <div
        onClick={() => {
          if (!disabled) {
            inputRef.current?.focus();
            setOpen(true);
          }
        }}
        className={cn(
          "flex min-h-9 w-full flex-wrap items-center gap-1.5 rounded-md border border-input bg-background px-2 py-1.5",
          "has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
          disabled && "cursor-not-allowed opacity-50",
        )}
      >
        {selectedOptions.map((option) => (
          <span
            key={option.value}
            className="flex items-center gap-1 rounded bg-secondary py-0.5 pl-2 pr-1 text-xs font-medium text-secondary-foreground"
          >
            {option.label}
            {!disabled ? (
              <button
                type="button"
                onClick={(event) => {
                  event.stopPropagation();
                  removeValue(option.value);
                }}
                aria-label={`Remove ${option.label}`}
                className="rounded-sm p-0.5 text-secondary-foreground/70 outline-none hover:bg-secondary-foreground/10 hover:text-secondary-foreground focus-visible:ring-1 focus-visible:ring-ring"
              >
                <X className="size-3" aria-hidden="true" />
              </button>
            ) : null}
          </span>
        ))}
        <input
          ref={inputRef}
          role="combobox"
          aria-expanded={open}
          aria-controls={listboxId}
          aria-autocomplete="list"
          aria-activedescendant={activeOption ? `${baseId}-option-${activeOption.value}` : undefined}
          aria-label={ariaLabel}
          disabled={disabled}
          value={search}
          onChange={(event) => {
            setSearch(event.target.value);
            onSearchChange?.(event.target.value);
            setOpen(true);
          }}
          onFocus={() => setOpen(true)}
          onKeyDown={handleKeyDown}
          placeholder={selectedOptions.length === 0 ? placeholder : ""}
          className="min-w-16 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
        />
        {loading ? (
          <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" aria-hidden="true" />
        ) : (
          <ChevronDown className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
        )}
      </div>

      {open && !disabled ? (
        <ul
          id={listboxId}
          role="listbox"
          aria-label={ariaLabel}
          className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-popover p-1 shadow-md"
        >
          {atMax ? (
            <li className="px-2 py-1.5 text-sm text-muted-foreground">Maximum of {maxSelected} selected.</li>
          ) : items.length === 0 ? (
            <li className="px-2 py-1.5 text-sm text-muted-foreground">
              {loading ? "Loading..." : "No options found."}
            </li>
          ) : (
            items.map((option, index) => (
              <li
                key={option.value}
                id={`${baseId}-option-${option.value}`}
                role="option"
                aria-selected={index === activeIndex}
                aria-disabled={option.disabled || undefined}
                onMouseEnter={() => setActiveIndex(index)}
                onMouseDown={(event) => {
                  event.preventDefault();
                  selectOption(option);
                }}
                className={cn(
                  "flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm",
                  index === activeIndex && "bg-accent text-accent-foreground",
                  option.disabled && "cursor-not-allowed opacity-50",
                )}
              >
                {option.create ? (
                  <>
                    <Plus className="size-3.5 shrink-0" aria-hidden="true" />
                    Create &quot;{option.label}&quot;
                  </>
                ) : (
                  option.label
                )}
              </li>
            ))
          )}
        </ul>
      ) : null}

      <span aria-live="polite" className="sr-only">
        {announcement}
      </span>
    </div>
  );
}