Skip to content
goodtabs UI
BrowseBrowse components

Multi select

Several picks shown as chips in a pill field, with a checked list in a popover.

npx shadcn@latest add https://ui.goodtabs.co/r/multi-select.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/multi-select

Six tags with two chosen. Each chip has a remove cross, the list has a count and Clear.

Many selected

Reviewers

Five reviewers with maxChips 2, so three fold into a +3 chip. Controlled through value.

Not searchable

Five days with no filter field above the list.

Usage

import { MultiSelect } from "@/components/ui/multi-select"

<MultiSelect
  options={[
    { value: "design", label: "Design" },
    { value: "frontend", label: "Frontend" },
  ]}
  defaultValue={["design"]}
  placeholder="Add tags"
  aria-label="Tags"
/>

Props

PropTypeDefaultDescription
options{ value: string; label: string; disabled?: boolean }[]The choices, in order.
valuestring[]Controlled values.
defaultValuestring[][]Initial values when uncontrolled.
onValueChange(value: string[]) => voidCalled with the new array.
placeholderstring"Select..."Field text when nothing is chosen.
maxChipsnumber3Chips shown before the rest collapse into +N.
searchablebooleantrueShow the filter field above the list.
searchPlaceholderstring"Search..."Placeholder of the filter field.
disabledbooleanfalseDisables the field.

Source

multi-select/multi-select.tsx
"use client";

import * as React from "react";
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
import { cn } from "@/lib/utils";

export type MultiSelectOption = {
  /** The value kept in the array. */
  value: string;
  /** Text shown in the list and on the chip. */
  label: string;
  /** Shown but not choosable. */
  disabled?: boolean;
};

type MultiSelectProps = Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & {
  /** The choices, in order. */
  options: MultiSelectOption[];
  /** Controlled values. */
  value?: string[];
  /** Initial values when uncontrolled. */
  defaultValue?: string[];
  /** Called with the new array. */
  onValueChange?: (value: string[]) => void;
  /** Field text when nothing is chosen. */
  placeholder?: string;
  /** Chips shown before the rest collapse into +N. */
  maxChips?: number;
  /** Show the filter field above the list. */
  searchable?: boolean;
  /** Placeholder of the filter field. */
  searchPlaceholder?: string;
  /** Disables the field. */
  disabled?: boolean;
};

/**
 * Several picks from a list. The field shows chips with a remove cross and folds extras into +N;
 * the popover lists every option with a check box, a count and a Clear action.
 */
export function MultiSelect({
  options,
  value: valueProp,
  defaultValue,
  onValueChange,
  placeholder = "Select...",
  maxChips = 3,
  searchable = true,
  searchPlaceholder = "Search...",
  disabled,
  className,
  ...props
}: MultiSelectProps) {
  const [open, setOpen] = React.useState(false);
  const [inner, setInner] = React.useState<string[]>(defaultValue ?? []);
  const value = valueProp ?? inner;
  const fieldRef = React.useRef<HTMLDivElement>(null);
  const listId = React.useId();

  const set = (next: string[]) => {
    if (valueProp === undefined) setInner(next);
    onValueChange?.(next);
  };
  const toggle = (v: string) => set(value.includes(v) ? value.filter((x) => x !== v) : [...value, v]);
  const remove = (v: string) => set(value.filter((x) => x !== v));

  const chosen = value.map((v) => options.find((o) => o.value === v)).filter((o): o is MultiSelectOption => !!o);
  const shown = chosen.slice(0, maxChips);
  const hidden = chosen.length - shown.length;

  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (event.target !== event.currentTarget) return;
    if (event.key === "Enter" || event.key === " " || event.key === "ArrowDown") {
      event.preventDefault();
      setOpen(true);
    } else if (event.key === "Backspace" && value.length) {
      event.preventDefault();
      set(value.slice(0, -1));
    }
  };

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverAnchor asChild>
        <div
          ref={fieldRef}
          role="combobox"
          tabIndex={disabled ? -1 : 0}
          aria-expanded={open}
          aria-haspopup="listbox"
          aria-controls={open ? listId : undefined}
          aria-disabled={disabled || undefined}
          data-slot="multi-select"
          data-state={open ? "open" : "closed"}
          onClick={() => !disabled && setOpen((o) => !o)}
          onKeyDown={onKeyDown}
          className={cn(
            "flex h-8 w-full cursor-default items-center gap-1 rounded-full border border-border bg-background py-0 pr-2 pl-1 text-sm transition-colors duration-100 ease-out outline-none select-none hover:bg-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 data-[state=open]:bg-muted dark:border-input dark:bg-input/30 dark:hover:bg-input/50 dark:data-[state=open]:bg-input/50",
            disabled && "pointer-events-none opacity-50",
            className,
          )}
          {...props}
        >
          <div className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
            {chosen.length === 0 && <span className="truncate pl-1.5 text-muted-foreground">{placeholder}</span>}
            {shown.map((option) => (
              <span
                key={option.value}
                className="inline-flex h-6 shrink-0 items-center gap-0.5 rounded-full bg-muted pr-0.5 pl-2 text-xs text-foreground"
              >
                <span className="max-w-32 truncate">{option.label}</span>
                <button
                  type="button"
                  tabIndex={-1}
                  aria-label={`Remove ${option.label}`}
                  onClick={(event) => {
                    event.stopPropagation();
                    remove(option.value);
                  }}
                  className="grid size-5 shrink-0 place-items-center rounded-full text-foreground/70 transition-colors duration-100 ease-out outline-none hover:bg-foreground/10 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
                >
                  <XIcon className="size-3" aria-hidden="true" />
                </button>
              </span>
            ))}
            {hidden > 0 && (
              <span className="inline-flex h-6 shrink-0 items-center rounded-full bg-muted px-2 font-mono text-xs text-foreground">
                +{hidden}
              </span>
            )}
          </div>
          <ChevronDownIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
        </div>
      </PopoverAnchor>
      <PopoverContent
        align="start"
        className="w-(--radix-popover-trigger-width) min-w-48 gap-0 rounded-md border p-0 ring-0"
        onOpenAutoFocus={(event) => {
          if (!searchable) event.preventDefault();
        }}
        onCloseAutoFocus={(event) => {
          event.preventDefault();
          fieldRef.current?.focus();
        }}
        onInteractOutside={(event) => {
          if (fieldRef.current?.contains(event.target as Node)) event.preventDefault();
        }}
      >
        <Command>
          {searchable && <CommandInput placeholder={searchPlaceholder} />}
          <CommandList id={listId}>
            <CommandEmpty className="text-muted-foreground">No results</CommandEmpty>
            {options.map((option) => {
              const checked = value.includes(option.value);
              return (
                <CommandItem
                  key={option.value}
                  value={option.label}
                  keywords={[option.value]}
                  disabled={option.disabled}
                  onSelect={() => toggle(option.value)}
                  className="h-8 py-0"
                >
                  <span
                    aria-hidden="true"
                    className={cn(
                      "grid size-4 shrink-0 place-items-center rounded-[4px] border border-input transition-colors duration-100 ease-out",
                      checked && "border-primary bg-primary text-primary-foreground",
                    )}
                  >
                    {checked && <CheckIcon className="size-3.5" />}
                  </span>
                  <span className="truncate">{option.label}</span>
                  <span className="sr-only">{checked ? ", selected" : ""}</span>
                </CommandItem>
              );
            })}
          </CommandList>
          <div className="flex h-8 items-center justify-between border-t px-2 text-xs text-muted-foreground">
            <span aria-live="polite">{value.length} selected</span>
            <Button
              type="button"
              variant="ghost"
              size="xs"
              disabled={value.length === 0}
              onClick={() => set([])}
              className="-mr-1 rounded-full"
            >
              Clear
            </Button>
          </div>
        </Command>
      </PopoverContent>
    </Popover>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/multi-select/prompt.md, or the Copy AI prompt button above.

When to use

  • Tags, labels, reviewers, channels: any set where several picks are normal
  • Filters where the chosen values should stay visible in the field
  • Lists long enough that typing to filter helps

When not to use

  • One pick only; use the searchable select
  • Four options or fewer that fit on one line; use a checkbox list or toggle group
  • Free text entries that are not in the list; use a tag input

Agent notes

  • Works controlled (value, onValueChange) or uncontrolled (defaultValue); it takes functions, so render it from a client component
  • The field is a focusable div with role combobox so chips can hold real remove buttons; Enter, Space or ArrowDown open it, Backspace removes the last pick
  • Chips never wrap: the field stays 32px tall and folds extras into +N after maxChips
  • The popover matches the field width and stays open while picking; the footer shows the count and clears everything