shadimage

Multi Select

A multi-select combobox with inline search, toggle-based selection, and full controlled/uncontrolled API

src/registry/base/multi-select/components/multi-select.tsx

import {
  useCallback,
  useMemo,
  type ComponentProps,
  type ReactNode,
} from "react";
import { Command as CommandPrimitive } from "cmdk";
import { Check } from "lucide-react";

import {
  Popover,
  PopoverAnchor,
  PopoverContent,
} from "@/components/ui/popover";
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandItem,
  CommandList,
} from "@/components/ui/command";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";

import { useControlledState } from "@/hooks/use-controlled-state";
import {
  useFilteredOptions,
  type SelectionOption,
} from "@/hooks/use-filtered-options";

type MultiSelectProps<T extends string> = {
  /** Full list of options to display. */
  items: SelectionOption<T>[];

  /** Controlled array of currently selected values. */
  value: T[];
  /** Called with the full updated selection array after each toggle. */
  onValueChange: (value: T[]) => void;

  /** Controlled open state of the dropdown. Omit to let the component manage it internally. */
  open?: boolean;
  /** Called whenever the open state changes. */
  onOpenChange?: (open: boolean) => void;

  /** Controlled search query. Omit to let the component manage query state internally. */
  query?: string;
  /** Called whenever the search query changes. */
  onQueryChange?: (query: string) => void;

  /**
   * Custom renderer for each list item.
   * Receives the option and a boolean indicating whether it is currently selected.
   * When omitted a default label + checkmark layout is used.
   */
  renderOption?: (option: SelectionOption<T>, selected: boolean) => ReactNode;

  /** Placeholder shown in the search input when empty. @default "Search..." */
  placeholder?: string;

  /** Content shown when no options match the current query. @default "No results found" */
  emptyContent?: ReactNode;
  /**
   * Content shown in place of the option list while `isLoading` is `true`.
   * Defaults to a single skeleton bar.
   */
  loadingContent?: ReactNode;

  /**
   * Custom filter predicate. Receives each option and the current query string.
   * Replaces the default case-insensitive label match when provided.
   */
  filterFn?: (item: SelectionOption<T>, query: string) => boolean;
  /**
   * Skip local filtering entirely.
   * Use when `items` are already filtered server-side for the current query.
   * @default false
   */
  disableLocalFilter?: boolean;

  /** Render the loading skeleton in place of the option list. */
  isLoading?: boolean;

  /** Clear the search query after each selection toggle. @default true */
  clearQueryOnSelect?: boolean;
  /** Close the dropdown immediately after each selection toggle. @default false */
  closeAfterSelect?: boolean;

  /** Content rendered as a leading addon inside the input (e.g. a search icon or label). */
  startAddon?: ReactNode;
  /** Content rendered as a trailing addon inside the input (e.g. a selected-count badge or clear button). */
  endAddon?: ReactNode;
} & ComponentProps<typeof PopoverContent>;

/**
 * Multi-select combobox that toggles values in and out of a selection array.
 *
 * **Required:** `value`, `onValueChange`, `items`
 *
 * Supports controlled `open` and `query` state, async loading, custom option rendering,
 * and custom filtering. Embed icons or actions inside the input via `startAddon` / `endAddon`.
 */
export function MultiSelect<T extends string>({
  items,
  value,
  onValueChange,
  open: openProp,
  onOpenChange,
  query: queryProp,
  onQueryChange,
  renderOption,
  placeholder = "Search...",
  emptyContent = "No results found",
  loadingContent,
  filterFn,
  disableLocalFilter = false,
  isLoading,
  clearQueryOnSelect = true,
  closeAfterSelect = false,
  startAddon,
  endAddon,
  ...props
}: MultiSelectProps<T>) {
  const [values, setValues] = useControlledState({
    value,
    defaultValue: [],
    onChange: onValueChange,
  });

  const [open, setOpen] = useControlledState({
    value: openProp,
    defaultValue: false,
    onChange: onOpenChange,
  });

  const [query, setQuery] = useControlledState({
    value: queryProp,
    defaultValue: "",
    onChange: onQueryChange,
  });

  const selectedSet = useMemo(() => new Set(values), [values]);

  const filteredItems = useFilteredOptions({
    items,
    query,
    filterFn,
    disableLocalFilter,
  });

  const toggleValue = useCallback(
    (nextValue: string) => {
      const v = nextValue as T;

      const next = selectedSet.has(v)
        ? values.filter((item) => item !== v)
        : [...values, v];

      setValues(next);

      if (clearQueryOnSelect) {
        setQuery("");
      }

      if (closeAfterSelect) {
        setOpen(false);
      }
    },
    [
      values,
      selectedSet,
      setValues,
      clearQueryOnSelect,
      setQuery,
      closeAfterSelect,
      setOpen,
    ],
  );

  return (
    <div data-slot="combobox-content" className="relative w-full">
      <Popover open={open} onOpenChange={setOpen}>
        <Command shouldFilter={false}>
          <PopoverAnchor asChild>
            <InputGroup>
              {startAddon && (
                <InputGroupAddon align="inline-start">
                  {startAddon}
                </InputGroupAddon>
              )}
              <CommandPrimitive.Input
                asChild
                value={query}
                onValueChange={setQuery}
                onMouseDown={() => setOpen(true)}
                onKeyDown={(e) => {
                  if (e.key === "Escape") {
                    if (open) {
                      setOpen(false);
                    } else {
                      setQuery("");
                    }
                    return;
                  }
                  setOpen(true);
                }}
              >
                <InputGroupInput
                  placeholder={placeholder}
                  onFocus={() => setOpen(true)}
                />
              </CommandPrimitive.Input>
              {endAddon && (
                <InputGroupAddon align="inline-end">{endAddon}</InputGroupAddon>
              )}
            </InputGroup>
          </PopoverAnchor>

          {!open && <CommandList aria-hidden="true" className="hidden" />}

          <PopoverContent
            asChild
            className="w-(--radix-popover-trigger-width) p-0"
            onInteractOutside={(event) => {
              if (
                event.target instanceof Element &&
                event.target.hasAttribute("cmdk-input")
              ) {
                event.preventDefault();
              }
            }}
            {...props}
          >
            <CommandList>
              {isLoading ? (
                <CommandPrimitive.Loading>
                  {loadingContent ?? (
                    <div className="p-2">
                      <Skeleton className="h-6 w-full" />
                    </div>
                  )}
                </CommandPrimitive.Loading>
              ) : filteredItems.length ? (
                <CommandGroup>
                  {filteredItems.map((item) => {
                    const isSelected = selectedSet.has(item.value);

                    return (
                      <CommandItem
                        key={item.value}
                        value={item.value}
                        disabled={item.disabled}
                        onSelect={toggleValue}
                        onMouseDown={(e) => e.preventDefault()}
                      >
                        {renderOption ? (
                          renderOption(item, isSelected)
                        ) : (
                          <>
                            <Check
                              className={cn(
                                "mr-2 h-4 w-4",
                                isSelected ? "opacity-100" : "opacity-0",
                              )}
                            />
                            {item.label}
                          </>
                        )}
                      </CommandItem>
                    );
                  })}
                </CommandGroup>
              ) : (
                <CommandEmpty>{emptyContent}</CommandEmpty>
              )}
            </CommandList>
          </PopoverContent>
        </Command>
      </Popover>
    </div>
  );
}
Interaction
dropdown

DependenciesBundle weight · light

command
cmdk
icons
lucide-react