Zum Inhalt springen
UI Kit
Accent

Mehrfachauswahl

Mehrere Werte wählen, mit Suche, Gruppen, Anzahlen und Grenze. Als Chips im Feld, als Filterknopf, als offene Facettenliste oder als Stapel von Personen.

Eine von 7 Components in Picker und Auswahlfelder

4 Variants

Variants

Chips

Die gewählten Werte stehen als Chips im Feld, Tippen filtert die gruppierte Liste. Chips federn herein, die Rücktaste entfernt den letzten, ein Zähler zeigt den Rest.

Im Studio öffnen

Filterknopf

Ein Knopf, der den Filter und die Auswahl nennt, ein Panel mit Suche, Anzahlen, Alle wählen und Entfernen. Die Tabelle darunter folgt jeder Änderung.

Im Studio öffnen

Facettenliste

Immer offen, für Seitenleisten in Shop und Suche. Eine kleine Suche, Anzahlen je Wert, die ersten Zeilen mit Alle zeigen, und die Trefferzahl zählt mit.

Im Studio öffnen

Stapel

Suche im Feld, die gewählten Personen darunter gestapelt, mit Rolle und Entfernen. Eine Grenze von drei, wer abwesend ist, steht da, lässt sich aber nicht wählen.

Im Studio öffnen

Details

Was Mehrfachauswahl mitbringt, aus Registry und Quellcode gelesen.

Bereich
Elements
Variants
4
Version
1.0.0 · seit 1.1.0
Datei
components/ui/multi-select.tsx
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 13 eigene Texte

Props

Die Schnittstelle MultiSelectProps aus components/ui/multi-select.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von FieldOwnProps an.

  • variantoptional
    MultiSelectVariant

    chipspopoverliststack

  • optionsPflicht
    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

Eingebaute Texte

Diese Texte schreibt Mehrfachauswahl selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 13 Texte anzeigen
placeholderEnglisch Choose…Deutsch Auswählen…
searchEnglisch SearchDeutsch Suchen
emptyEnglisch No match. Try another word.Deutsch Kein Treffer. Versuch ein anderes Wort.
removeEnglisch RemoveDeutsch Entfernen
clearAllEnglisch Clear allDeutsch Alle entfernen
selectAllEnglisch Select allDeutsch Alle wählen
selectedEnglisch selectedDeutsch ausgewählt
addedEnglisch addedDeutsch hinzugefügt
removedEnglisch removedDeutsch entfernt
ofEnglisch ofDeutsch von
moreEnglisch Show allDeutsch Alle zeigen
lessEnglisch Show fewerDeutsch Weniger zeigen
noneEnglisch Nothing chosen yetDeutsch Noch nichts ausgewählt

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Mehrfachauswahl reagiert selbst auf die Tasten Escape, Enter, Pfeil hoch, Pfeil runter, Pos1, Ende, Tab, Rücktaste und Leertaste.
  • Setzt aria-activedescendant, aria-autocomplete, aria-controls, aria-describedby, aria-disabled, aria-expanded, aria-haspopup, aria-invalid, aria-label, aria-labelledby, aria-live und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen combobox, dialog, group, listbox und option.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 5 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 654 Zeilen
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} />,
  );
}

Häufige Fragen

Wie installiere ich Mehrfachauswahl?

Mit aiw add @ai-website/multi-select. Der Befehl legt die Datei components/ui/multi-select.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, avatar, input, select und popover. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Mehrfachauswahl?

4 Varianten, Chips, Filterknopf, Facettenliste und Stapel. Du wählst sie mit der Prop variant, zum Beispiel variant="list".

Kann ich die Texte von Mehrfachauswahl übersetzen?

Ja. Mehrfachauswahl bringt 13 eigene Texte auf Deutsch und Englisch mit, etwa „Auswählen…“, „Suchen“ und „Kein Treffer. Versuch ein anderes Wort.“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Mehrfachauswahl einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Mehrfachauswahl mit aiw_components und installieren sie mit aiw_add @ai-website/multi-select. Nenn im Prompt die Variante, etwa variant="popover", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Mehrfachauswahl ohne Code anpassen?

Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden MultiSelect-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert