Bloom Logo

Multi Select

A searchable multi-selection input with removable pill badges, Select All / Deselect All batch actions, collapsible header categories, keyboard navigation, and max count enforcement.

importimport { MultiSelect } from "@/components/ui/multiSelect/multiSelect";
$npx @bloomui-react/cli add multiSelect
multiSelect.tsx
"use client";

import { Icon } from "@iconify/react";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";

const multiSelectVariants = cva(
  "flex flex-wrap items-center gap-1.5 min-h-[42px] px-3 py-2 rounded-2xl transition-colors cursor-text",
  {
    variants: {
      variant: {
        default:
          "bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 shadow-xs focus-within:border-sky-500 focus-within:ring-2 focus-within:ring-sky-500/40 text-zinc-900 dark:text-zinc-100",
        bordered:
          "bg-transparent border-2 border-zinc-200 dark:border-zinc-800 focus-within:border-sky-500 text-zinc-900 dark:text-zinc-100",
        flat: "bg-zinc-100 dark:bg-zinc-800/60 border-transparent hover:bg-zinc-200/70 dark:hover:bg-zinc-800 focus-within:bg-white dark:focus-within:bg-zinc-900 focus-within:border-sky-500 border text-zinc-900 dark:text-zinc-100",
        underlined:
          "bg-transparent border-b-2 border-zinc-200 dark:border-zinc-800 rounded-none px-0 focus-within:border-sky-500 text-zinc-900 dark:text-zinc-100",
        filled:
          "bg-zinc-100 dark:bg-zinc-800/80 border border-transparent focus-within:border-sky-500 focus-within:ring-2 focus-within:ring-sky-500/40 text-zinc-900 dark:text-zinc-100",
        glassmorphism:
          "backdrop-blur-md bg-white/10 dark:bg-black/10 border border-white/20 dark:border-white/10 focus-within:border-sky-500 shadow-lg text-zinc-900 dark:text-zinc-100",
        "gradient-border":
          "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 relative [background-clip:padding-box] border border-transparent before:absolute before:inset-0 before:-z-10 before:rounded-[inherit] before:p-[1px] before:bg-gradient-to-r before:from-sky-500 before:via-indigo-500 before:to-pink-500 focus-within:ring-2 focus-within:ring-indigo-500/30",
        glow: "bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 shadow-xs focus-within:border-sky-500 focus-within:shadow-[0_0_12px_rgba(14,165,233,0.35)] text-zinc-900 dark:text-zinc-100",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  },
);

export interface MultiSelectOption {
  label: string;
  value: string;
  category?: string;
  icon?: React.ReactNode;
}

interface MultiSelectProps extends VariantProps<typeof multiSelectVariants> {
  options: MultiSelectOption[];
  value: string[];
  onChange: (value: string[]) => void;
  placeholder?: string;
  label?: React.ReactNode;
  maxCount?: number;
  showSelectAll?: boolean;
  isDisabled?: boolean;
  isInvalid?: boolean;
  className?: string;
  isRequired?: boolean;
}

export function MultiSelect({
  options,
  value,
  onChange,
  placeholder = "Select options...",
  label,
  maxCount,
  showSelectAll = true,
  isDisabled = false,
  isInvalid = false,
  variant,
  className,
  isRequired = false,
}: MultiSelectProps) {
  const [isOpen, setIsOpen] = React.useState(false);
  const [search, setSearch] = React.useState("");
  const [collapsedCategories, setCollapsedCategories] = React.useState<
    Record<string, boolean>
  >({});
  const containerRef = React.useRef<HTMLDivElement>(null);
  const inputRef = React.useRef<HTMLInputElement>(null);

  React.useEffect(() => {
    function handleClickOutside(event: MouseEvent) {
      if (
        containerRef.current &&
        !containerRef.current.contains(event.target as Node)
      ) {
        setIsOpen(false);
        setSearch("");
      }
    }
    document.addEventListener("mousedown", handleClickOutside);
    return () => document.removeEventListener("mousedown", handleClickOutside);
  }, []);

  const toggleOption = (optionValue: string) => {
    if (value.includes(optionValue)) {
      onChange(value.filter((v) => v !== optionValue));
    } else {
      if (maxCount && value.length >= maxCount) return;
      onChange([...value, optionValue]);
    }
  };

  const removeOption = (optionValue: string) => {
    onChange(value.filter((v) => v !== optionValue));
  };

  const selectAll = () => {
    const availableValues = filteredOptions.map((opt) => opt.value);
    const newValues = Array.from(new Set([...value, ...availableValues]));
    onChange(maxCount ? newValues.slice(0, maxCount) : newValues);
  };

  const deselectAll = () => {
    onChange([]);
  };

  const toggleCategoryCollapse = (category: string) => {
    setCollapsedCategories((prev) => ({
      ...prev,
      [category]: !prev[category],
    }));
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === "Backspace" && search === "" && value.length > 0) {
      onChange(value.slice(0, -1));
    }
    if (e.key === "Escape") {
      setIsOpen(false);
      setSearch("");
    }
  };

  const filteredOptions = options.filter(
    (opt) =>
      opt.label.toLowerCase().includes(search.toLowerCase()) ||
      opt.category?.toLowerCase().includes(search.toLowerCase()),
  );

  const selectedOptions = options.filter((opt) => value.includes(opt.value));

  const groupedOptions = React.useMemo(() => {
    const groups: Record<string, MultiSelectOption[]> = {};
    const ungrouped: MultiSelectOption[] = [];

    filteredOptions.forEach((opt) => {
      if (opt.category) {
        if (!groups[opt.category]) groups[opt.category] = [];
        groups[opt.category].push(opt);
      } else {
        ungrouped.push(opt);
      }
    });

    return { groups, ungrouped };
  }, [filteredOptions]);

  const isAllSelected =
    filteredOptions.length > 0 &&
    filteredOptions.every((opt) => value.includes(opt.value));

  return (
    <div className={cn("relative w-full", className)} ref={containerRef}>
      {label && (
        <label className="block text-sm font-bold text-zinc-700 dark:text-zinc-300 mb-1.5">
          {label}
          {isRequired && <span className="text-rose-500 ml-0.5">*</span>}
        </label>
      )}
      <div
        className={cn(
          multiSelectVariants({ variant }),
          isInvalid
            ? "border-rose-500 focus-within:ring-2 focus-within:ring-rose-500/20"
            : "",
          isDisabled && "opacity-50 cursor-not-allowed",
        )}
        onClick={() => {
          if (!isDisabled) {
            setIsOpen(true);
            inputRef.current?.focus();
          }
        }}
      >
        {selectedOptions.map((opt) => (
          <span
            key={opt.value}
            className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300"
          >
            {opt.icon && <span className="shrink-0">{opt.icon}</span>}
            {opt.label}
            <button
              type="button"
              onClick={(e) => {
                e.stopPropagation();
                removeOption(opt.value);
              }}
              className="ml-0.5 rounded-full hover:bg-zinc-200 dark:hover:bg-zinc-700 p-0.5 transition-colors"
              disabled={isDisabled}
            >
              <Icon icon="hugeicons:cancel-01" className="size-3" />
            </button>
          </span>
        ))}
        <input
          ref={inputRef}
          type="text"
          value={search}
          onChange={(e) => setSearch(e.target.value)}
          onFocus={() => !isDisabled && setIsOpen(true)}
          onKeyDown={handleKeyDown}
          placeholder={value.length === 0 ? placeholder : ""}
          disabled={isDisabled}
          className="flex-1 min-w-[80px] bg-transparent text-sm text-zinc-900 dark:text-zinc-100 placeholder:text-zinc-400 outline-none disabled:cursor-not-allowed"
        />
      </div>

      {isOpen && !isDisabled && (
        <div className="absolute z-50 mt-2 w-full max-h-72 overflow-y-auto rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 shadow-xl p-1 space-y-1">
          {showSelectAll && filteredOptions.length > 0 && (
            <div className="flex items-center justify-between px-3 py-2 border-b border-zinc-200 dark:border-zinc-800 bg-zinc-50/50 dark:bg-zinc-800/30 rounded-xl mb-1">
              <span className="text-xs text-zinc-500 font-medium">
                {value.length} selected
              </span>
              <div className="flex items-center gap-2">
                <button
                  type="button"
                  onClick={isAllSelected ? deselectAll : selectAll}
                  className="text-xs font-semibold text-sky-500 hover:text-sky-600 transition-colors"
                >
                  {isAllSelected ? "Deselect All" : "Select All"}
                </button>
                {value.length > 0 && !isAllSelected && (
                  <button
                    type="button"
                    onClick={deselectAll}
                    className="text-xs text-rose-500 hover:underline"
                  >
                    Clear
                  </button>
                )}
              </div>
            </div>
          )}

          {filteredOptions.length === 0 ? (
            <div className="px-4 py-3 text-sm text-zinc-400">
              No options found.
            </div>
          ) : (
            <>
              {Object.entries(groupedOptions.groups).map(
                ([categoryName, groupOpts]) => {
                  const isCollapsed = collapsedCategories[categoryName];
                  const categorySelectedCount = groupOpts.filter((opt) =>
                    value.includes(opt.value),
                  ).length;

                  return (
                    <div key={categoryName} className="space-y-0.5">
                      <button
                        type="button"
                        onClick={() => toggleCategoryCollapse(categoryName)}
                        className="w-full flex items-center justify-between px-3 py-1.5 text-[11px] font-bold uppercase tracking-wider text-zinc-400 dark:text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800/60 rounded-xl transition-colors select-none"
                      >
                        <span className="flex items-center gap-1.5">
                          <Icon
                            icon="hugeicons:arrow-down-01"
                            className={cn(
                              "size-3 transition-transform duration-200",
                              isCollapsed && "-rotate-90",
                            )}
                          />
                          {categoryName}
                        </span>
                        {categorySelectedCount > 0 && (
                          <span className="px-1.5 py-0.5 rounded-full text-[10px] bg-sky-500/10 text-sky-500 font-semibold">
                            {categorySelectedCount}/{groupOpts.length}
                          </span>
                        )}
                      </button>

                      {!isCollapsed &&
                        groupOpts.map((opt) => renderOptionItem(opt))}
                    </div>
                  );
                },
              )}

              {groupedOptions.ungrouped.map((opt) => renderOptionItem(opt))}
            </>
          )}
        </div>
      )}
    </div>
  );

  function renderOptionItem(opt: MultiSelectOption) {
    const isSelected = value.includes(opt.value);
    const isLimitReached = maxCount
      ? value.length >= maxCount && !isSelected
      : false;

    return (
      <button
        key={opt.value}
        type="button"
        onClick={() => toggleOption(opt.value)}
        disabled={isLimitReached}
        className={cn(
          "flex items-center gap-2 w-full px-3 py-2 text-sm text-left transition-colors rounded-xl",
          isSelected
            ? "text-sky-500 bg-sky-500/5 font-medium"
            : "text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800/50",
          isLimitReached && "opacity-40 cursor-not-allowed",
        )}
      >
        <span
          className={cn(
            "flex items-center justify-center size-4 rounded border transition-colors",
            isSelected
              ? "bg-sky-500 border-sky-500 text-white"
              : "border-zinc-300 dark:border-zinc-600",
          )}
        >
          {isSelected && (
            <Icon icon="hugeicons:checkmark-circle-02" className="size-3" />
          )}
        </span>
        {opt.icon && <span className="shrink-0">{opt.icon}</span>}
        <span className="flex-1">{opt.label}</span>
      </button>
    );
  }
}

Default

A multi-select input with pre-selected items and search filtering.

AppleCherry

Variants

Defines the visual appearance of the multi-select input using the variant prop.

variant: default | bordered | flat | filled | glow | glassmorphism | gradient-border | underlined
Apple
Banana
Cherry
Dragon Fruit

With Icons

Options can include icons for richer visual context.

options: anyreact: anyicon: anydevicon: anysize-4: anytypescript: any
ReactTypeScript

Max Count

Limit the number of selectable items with the maxCount prop.

options: anyfruitOptions: anysetValue: anyup: anyto: anyfruits: anymaxCount: any
Apple

Select All Batch Actions & Collapsible Category Headers

Batch select or clear all filtered options with showSelectAll, and group items into collapsible category sections via the category option field.

showSelectAll: booleancategory?: string (in option)
ReactNode.jsPostgreSQL

Required State

Displays an asterisk next to the label indicating that choosing options is mandatory.

isRequired: boolean
API Reference
PropTypeDefaultDescription
showSelectAllbooleantrueRenders batch action bar for Select All and Clear.
optionsMultiSelectOption[]Array of selectable options (with optional category & icon).
valuestring[]Currently selected values.
onChange(value: string[]) => voidCallback when selection changes.