Skip to content
UI Kit
Accent color

Multi select

Pick several values with search, groups, counts and a limit. As chips in the field, a filter button, an open facet list or a stack of people.

One of 7 components in Pickers and selections

4 variants

Variants

Chips

The chosen values sit as chips in the field, typing filters the grouped list. Chips spring in, Backspace removes the last one, a counter shows the room left.

Open in Studio

Filter button

A button that names the filter and what is picked, a panel with search, counts, select all and clear. The table below follows every change.

Open in Studio

Facet list

Always open, for shop and search sidebars. A small search, counts per value, the first rows with Show all, and the result count follows.

Open in Studio

Stack

Search in the field, the chosen people stacked below with role and a remove button. A limit of three, someone away is shown but cannot be picked.

Open in Studio

Details

What Multi select brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/multi-select.tsx
Brings along
utilskiticonavatarinputselectpopover
npm packages
None
Languages
English and German, 13 texts of its own

Props

The MultiSelectProps interface from components/ui/multi-select.tsx, with the comments from the code. Also takes every prop of FieldOwnProps.

  • variantoptional
    MultiSelectVariant

    chipspopoverliststack

  • optionsrequired
    MultiSelectOption[]
  • valueoptional
    string[]
  • defaultValueoptional
    string[]
  • onValueChangeoptional
    (value: string[]) => void
  • placeholderoptional
    string
  • searchPlaceholderoptional
    string

    Placeholder of the search field in popover and list

  • maxoptional
    number

    At most this many. Further options are disabled and a counter shows the room left.

  • triggerLabeloptional
    string

    popover: the text on the button, e.g. "Status"

  • triggerIconoptional
    IconName

    popover: icon on the button

  • searchoptional
    boolean

    popover and list: a search field above the rows. Default when there are more than six options.

  • visibleoptional
    number

    list: rows shown before "Show all". Default 6.

  • sizeoptional
    ControlSize
  • nameoptional
    string
  • idoptional
    string
  • disabledoptional
    boolean
  • requiredoptional
    boolean
  • invalidoptional
    boolean
  • defaultOpenoptional
    boolean

    Open on first render, for demos

Built-in texts

Multi select writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 13 texts
placeholderEnglish Choose…German Auswählen…
searchEnglish SearchGerman Suchen
emptyEnglish No match. Try another word.German Kein Treffer. Versuch ein anderes Wort.
removeEnglish RemoveGerman Entfernen
clearAllEnglish Clear allGerman Alle entfernen
selectAllEnglish Select allGerman Alle wählen
selectedEnglish selectedGerman ausgewählt
addedEnglish addedGerman hinzugefügt
removedEnglish removedGerman entfernt
ofEnglish ofGerman von
moreEnglish Show allGerman Alle zeigen
lessEnglish Show fewerGerman Weniger zeigen
noneEnglish Nothing chosen yetGerman Noch nichts ausgewählt

Accessibility

Read from the source of the component.

  • Multi select handles the keys Escape, Enter, Arrow up, Arrow down, Home, End, Tab, Backspace and Space itself.
  • Sets aria-activedescendant, aria-autocomplete, aria-controls, aria-describedby, aria-disabled, aria-expanded, aria-haspopup, aria-invalid, aria-label, aria-labelledby, aria-live and aria-selected, so screen readers announce purpose and state.
  • Uses the roles combobox, dialog, group, listbox and option.
  • Announces changes through a live region without moving focus.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 5 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 654 lines
components/ui/multi-select.tsx
'use client';

import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Avatar } from '@/components/ui/avatar';
import { CONTROL_HEIGHT, controlBox, controlBoxFocus, useControl, useShake, withField, type ControlSize, type FieldOwnProps } from '@/components/ui/input';
import { ActiveMarker, LIST_STYLE, groupOptions, revealOption, unfold } from '@/components/ui/select';
import { Floating, floatingPop, originFor, panelSurface, useDismiss, useMounted, usePresence } from '@/components/ui/popover';

/*
 * Pick several values from a list. A listbox with aria-multiselectable after
 * the WAI-ARIA APG: arrows move, Space or Enter toggles, Home and End jump,
 * typing filters. Four looks: chips inside the field, a filter button with a
 * searchable panel, a list that is always open (shop facets) and a field
 * with the chosen items stacked below it (people, reviewers).
 */

const TEXTS = {
  en: {
    placeholder: 'Choose…', search: 'Search', empty: 'No match. Try another word.', remove: 'Remove', clearAll: 'Clear all', selectAll: 'Select all',
    selected: 'selected', added: 'added', removed: 'removed', of: 'of', more: 'Show all', less: 'Show fewer', none: 'Nothing chosen yet',
  },
  de: {
    placeholder: 'Auswählen…', search: 'Suchen', empty: 'Kein Treffer. Versuch ein anderes Wort.', remove: 'Entfernen', clearAll: 'Alle entfernen', selectAll: 'Alle wählen',
    selected: 'ausgewählt', added: 'hinzugefügt', removed: 'entfernt', of: 'von', more: 'Alle zeigen', less: 'Weniger zeigen', none: 'Noch nichts ausgewählt',
  },
};
type Texts = (typeof TEXTS)['en'];

export interface MultiSelectOption {
  value: string;
  label: string;
  description?: string;
  icon?: IconName;
  /** A person: shows an avatar with initials (or the image) before the label */
  avatar?: string;
  /** A small colored dot before the label, as a CSS color or token, e.g. "var(--success)" */
  dot?: string;
  /** A count at the end of the row, e.g. matching products */
  count?: number;
  group?: string;
  disabled?: boolean;
}

export type MultiSelectVariant = 'chips' | 'popover' | 'list' | 'stack';

export interface MultiSelectProps extends FieldOwnProps {
  variant?: MultiSelectVariant;
  options: MultiSelectOption[];
  value?: string[];
  defaultValue?: string[];
  onValueChange?: (value: string[]) => void;
  placeholder?: string;
  /** Placeholder of the search field in popover and list */
  searchPlaceholder?: string;
  /** At most this many. Further options are disabled and a counter shows the room left. */
  max?: number;
  /** popover: the text on the button, e.g. "Status" */
  triggerLabel?: string;
  /** popover: icon on the button */
  triggerIcon?: IconName;
  /** popover and list: a search field above the rows. Default when there are more than six options. */
  search?: boolean;
  /** list: rows shown before "Show all". Default 6. */
  visible?: number;
  size?: ControlSize;
  name?: string;
  id?: string;
  disabled?: boolean;
  required?: boolean;
  invalid?: boolean;
  /** Open on first render, for demos */
  defaultOpen?: boolean;
  labels?: Partial<Texts>;
  className?: string;
}

const norm = (s: string) => s.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
const matches = (o: MultiSelectOption, q: string) => {
  const hay = norm(`${o.label} ${o.description ?? ''} ${o.group ?? ''}`);
  return norm(q).split(/\s+/).filter(Boolean).every((w) => hay.includes(w));
};

/** The square that fills with a check when a row is chosen */
function CheckMark({ on, dim }: { on: boolean; dim?: boolean }) {
  return (
    <span
      aria-hidden
      className={cn(
        'grid size-[17px] shrink-0 place-items-center rounded-[5px] border transition-[background-color,border-color,scale] duration-200 ease-[var(--ease-kit)] motion-reduce:transition-none',
        on ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong bg-background',
        dim && 'opacity-50',
      )}
    >
      <svg viewBox="0 0 24 24" width={12} height={12} fill="none" className={cn('transition-[opacity,scale] duration-200 motion-reduce:transition-none', on ? 'scale-100 opacity-100' : 'scale-50 opacity-0')}>
        <path d="m5 12.5 4.5 4.5L19 7.5" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
      </svg>
    </span>
  );
}

function Leading({ o }: { o: MultiSelectOption }) {
  if (o.avatar) return <Avatar name={o.avatar} size="xs" alt="" />;
  if (o.dot) return <span aria-hidden className="size-2 shrink-0 rounded-full" style={{ background: o.dot }} />;
  if (o.icon) return <Icon name={o.icon} size={16} className="shrink-0 text-muted-foreground" />;
  return null;
}

/** Marks the typed part of a label */
function Hit({ text, query }: { text: string; query: string }) {
  const q = norm(query.trim()).split(/\s+/)[0];
  const i = q ? norm(text).indexOf(q) : -1;
  if (i < 0) return <>{text}</>;
  return (
    <>
      {text.slice(0, i)}
      <mark className="rounded-[3px] bg-[color-mix(in_oklab,var(--primary)_18%,transparent)] text-inherit">{text.slice(i, i + q.length)}</mark>
      {text.slice(i + q.length)}
    </>
  );
}

interface ListProps {
  shown: MultiSelectOption[];
  baseId: string;
  active: number;
  setActive: (i: number) => void;
  isOn: (v: string) => boolean;
  isFull: boolean;
  toggle: (o: MultiSelectOption) => void;
  query: string;
  showGroups?: boolean;
}

/** The rows, grouped, with a check square. Shared by every look. */
function Rows({ shown, baseId, active, setActive, isOn, isFull, toggle, query, showGroups = true }: ListProps) {
  const locale = useLocale();
  const groups = showGroups ? groupOptions(shown as never) : [{ name: undefined, items: shown.map((option, index) => ({ option, index })) }];
  return (
    <>
      {groups.map((g, gi) => {
        const rows = g.items.map(({ option, index }) => {
          const o = option as MultiSelectOption;
          const on = isOn(o.value);
          const off = o.disabled || (isFull && !on);
          return (
            <div
              key={o.value}
              id={`${baseId}-${index}`}
              role="option"
              aria-selected={on}
              aria-disabled={off || undefined}
              data-active={index === active || undefined}
              onPointerMove={() => setActive(index)}
              onPointerDown={(e) => e.preventDefault()}
              onClick={() => !off && toggle(o)}
              style={{ '--i': index } as React.CSSProperties}
              className="kitfx-select-row relative flex min-h-9 cursor-default select-none items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm text-foreground aria-disabled:cursor-not-allowed max-sm:min-h-11"
            >
              <CheckMark on={on} dim={off} />
              <Leading o={o} />
              <span className={cn('min-w-0 flex-1', off && 'opacity-50')}>
                <span className="block truncate">
                  <Hit text={o.label} query={query} />
                </span>
                {o.description && <span className="block truncate text-xs text-muted-foreground">{o.description}</span>}
              </span>
              {o.count !== undefined && <span className="shrink-0 text-xs tabular-nums text-subtle-foreground">{o.count.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}</span>}
            </div>
          );
        });
        if (!g.name) return <div key={gi}>{rows}</div>;
        return (
          <div key={gi} role="group" aria-labelledby={`${baseId}-g${gi}`} className={cn(gi > 0 && 'mt-1 border-t border-border pt-1')}>
            <div id={`${baseId}-g${gi}`} className="px-2.5 pb-1 pt-2 text-[11px] font-medium uppercase tracking-[0.08em] text-subtle-foreground">
              {g.name}
            </div>
            {rows}
          </div>
        );
      })}
    </>
  );
}

/** Keyboard on a list with an active row: arrows, Home, End, Enter and Space toggle */
function listKeys(e: React.KeyboardEvent, count: number, active: number, setActive: (i: number) => void, toggleActive: () => void, spaceToggles: boolean) {
  const k = e.key;
  if (!count) return false;
  if (k === 'ArrowDown' || k === 'ArrowUp') {
    e.preventDefault();
    setActive(active < 0 ? (k === 'ArrowDown' ? 0 : count - 1) : (active + (k === 'ArrowDown' ? 1 : -1) + count) % count);
    return true;
  }
  if ((k === 'Home' || k === 'End') && (e.ctrlKey || spaceToggles)) {
    e.preventDefault();
    setActive(k === 'Home' ? 0 : count - 1);
    return true;
  }
  if (k === 'Enter' || (k === ' ' && spaceToggles)) {
    if (active < 0) return false;
    e.preventDefault();
    toggleActive();
    return true;
  }
  return false;
}

function MultiBox(props: Omit<MultiSelectProps, keyof FieldOwnProps> & { listLabel?: string }) {
  const {
    variant = 'chips', options, value: valueProp, defaultValue, onValueChange, placeholder, searchPlaceholder, max, triggerLabel, triggerIcon = 'filter',
    visible = 6, size = 'md', name, id, disabled, required, invalid, defaultOpen, labels, className, listLabel,
  } = props;
  const searchable = props.search ?? options.length > 6;
  const t = useTexts(TEXTS, labels);
  const c = useControl({ id, invalid, disabled, required });
  const [inner, setInner] = useState<string[]>(defaultValue ?? []);
  const value = valueProp ?? inner;
  const [query, setQuery] = useState('');
  const [open, setOpen] = useState(Boolean(defaultOpen));
  const [active, setActive] = useState(-1);
  const [expanded, setExpanded] = useState(false);
  const [said, setSaid] = useState('');
  const mounted = useMounted();
  const boxRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const panelRef = useRef<HTMLDivElement>(null);
  const baseId = useId();
  const listId = `${baseId}-list`;
  useShake(boxRef, c.invalid);

  const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);
  const chosen = value.map((v) => byValue.get(v)).filter(Boolean) as MultiSelectOption[];
  const isOn = (v: string) => value.includes(v);
  const isFull = max !== undefined && value.length >= max;
  const filtered = useMemo(() => (query.trim() ? options.filter((o) => matches(o, query)) : options), [options, query]);
  // The list look keeps the chosen rows on top only after a change of query, so rows never jump under the pointer
  const limited = variant === 'list' && !expanded && !query.trim() ? filtered.slice(0, visible) : filtered;
  const shown = limited;

  const set = (next: string[]) => {
    setInner(next);
    onValueChange?.(next);
  };
  const toggle = (o: MultiSelectOption) => {
    if (o.disabled) return;
    const on = isOn(o.value);
    if (!on && isFull) return;
    set(on ? value.filter((v) => v !== o.value) : [...value, o.value]);
    setSaid(`${o.label} ${on ? t.removed : t.added}`);
    // In the field looks a pick clears the typed text, ready for the next one
    if ((variant === 'chips' || variant === 'stack') && query) {
      setQuery('');
      setActive(Math.max(0, options.indexOf(o)));
    }
  };
  const removeValue = (v: string) => {
    const o = byValue.get(v);
    set(value.filter((x) => x !== v));
    if (o) setSaid(`${o.label} ${t.removed}`);
  };

  const close = useCallback(() => {
    setOpen(false);
    setActive(-1);
  }, []);
  const refs = useRef([boxRef, panelRef]).current;
  useDismiss(open && variant !== 'list', close, refs);

  useEffect(() => {
    if (active >= 0 && (open || variant === 'list')) revealOption(`${baseId}-${active}`);
  }, [active, open, baseId, variant]);
  useEffect(() => {
    if (active >= shown.length) setActive(shown.length ? shown.length - 1 : -1);
  }, [shown.length, active]);
  // The popover hands the focus to its search field, or to its list when there is none.
  // The panel stays hidden until it is placed, so this waits two frames instead of autofocus.
  useEffect(() => {
    if (!open || variant !== 'popover') return;
    let inner = 0;
    const id = requestAnimationFrame(() => {
      inner = requestAnimationFrame(() => (searchable ? inputRef.current : document.getElementById(listId))?.focus());
    });
    return () => {
      cancelAnimationFrame(id);
      cancelAnimationFrame(inner);
    };
  }, [open, variant, searchable, listId]);

  const activeId = active >= 0 && active < shown.length && (open || variant === 'list') ? `${baseId}-${active}` : undefined;
  const counter = max !== undefined && (
    <span className={cn('shrink-0 text-xs tabular-nums transition-colors duration-300', isFull ? 'text-warning-foreground' : 'text-subtle-foreground')}>
      {value.length} {t.of} {max}
    </span>
  );
  const live = (
    <span aria-live="polite" className="sr-only">
      {said}
    </span>
  );
  const hidden = name ? value.map((v) => <input key={v} type="hidden" name={name} value={v} />) : null;
  const rows = (
    <Rows shown={shown} baseId={baseId} active={active} setActive={setActive} isOn={isOn} isFull={isFull} toggle={toggle} query={query} />
  );
  const emptyRow = shown.length === 0 && <p className="px-3 py-6 text-center text-sm text-muted-foreground">{t.empty}</p>;

  const panel = (inner: React.ReactNode, extraClass?: string) =>
    mounted &&
    open && (
      <Floating anchor={variant === 'popover' ? triggerRef : boxRef} side="bottom" align="start" offset={6} matchWidth={variant !== 'popover'} ref={panelRef}>
        {(pos) => (
          <div className={cn(panelSurface, unfold(pos?.side).className, extraClass)}>
            <style dangerouslySetInnerHTML={{ __html: LIST_STYLE }} />
            {inner}
          </div>
        )}
      </Floating>
    );

  // Without a search field the list itself takes the focus and the keys
  const listbox = (cls?: string, labelled?: string, focusable?: boolean) => (
    <div
      id={listId}
      role="listbox"
      aria-multiselectable
      aria-labelledby={labelled}
      aria-label={labelled ? undefined : listLabel}
      tabIndex={focusable ? 0 : undefined}
      aria-activedescendant={focusable ? activeId : undefined}
      onFocus={focusable ? () => active < 0 && setActive(0) : undefined}
      onBlur={focusable && variant === 'list' ? () => setActive(-1) : undefined}
      onKeyDown={focusable ? (e) => listKeys(e, shown.length, active, setActive, () => toggle(shown[active]), true) : undefined}
      className={cn('kit-scrollbar relative overflow-y-auto overscroll-contain', focusable && 'outline-none [&:focus-visible_[data-active]]:shadow-[inset_0_0_0_1.5px_color-mix(in_oklab,var(--primary)_55%,transparent)]', cls)}
    >
      <ActiveMarker id={activeId} version={`${query}|${shown.length}|${expanded}`} />
      {rows}
      {emptyRow}
    </div>
  );

  const comboKeys = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'ArrowDown' && !open) {
      e.preventDefault();
      setOpen(true);
      setActive(0);
      return;
    }
    if (open && listKeys(e, shown.length, active, setActive, () => toggle(shown[active]), false)) return;
    if (e.key === 'Escape') {
      if (open) {
        e.preventDefault();
        close();
      } else if (query) setQuery('');
    } else if (e.key === 'Backspace' && !query && value.length && variant === 'chips') {
      removeValue(value[value.length - 1]);
    } else if (e.key === 'Tab' && open) close();
  };

  const searchInput = (opts: { combobox?: boolean; className?: string; autoFocus?: boolean }) => (
    <input
      ref={inputRef}
      id={opts.combobox ? c.id : undefined}
      type="text"
      role="combobox"
      aria-autocomplete="list"
      aria-expanded={variant === 'list' ? true : open}
      aria-controls={open || variant === 'list' ? listId : undefined}
      aria-activedescendant={activeId}
      aria-invalid={opts.combobox ? c['aria-invalid'] : undefined}
      aria-describedby={opts.combobox ? c['aria-describedby'] : undefined}
      aria-label={opts.combobox ? undefined : searchPlaceholder ?? t.search}
      autoFocus={opts.autoFocus}
      disabled={c.disabled}
      autoComplete="off"
      spellCheck={false}
      placeholder={opts.combobox ? (value.length && variant === 'chips' ? '' : placeholder ?? t.placeholder) : searchPlaceholder ?? t.search}
      value={query}
      onChange={(e) => {
        setQuery(e.target.value);
        setActive(0);
        if (variant !== 'list') setOpen(true);
      }}
      onClick={() => variant !== 'list' && variant !== 'popover' && setOpen(true)}
      onKeyDown={variant === 'list' ? (e) => listKeys(e, shown.length, active, setActive, () => toggle(shown[active]), false) : comboKeys}
      className={cn('min-w-0 bg-transparent text-inherit outline-none placeholder:text-subtle-foreground', opts.className)}
    />
  );

  /* ---------------- chips: tokens inside the field ---------------- */
  if (variant === 'chips') {
    return (
      <>
        <div
          ref={boxRef}
          data-invalid={c.invalid || undefined}
          data-disabled={c.disabled || undefined}
          onPointerDown={(e) => {
            if ((e.target as HTMLElement).closest('button')) return;
            e.preventDefault();
            inputRef.current?.focus();
            setOpen(true);
          }}
          className={cn('group/multi relative flex min-h-11 w-full cursor-text flex-wrap items-center gap-1.5 rounded-lg px-1.5 py-1.5 text-base sm:text-sm', controlBox, className)}
        >
          {chosen.map((o) => (
            <span
              key={o.value}
              className="inline-flex h-8 max-w-full items-center gap-1.5 rounded-md border border-border bg-muted ps-2 text-[13px] font-medium text-foreground transition-[opacity,scale] duration-300 ease-[cubic-bezier(0.34,1.4,0.64,1)] starting:scale-75 starting:opacity-0 motion-reduce:transition-none sm:h-7"
            >
              <Leading o={o} />
              <span className="truncate">{o.label}</span>
              <button
                type="button"
                aria-label={`${t.remove}: ${o.label}`}
                disabled={c.disabled}
                onClick={() => {
                  removeValue(o.value);
                  inputRef.current?.focus();
                }}
                className="relative grid h-full w-10 place-items-center rounded-e-[5px] text-subtle-foreground outline-none transition-colors after:absolute after:-inset-y-2 after:-inset-x-0.5 after:content-[''] sm:after:-inset-y-1.5 sm:after:inset-x-0 hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] sm:w-6"
              >
                <Icon name="x" size={12} strokeWidth={2} />
              </button>
            </span>
          ))}
          {/* Input and counter wrap together, so the counter always closes the last line */}
          <span className="flex min-w-[9rem] flex-1 items-center">
            {searchInput({ combobox: true, className: 'h-8 w-0 flex-1 ps-1.5 sm:h-7' })}
            <span className="pointer-events-none flex h-8 shrink-0 items-center gap-2 pe-1.5 ps-1 sm:h-7">
              {counter}
              <Icon name="chevrons-up-down" size={15} className="text-subtle-foreground" />
            </span>
          </span>
        </div>
        {panel(listbox('max-h-72 p-1.5'))}
        {live}
        {hidden}
      </>
    );
  }

  /* ---------------- stack: chosen items listed below the field ---------------- */
  if (variant === 'stack') {
    return (
      <div className={cn('grid w-full grid-cols-[minmax(0,1fr)] gap-2.5', className)}>
        <div ref={boxRef} data-invalid={c.invalid || undefined} data-disabled={c.disabled || undefined} className={cn('group/multi relative flex w-full items-center', CONTROL_HEIGHT[size], controlBox)}>
          <Icon name="search" size={16} className="pointer-events-none ms-3.5 shrink-0 text-subtle-foreground" />
          {searchInput({ combobox: true, className: 'h-full w-0 flex-1 ps-2.5' })}
          <span className="pointer-events-none me-3 flex items-center gap-2">{counter}</span>
        </div>
        {panel(listbox('max-h-72 p-1.5'))}
        <ul className="grid grid-cols-[minmax(0,1fr)] overflow-hidden rounded-lg border border-border bg-card">
          {chosen.length === 0 && <li className="px-3.5 py-3 text-[13px] text-muted-foreground">{t.none}</li>}
          {chosen.map((o, i) => (
            <li
              key={o.value}
              className={cn(
                'flex min-h-14 items-center gap-3 px-3.5 py-2.5 transition-[opacity,translate] duration-300 ease-[var(--ease-kit)] starting:-translate-y-1 starting:opacity-0 motion-reduce:transition-none',
                i > 0 && 'border-t border-border',
              )}
            >
              {o.avatar ? <Avatar name={o.avatar} size="sm" alt="" /> : <Leading o={o} />}
              <span className="min-w-0 flex-1">
                <span className="block truncate text-sm font-medium text-foreground">{o.label}</span>
                {o.description && <span className="block truncate text-[13px] text-muted-foreground">{o.description}</span>}
              </span>
              <button
                type="button"
                aria-label={`${t.remove}: ${o.label}`}
                disabled={c.disabled}
                onClick={() => removeValue(o.value)}
                className="grid size-11 shrink-0 place-items-center rounded-md text-subtle-foreground outline-none transition-[background-color,color,scale] duration-200 hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] active:scale-90 sm:size-8"
              >
                <Icon name="x" size={15} />
              </button>
            </li>
          ))}
        </ul>
        {live}
        {hidden}
      </div>
    );
  }

  /* ---------------- list: always open, for filter sidebars ---------------- */
  if (variant === 'list') {
    const rest = filtered.length - visible;
    return (
      <div ref={boxRef} className={cn('grid w-full grid-cols-[minmax(0,1fr)] gap-2', className)}>
        {searchable && (
          <div data-invalid={c.invalid || undefined} data-disabled={c.disabled || undefined} className={cn('relative flex h-11 items-center rounded-lg text-base sm:h-9 sm:rounded-md sm:text-[13px]', controlBox)}>
            <Icon name="search" size={15} className="pointer-events-none ms-3 shrink-0 text-subtle-foreground" />
            {searchInput({ className: 'h-full w-0 flex-1 ps-2 pe-3' })}
          </div>
        )}
        <div className="relative">
          <style dangerouslySetInnerHTML={{ __html: LIST_STYLE }} />
          {listbox(cn('max-h-[22rem]', searchable ? '-mx-2.5' : '-mx-2.5 p-0.5'), undefined, !searchable)}
        </div>
        {((rest > 0 && !query.trim()) || value.length > 0) && (
        <div className="flex min-h-6 items-center justify-between gap-3 text-[13px]">
          {rest > 0 && !query.trim() ? (
            <button
              type="button"
              aria-expanded={expanded}
              aria-controls={listId}
              onClick={() => setExpanded(!expanded)}
              className="-mx-1.5 inline-flex min-h-11 items-center gap-1 rounded-md px-1.5 font-medium text-foreground outline-none hover:underline focus-visible:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] sm:min-h-7"
            >
              {expanded ? t.less : `${t.more} (${filtered.length})`}
              <Icon name="chevron-down" size={14} className={cn('transition-[rotate] duration-300 ease-[var(--ease-kit)]', expanded && 'rotate-180')} />
            </button>
          ) : (
            <span />
          )}
          {value.length > 0 && (
            <button type="button" onClick={() => set([])} className="-mx-1.5 inline-flex min-h-11 items-center rounded-md px-1.5 text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] sm:min-h-7 starting:opacity-0">
              {t.clearAll}
            </button>
          )}
        </div>
        )}
        {live}
        {hidden}
      </div>
    );
  }

  /* ---------------- popover: a filter button with a searchable panel ---------------- */
  const summary = chosen.length === 0 ? null : chosen.length <= 2 ? chosen.map((o) => o.label).join(', ') : `${chosen.length} ${t.selected}`;
  const enabled = options.filter((o) => !o.disabled);
  const allOn = enabled.length > 0 && enabled.every((o) => isOn(o.value));
  return (
    <>
      <div ref={boxRef} className={cn('inline-flex max-w-full', className)}>
        <button
          ref={triggerRef}
          type="button"
          id={c.id}
          aria-haspopup="listbox"
          aria-expanded={open}
          aria-controls={open ? `${baseId}-panel` : undefined}
          aria-describedby={c['aria-describedby']}
          disabled={c.disabled}
          onClick={() => {
            setOpen(!open);
            setActive(-1);
          }}
          onKeyDown={(e) => {
            if (e.key === 'ArrowDown' && !open) {
              e.preventDefault();
              setOpen(true);
              setActive(0);
            }
          }}
          className={cn(
            'group/trigger inline-flex h-11 max-w-full items-center gap-2 rounded-lg px-3.5 text-sm font-medium sm:h-9 sm:px-3 sm:text-[13px]',
            controlBoxFocus,
            chosen.length > 0 && 'border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_7%,var(--background))]',
          )}
        >
          <Icon name={triggerIcon} size={15} className="shrink-0 text-muted-foreground" />
          <span className="shrink-0">{triggerLabel ?? placeholder ?? t.placeholder}</span>
          {summary && (
            <>
              <span aria-hidden className="h-4 w-px shrink-0 bg-border-strong" />
              <span key={summary} className="truncate font-normal text-primary transition-[opacity,translate] duration-300 ease-[var(--ease-kit)] starting:translate-y-1 starting:opacity-0 motion-reduce:transition-none">
                {summary}
              </span>
            </>
          )}
          <Icon name="chevron-down" size={14} className={cn('shrink-0 text-subtle-foreground transition-[rotate] duration-300 ease-[var(--ease-kit)]', open && 'rotate-180')} />
        </button>
      </div>
      <PopoverPanel open={open} mounted={mounted} anchor={triggerRef} panelRef={panelRef} id={`${baseId}-panel`} label={triggerLabel ?? placeholder ?? t.placeholder} onEscape={() => { close(); triggerRef.current?.focus(); }}>
        {searchable && (
          <div className="flex h-11 items-center gap-2 border-b border-border px-3">
            <Icon name="search" size={15} className="shrink-0 text-subtle-foreground" />
            {searchInput({ className: 'h-full w-0 flex-1 text-base sm:text-[13px]' })}
            {counter}
          </div>
        )}
        {listbox('max-h-64 p-1.5', undefined, !searchable)}
        <div className="flex items-center justify-between gap-2 border-t border-border p-1.5">
          <button
            type="button"
            disabled={max !== undefined || allOn}
            onClick={() => set(enabled.map((o) => o.value))}
            className="inline-flex h-11 items-center rounded-md px-2.5 text-[13px] font-medium text-foreground outline-none transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] disabled:pointer-events-none disabled:opacity-40 sm:h-8"
          >
            {t.selectAll}
          </button>
          <button
            type="button"
            disabled={value.length === 0}
            onClick={() => set([])}
            className="inline-flex h-11 items-center rounded-md px-2.5 text-[13px] text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] disabled:pointer-events-none disabled:opacity-40 sm:h-8"
          >
            {t.clearAll}
          </button>
        </div>
      </PopoverPanel>
      {live}
      {hidden}
    </>
  );

}
/** The filter panel of the popover look, kept mounted for its exit */
function PopoverPanel({ open, mounted, anchor, panelRef, id, label, onEscape, children }: { open: boolean; mounted: boolean; anchor: React.RefObject<HTMLButtonElement | null>; panelRef: React.RefObject<HTMLDivElement | null>; id: string; label: string; onEscape: () => void; children: React.ReactNode }) {
  const { present, closing } = usePresence(open);
  if (!mounted || !present) return null;
  return (
    <Floating anchor={anchor} side="bottom" align="start" offset={6} ref={panelRef}>
      {(pos) => (
        <div
          id={id}
          role="dialog"
          aria-label={label}
          data-closing={closing || undefined}
          inert={closing || undefined}
          onKeyDown={(e) => {
            if (e.key === 'Escape') {
              e.stopPropagation();
              onEscape();
            }
          }}
          className={cn('w-72 max-w-[calc(100vw-16px)] overflow-hidden', panelSurface, floatingPop)}
          style={{ '--origin': originFor(pos, 'bottom') } as React.CSSProperties}
        >
          <style dangerouslySetInnerHTML={{ __html: LIST_STYLE }} />
          {children}
        </div>
      )}
    </Floating>
  );
}

/**
 * Pick several values from a list, with search, groups, counts and a limit.
 *   <MultiSelect label="Skills" options={skills} defaultValue={['research']} />
 *   <MultiSelect variant="popover" triggerLabel="Status" options={statuses} />
 *   <MultiSelect variant="list" label="Material" options={materials} visible={5} />
 *   <MultiSelect variant="stack" label="Reviewers" options={people} max={3} />
 */
export function MultiSelect({ label, hint, error, optional, labelAction, className, ...rest }: MultiSelectProps) {
  const wrapped = Boolean(label || hint || error);
  return withField(
    { label, hint, error, optional, labelAction, id: rest.id, required: rest.required, disabled: rest.disabled, className },
    <MultiBox {...rest} listLabel={rest.triggerLabel ?? (typeof label === 'string' ? label : undefined)} invalid={rest.invalid ?? (error ? true : undefined)} className={wrapped ? undefined : className} />,
  );
}

Frequent questions

How do I install Multi select?

With aiw add @ai-website/multi-select. The command puts components/ui/multi-select.tsx into your project, the code is yours from then on. It brings along icon, avatar, input, select and popover. It needs no further npm packages.

Which variants does Multi select have?

4 variants, Chips, Filter button, Facet list and Stack. You pick one with the variant prop, for example variant="list".

Can I translate the texts of Multi select?

Yes. Multi select carries 13 texts of its own in English and German, such as “Choose…”, “Search” and “No match. Try another word.”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Multi select?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Multi select with aiw_components and install it with aiw_add @ai-website/multi-select. Name the variant in your prompt, for example variant="popover", and the assistant writes your content straight into the props.

Can I adjust Multi select without code?

Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching MultiSelect code to copy.

Often searched

Jump to

Copied to the clipboard