Multi-select
Select multiple options — chips or count display, searchable, accessible, in React (@bpdm/ui) and Angular (@bpdm/ng).
The Multi-select lets users pick several options from a data-driven list. Selected items show as chips (with a "+N" overflow) or as a count, with optional search and a "select all" row.
Usage
Selected options appear as removable chips.
import { MultiSelect } from '@bpdm/ui/multi-select';
const FRAMEWORKS = [
{ value: 'react', label: 'React' },
{ value: 'angular', label: 'Angular' },
{ value: 'vue', label: 'Vue' },
{ value: 'svelte', label: 'Svelte' },
];
export function MultiSelectUsage() {
return <MultiSelect options={FRAMEWORKS} defaultValue={['react', 'vue']} placeholder="Select frameworks" />;
}import { Component } from '@angular/core';
import { BpdmMultiSelect } from '@bpdm/ng';
@Component({
selector: 'multi-select-usage',
imports: [BpdmMultiSelect],
templateUrl: './multi-select-usage.html',
})
export class MultiSelectUsage {
readonly frameworks = [
{ value: 'react', label: 'React' },
{ value: 'angular', label: 'Angular' },
{ value: 'vue', label: 'Vue' },
{ value: 'svelte', label: 'Svelte' },
];
}<bpdm-multi-select [options]="frameworks" [defaultValue]="['react', 'vue']" placeholder="Select frameworks" />Chip overflow
maxDisplay caps how many chips show; the rest collapse into a "+N". Here
maxDisplay={2} with four selected shows two chips and "+2".
import { MultiSelect } from '@bpdm/ui/multi-select';
const frameworks = [
{ value: 'react', label: 'React' },
{ value: 'vue', label: 'Vue' },
{ value: 'svelte', label: 'Svelte' },
{ value: 'solid', label: 'Solid' },
];
export function ChipOverflow() {
return (
<MultiSelect
options={frameworks}
maxDisplay={2}
defaultValue={['react', 'vue', 'svelte', 'solid']}
placeholder="Select frameworks"
/>
);
}import { Component } from '@angular/core';
import { BpdmMultiSelect } from '@bpdm/ng';
@Component({
selector: 'app-chip-overflow',
standalone: true,
imports: [BpdmMultiSelect],
template: `
<bpdm-multi-select
[options]="frameworks"
[maxDisplay]="2"
[defaultValue]="['react', 'vue', 'svelte', 'solid']"
placeholder="Select frameworks" />
`,
})
export class ChipOverflowComponent {
readonly frameworks = [
{ value: 'react', label: 'React' },
{ value: 'vue', label: 'Vue' },
{ value: 'svelte', label: 'Svelte' },
{ value: 'solid', label: 'Solid' },
];
}Count display
By default up to maxDisplay chips show before a "+N". Set maxDisplay={0} to
show a compact "N selected" count instead.
import { MultiSelect } from '@bpdm/ui/multi-select';
const frameworks = [
{ value: 'react', label: 'React' },
{ value: 'angular', label: 'Angular' },
{ value: 'vue', label: 'Vue' },
];
export function CountDisplay() {
return <MultiSelect options={frameworks} maxDisplay={0} defaultValue={['react', 'angular', 'vue']} />;
}import { Component } from '@angular/core';
import { BpdmMultiSelect } from '@bpdm/ng';
@Component({
selector: 'app-count-display',
standalone: true,
imports: [BpdmMultiSelect],
template: `<bpdm-multi-select [options]="frameworks" [maxDisplay]="0" [defaultValue]="['react', 'angular', 'vue']" />`,
})
export class CountDisplayComponent {
readonly frameworks = [
{ value: 'react', label: 'React' },
{ value: 'angular', label: 'Angular' },
{ value: 'vue', label: 'Vue' },
];
}Grouped & searchable
Pass groups ({ label, options }) for labelled sections, and searchable to
filter.
import { MultiSelect } from '@bpdm/ui/multi-select';
const CITIES = [
{
label: 'Europe',
options: [
{ value: 'lon', label: 'London' },
{ value: 'par', label: 'Paris' },
{ value: 'ber', label: 'Berlin' },
],
},
{
label: 'Americas',
options: [
{ value: 'nyc', label: 'New York' },
{ value: 'sf', label: 'San Francisco' },
{ value: 'tor', label: 'Toronto' },
],
},
];
export function GroupedCities() {
return <MultiSelect options={CITIES} searchable placeholder="Select cities" />;
}import { Component } from '@angular/core';
import { BpdmMultiSelect, type SelectItems } from '@bpdm/ng';
@Component({
selector: 'app-grouped-cities',
standalone: true,
imports: [BpdmMultiSelect],
template: `<bpdm-multi-select [options]="cities" searchable placeholder="Select cities" />`,
})
export class GroupedCitiesComponent {
readonly cities: SelectItems = [
{
label: 'Europe',
options: [
{ value: 'lon', label: 'London' },
{ value: 'par', label: 'Paris' },
{ value: 'ber', label: 'Berlin' },
],
},
{
label: 'Americas',
options: [
{ value: 'nyc', label: 'New York' },
{ value: 'sf', label: 'San Francisco' },
{ value: 'tor', label: 'Toronto' },
],
},
];
}Large datasets
The dropdown is virtualized — only visible rows render — so it stays smooth
with thousands of options. Combine with searchable to filter.
import { MultiSelect } from '@bpdm/ui/multi-select';
const options = Array.from({ length: 10000 }, (_, i) => ({
value: `item-${i}`,
label: `Item ${i + 1}`,
}));
export function MultiSelectLarge() {
return <MultiSelect searchable options={options} placeholder="Pick records" />;
}import { Component } from '@angular/core';
import { BpdmMultiSelect } from '@bpdm/ng';
@Component({
selector: 'app-multi-select-large',
standalone: true,
imports: [BpdmMultiSelect],
template: `<bpdm-multi-select [options]="options" searchable placeholder="Pick records" />`,
})
export class MultiSelectLargeComponent {
readonly options = Array.from({ length: 10000 }, (_, i) => ({
value: `item-${i}`,
label: `Item ${i + 1}`,
}));
}States
import { MultiSelect } from '@bpdm/ui/multi-select';
const frameworks = [
{ value: 'react', label: 'React' },
{ value: 'vue', label: 'Vue' },
{ value: 'angular', label: 'Angular' },
];
export function States() {
return (
<div className="flex flex-col gap-3">
<MultiSelect options={frameworks} placeholder="Default" />
<MultiSelect options={frameworks} aria-invalid placeholder="Invalid" />
<MultiSelect options={frameworks} disabled placeholder="Disabled" />
</div>
);
}import { Component } from '@angular/core';
import { BpdmMultiSelect } from '@bpdm/ng';
@Component({
selector: 'app-states',
standalone: true,
imports: [BpdmMultiSelect],
template: `
<div class="flex flex-col gap-3">
<bpdm-multi-select [options]="frameworks" placeholder="Default" />
<bpdm-multi-select [options]="frameworks" aria-invalid="true" placeholder="Invalid" />
<bpdm-multi-select [options]="frameworks" disabled placeholder="Disabled" />
</div>
`,
})
export class StatesComponent {
readonly frameworks = [
{ value: 'react', label: 'React' },
{ value: 'vue', label: 'Vue' },
{ value: 'angular', label: 'Angular' },
];
}Controlled value
value is an array of selected values; drive it with onValueChange (React) or
two-way [(value)] (Angular).
import { useState } from 'react';
import { MultiSelect } from '@bpdm/ui/multi-select';
export function Controlled({ frameworks }) {
const [value, setValue] = useState<string[]>(['react']);
return <MultiSelect options={frameworks} value={value} onValueChange={setValue} />;
}import { Component } from '@angular/core';
import { BpdmMultiSelect } from '@bpdm/ng';
@Component({
selector: 'app-controlled-multi',
standalone: true,
imports: [BpdmMultiSelect],
template: `<bpdm-multi-select [options]="frameworks" [(value)]="selected" />`,
})
export class ControlledMultiComponent {
selected: string[] = ['react'];
readonly frameworks = [
{ value: 'react', label: 'React' },
{ value: 'vue', label: 'Vue' },
{ value: 'angular', label: 'Angular' },
];
}API
| Prop | Type | Default | Description |
|---|---|---|---|
options | SelectItems (flat or grouped) | — | Required. Data-driven options. |
value / defaultValue | string[] | — | Controlled / uncontrolled selection. |
onValueChange | (value: string[]) => void | — | React — fires on change. Angular uses [(value)]. |
placeholder | string | Select… | Text when nothing is selected. |
maxDisplay | number | 3 | Chips shown before "+N"; 0 → "N selected" count. |
selectAll | boolean | true | Show a "Select all" row. |
searchable | boolean | false | Show a filter box. |
searchPlaceholder | string | Search… | Filter placeholder. |
emptyText | string | No results. | Shown when the filter matches nothing. |
size | sm | md | lg | md | Trigger height / text size. |
disabled | boolean | false | Disable the control. |
aria-invalid | boolean | — | Error state. |
id | string | — | Trigger id (for <label> association / testing). |
aria-label / aria-describedby | string | — | Accessible name / description for the trigger + option list. |
messages | { selectAll, clearAll, remove(label), selected(count) } | English | Override the built-in labels + count text for i18n. |
maxHeight (px) caps the list; in React contentClassName styles the panel.
Accessibility
- Full WAI-ARIA combobox + multi-selectable listbox wiring: the trigger is
role="combobox"witharia-haspopup="listbox",aria-expanded, andaria-controls; the panel isrole="listbox"aria-multiselectable="true"and each option isrole="option"witharia-selected. - The active option is exposed via
aria-activedescendanton the focused element (the search box whensearchable, otherwise the listbox), so screen readers announce each row as you arrow — even virtualized. Group headers arearia-hidden. - Select all is a tri-state
role="checkbox"(aria-checked=true/mixed/false); each chip's remove button and the clear-all button carry descriptivearia-labels. - Keyboard: arrows move, Enter toggles, Esc closes;
searchablemakes the filter an editable combobox. Give it a name via<label>(id) oraria-label; setaria-invalidon error.
Internationalization
- All built-in copy is translatable:
placeholder/searchPlaceholder/emptyTextare props, and themessagesprop overridesselectAll,clearAll, the per-chipremove(label)label, and theselected(count)count text (e.g."3 selected"). Option labels are your data. - RTL-safe: options use logical alignment (
text-start); chips, the count, and the chevron lay out with flex, so everything mirrors underdir="rtl". See the Internationalization guide.