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
Variants
Details
What Multi select brings along, read from the registry and the source.
- Category
- Pickers and selections
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/multi-select.tsx
- npm packages
- None
- Languages
- English and German, 13 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The MultiSelectProps interface from components/ui/multi-select.tsx, with the comments from the code. Also takes every prop of FieldOwnProps.
variantoptionalMultiSelectVariantchipspopoverliststack
optionsrequiredMultiSelectOption[]valueoptionalstring[]defaultValueoptionalstring[]onValueChangeoptional(value: string[]) => voidplaceholderoptionalstringsearchPlaceholderoptionalstringPlaceholder of the search field in popover and list
maxoptionalnumberAt most this many. Further options are disabled and a counter shows the room left.
triggerLabeloptionalstringpopover: the text on the button, e.g. "Status"
triggerIconoptionalIconNamepopover: icon on the button
searchoptionalbooleanpopover and list: a search field above the rows. Default when there are more than six options.
visibleoptionalnumberlist: rows shown before "Show all". Default 6.
sizeoptionalControlSizenameoptionalstringidoptionalstringdisabledoptionalbooleanrequiredoptionalbooleaninvalidoptionalbooleandefaultOpenoptionalbooleanOpen 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
| Key | English | German |
|---|---|---|
| placeholder | English Choose… | German Auswählen… |
| search | English Search | German Suchen |
| empty | English No match. Try another word. | German Kein Treffer. Versuch ein anderes Wort. |
| remove | English Remove | German Entfernen |
| clearAll | English Clear all | German Alle entfernen |
| selectAll | English Select all | German Alle wählen |
| selected | English selected | German ausgewählt |
| added | English added | German hinzugefügt |
| removed | English removed | German entfernt |
| of | English of | German von |
| more | English Show all | German Alle zeigen |
| less | English Show fewer | German Weniger zeigen |
| none | English Nothing chosen yet | German 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
'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.