# Multi select

Package: @commenda/pathfinder@0.1.0
Page: /docs/components/multi-select
Figma: https://www.figma.com/design/dn7BkGYEfIyP0o2zfesfVh/The-System?node-id=865-3077

```tsx
import { DEFlag, GBFlag, INFlag, SGFlag } from '@commenda/pathfinder/flags';
import { MultiSelect } from '@commenda/pathfinder/multi-select';

const entityOptions = [
  { value: 'gb', label: 'Meridian UK Ltd', leadingIcon: <GBFlag size={16} /> },
  { value: 'de', label: 'Meridian GmbH', leadingIcon: <DEFlag size={16} /> },
  { value: 'in', label: 'Meridian India', leadingIcon: <INFlag size={16} /> },
  {
    value: 'sg',
    label: 'Meridian Singapore',
    leadingIcon: <SGFlag size={16} />,
  },
];

export const MultiSelectDefault = () => (
  <MultiSelect
    label="Entity"
    defaultValue={['gb', 'de', 'in', 'sg']}
    options={entityOptions}
  />
);
```


## Installation [#installation]

Import the component in your screen:

```tsx
import { MultiSelect } from '@commenda/pathfinder/multi-select';
```

Load the stylesheet once in your app root, after host styles:

```tsx
import '@commenda/pathfinder/styles.css';
```

Pathfinder ships precompiled CSS; no Tailwind setup is required. In Next.js App Router, put interactive state and callbacks in a client component. See [installation](/docs/getting-started/installation) for the workspace setup.

## Empty [#empty]


```tsx
import { DEFlag, GBFlag, INFlag, SGFlag } from '@commenda/pathfinder/flags';
import { MultiSelect } from '@commenda/pathfinder/multi-select';

const entityOptions = [
  { value: 'gb', label: 'Meridian UK Ltd', leadingIcon: <GBFlag size={16} /> },
  { value: 'de', label: 'Meridian GmbH', leadingIcon: <DEFlag size={16} /> },
  { value: 'in', label: 'Meridian India', leadingIcon: <INFlag size={16} /> },
  {
    value: 'sg',
    label: 'Meridian Singapore',
    leadingIcon: <SGFlag size={16} />,
  },
];

export const MultiSelectEmpty = () => (
  <MultiSelect
    label="Entity"
    placeholder="Select entities"
    options={entityOptions}
  />
);
```


## Clearable [#clearable]


```tsx
import { DEFlag, GBFlag, INFlag, SGFlag } from '@commenda/pathfinder/flags';
import { MultiSelect } from '@commenda/pathfinder/multi-select';

const entityOptions = [
  { value: 'gb', label: 'Meridian UK Ltd', leadingIcon: <GBFlag size={16} /> },
  { value: 'de', label: 'Meridian GmbH', leadingIcon: <DEFlag size={16} /> },
  { value: 'in', label: 'Meridian India', leadingIcon: <INFlag size={16} /> },
  {
    value: 'sg',
    label: 'Meridian Singapore',
    leadingIcon: <SGFlag size={16} />,
  },
];

export const MultiSelectClearable = () => (
  <MultiSelect
    label="Entity"
    defaultValue={['gb', 'de']}
    options={entityOptions}
    clearConfig={{}}
  />
);
```


## States [#states]


```tsx
import { DEFlag, GBFlag, INFlag, SGFlag } from '@commenda/pathfinder/flags';
import { MultiSelect } from '@commenda/pathfinder/multi-select';

const entityOptions = [
  { value: 'gb', label: 'Meridian UK Ltd', leadingIcon: <GBFlag size={16} /> },
  { value: 'de', label: 'Meridian GmbH', leadingIcon: <DEFlag size={16} /> },
  { value: 'in', label: 'Meridian India', leadingIcon: <INFlag size={16} /> },
  {
    value: 'sg',
    label: 'Meridian Singapore',
    leadingIcon: <SGFlag size={16} />,
  },
];

export const MultiSelectStates = () => (
  <div style={{ display: 'grid', gap: 24 }}>
    <MultiSelect
      label="Entity"
      placeholder="Select entities"
      options={entityOptions}
    />
    <MultiSelect
      label="Entity"
      placeholder="Select entities"
      options={entityOptions}
      disableConfig={{ isDisabled: true }}
    />
    <MultiSelect
      label="Entity"
      defaultValue={['gb', 'de', 'in', 'sg']}
      options={entityOptions}
      disableConfig={{ isDisabled: true }}
    />
    <MultiSelect
      label="Entity"
      placeholder="Select entities"
      options={entityOptions}
      errorConfig={{ message: 'Choose an entity.', reveal: 'immediate' }}
    />
    <MultiSelect
      label="Entity"
      defaultValue={['gb', 'de', 'in', 'sg']}
      options={entityOptions}
      errorConfig={{ message: 'Choose an entity.', reveal: 'immediate' }}
    />
  </div>
);
```


## Search [#search]


```tsx
import { DEFlag, GBFlag, INFlag, SGFlag } from '@commenda/pathfinder/flags';
import { MultiSelect } from '@commenda/pathfinder/multi-select';

const entityOptions = [
  { value: 'gb', label: 'Meridian UK Ltd', leadingIcon: <GBFlag size={16} /> },
  { value: 'de', label: 'Meridian GmbH', leadingIcon: <DEFlag size={16} /> },
  { value: 'in', label: 'Meridian India', leadingIcon: <INFlag size={16} /> },
  {
    value: 'sg',
    label: 'Meridian Singapore',
    leadingIcon: <SGFlag size={16} />,
  },
];

export const MultiSelectSearch = () => (
  <MultiSelect
    label="Entity"
    defaultValue={['gb', 'de']}
    options={entityOptions}
    searchConfig={{
      mode: 'local',
      enabled: true,
      placeholder: 'Search entities',
    }}
    selectAllConfig={{ label: 'Select all' }}
  />
);
```


## API reference [#api-reference]

| Prop | Type | Requirement | Behavior |
| --- | --- | --- | --- |
| `options` | `readonly ChoiceOption[]` | conditional | Same option contract as Select |
| `groups` | `readonly ChoiceGroup[]` | conditional | Same option contract as Select |
| `value` | `readonly string[]` | conditional | Controlled callback required; empty array clears |
| `defaultValue` | `readonly string[]` | optional | Controlled callback required; empty array clears |
| `searchConfig` | `{ mode: "local"; enabled?: boolean; placeholder?: string; } \| { mode: "remote"; query: string; onQueryChange: (query: string) => void; resultsQuery: string; isLoading: boolean; error?: string; onRetry?: () => void; placeholder?: string; }` | conditional | Same contract as Select |
| `selectedOptions` | `readonly ChoiceOption[]` | optional | Remote selected metadata independent from current results |
| `paginationConfig` | `ChoicePaginationConfig` | optional | Remote-only pagination |
| `selectionDisplayConfig` | `{ mode: "tags"; } \| { mode: "summary"; format: (count: number) => string; }` | optional | tags; measured +N overflow |
| `selectionLimitConfig` | `{ max: number; message?: string; }` | optional | Blocks additional selection, permits deselection |
| `selectAllConfig` | `{ label?: string; }` | optional | Only listed eligible options; respects limits |
| `clearConfig` | `{ label?: string; }` | optional | Clears unlocked values |
| `renderConfig` | `{ optionContent?: (option: ChoiceOption, state: { isSelected: boolean; }) => ReactNode; selectedContent?: (option: ChoiceOption) => ReactNode; }` | optional | Shared customization, virtualization, creation action |
| `listConfig` | `{ virtualized?: boolean; maxHeight?: number; }` | optional | Shared customization, virtualization, creation action |
| `addItemConfig` | `{ label: string; onAddItem: (query: string) => void; isDisabled?: boolean; }` | optional | Shared customization, virtualization, creation action |
| `label` | `string` | conditional | Visible field label. Supply an accessible name when it is omitted. |
| `hideLabel` | `boolean` | optional | Visually hide the label while preserving its accessible name. |
| `aria-label` | `string` | conditional | Defines a string value that labels the current element. |
| `required` | `boolean` | conditional | Mark the field as required. See validation guidance for the component. |
| `labelConfig` | `{ optionalLabel: string; help?: never; } \| { help: string; optionalLabel?: never; }` | optional | Choose either `optionalLabel` or `help`, never both. `never` excludes the other field. |
| `description` | `string` | optional | Supporting guidance associated with the field. |
| `errorConfig` | `{ message: string; reveal?: "after-blur" \| "immediate"; }` | optional | Validation message and when it becomes visible. |
| `disableConfig` | `DisableConfig` | optional | Disabled state with an optional explanation. |
| `id` | `string` | optional | Stable HTML identifier for the component. |
| `name` | `string` | optional |  |
| `form` | `string` | optional |  |
| `autoComplete` | `string` | optional |  |
| `className` | `string` | optional | Additional classes for the component root. |
| `placeholder` | `string` | optional |  |
| `onBlur` | `FocusEventHandler<HTMLButtonElement>` | optional | Called when focus leaves the component. |
| `onFocus` | `FocusEventHandler<HTMLButtonElement>` | optional | Called when focus enters the component. |
| `emptyLabel` | `string` | optional |  |
| `onValueChange` | `((value: readonly string[]) => void)` | conditional |  |

### searchConfig fields

Choose one shape; excluded fields cannot be combined.

#### Option 1

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `mode` | `"local"` | Yes |  |
| `enabled` | `boolean` | No |  |
| `placeholder` | `string` | No |  |

#### Option 2

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `mode` | `"remote"` | Yes |  |
| `query` | `string` | Yes |  |
| `onQueryChange` | `(query: string) => void` | Yes |  |
| `resultsQuery` | `string` | Yes |  |
| `isLoading` | `boolean` | Yes |  |
| `error` | `string` | No |  |
| `onRetry` | `(() => void)` | No |  |
| `placeholder` | `string` | No |  |

### paginationConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `hasMore` | `boolean` | Yes |  |
| `isLoadingMore` | `boolean` | Yes |  |
| `onLoadMore` | `() => void` | Yes |  |
| `error` | `string` | No |  |
| `onRetry` | `(() => void)` | No |  |

### selectionDisplayConfig fields

Choose one shape; excluded fields cannot be combined.

#### Option 1

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `mode` | `"tags"` | Yes |  |

#### Option 2

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `mode` | `"summary"` | Yes |  |
| `format` | `(count: number) => string` | Yes |  |

### selectionLimitConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `max` | `number` | Yes |  |
| `message` | `string` | No |  |

### selectAllConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | `string` | No |  |

### clearConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | `string` | No |  |

### renderConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `optionContent` | `((option: ChoiceOption, state: { isSelected: boolean; }) => ReactNode)` | No |  |
| `selectedContent` | `((option: ChoiceOption) => ReactNode)` | No |  |

### listConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `virtualized` | `boolean` | No |  |
| `maxHeight` | `number` | No |  |

### addItemConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | `string` | Yes |  |
| `onAddItem` | `(query: string) => void` | Yes |  |
| `isDisabled` | `boolean` | No |  |

### labelConfig fields

Choose one shape; excluded fields cannot be combined.

#### Option 1

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `optionalLabel` | `string` | Yes |  |

Do not supply: `help`.

#### Option 2

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `help` | `string` | Yes |  |

Do not supply: `optionalLabel`.

### errorConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `message` | `string` | Yes |  |
| `reveal` | `"after-blur" \| "immediate"` | No |  |

### disableConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `isDisabled` | `boolean` | Yes |  |
| `reason` | `string` | No |  |

Shared labels, descriptions, required annotations, errorConfig, disableConfig, IDs, form names, refs, and focus/blur callbacks follow [Input](/docs/components/input). Selection controls do not expose readOnly.

## Local and remote search [#local-and-remote-search]

A ChoiceOption has `value`, `label`, optional `description`, `keywords`, `leadingIcon`, and `disableConfig`. Groups contain stable `id`, `label`, and `options`. Keywords participate only in local filtering. Rich rendering preserves plain-text labels for accessibility and search.

Local config: `{ mode: 'local', enabled?, placeholder? }`. Small local lists use Base UI Select; searchable lists use Base UI Combobox internally. There are exactly two public selection components: Select and MultiSelect.

Remote config: `{ mode: 'remote', query, onQueryChange, resultsQuery, isLoading, error?, onRetry?, placeholder? }`. Query updates immediately; the application owns debouncing, requests, cache, and cancellation. Remote results bypass client filtering and retain server order. While query differs from resultsQuery, stale options are unavailable for selection. Associate resultsQuery with the response that actually produced the options.

Supply selectedOptions for edit-form selections absent from current results. Selection never disappears merely because a page changes. A selected-only cache bridges user selection updates; it is not an unbounded history of every result. Missing metadata renders an explicit unavailable-value label until supplied.

Pagination receives accumulated, deduplicated options. New pages preserve scroll; new queries reset it. The UI gates duplicate load-more requests and provides a keyboard-accessible Load more button. Page errors preserve loaded results and offer retry. Virtualization limits mounted rows; remote pagination limits fetched data. Use both where appropriate.

Locked selected options survive tag removal, clear-all, Backspace, and bulk operations. MultiSelect select-all affects listed enabled options only and cannot imply selecting every record on a server. Bulk selection that would exceed a limit is disabled rather than partially applied.

### Limit [#limit]


```tsx
import { DEFlag, GBFlag, INFlag } from '@commenda/pathfinder/flags';
import { MultiSelect } from '@commenda/pathfinder/multi-select';

export const MultiSelectLimit = () => (
  <MultiSelect
    label="Countries"
    selectionLimitConfig={{ max: 2 }}
    selectAllConfig={{}}
    options={[
      {
        leadingIcon: <GBFlag size={16} />,
        value: 'gb',
        label: 'United Kingdom',
      },
      { leadingIcon: <DEFlag size={16} />, value: 'de', label: 'Germany' },
      { leadingIcon: <INFlag size={16} />, value: 'in', label: 'India' },
    ]}
  />
);
```


### Summary [#summary]


```tsx
import { DEFlag, GBFlag } from '@commenda/pathfinder/flags';
import { MultiSelect } from '@commenda/pathfinder/multi-select';

export const MultiSelectSummary = () => (
  <MultiSelect
    label="Entities"
    selectionDisplayConfig={{
      mode: 'summary',
      format: (count) => `${String(count)} entities selected`,
    }}
    defaultValue={['gb', 'de']}
    options={[
      {
        leadingIcon: <GBFlag size={16} />,
        value: 'gb',
        label: 'Meridian UK Ltd',
      },
      {
        leadingIcon: <DEFlag size={16} />,
        value: 'de',
        label: 'Meridian GmbH',
      },
    ]}
  />
);
```


### Remote [#remote]


```tsx
import { useEffect, useState } from 'react';

import {
  type ChoiceOption,
  MultiSelect,
} from '@commenda/pathfinder/multi-select';

export const MultiSelectRemote = () => {
  const [query, setQuery] = useState('');
  const [values, setValues] = useState<readonly string[]>(['saved']);
  const [selected, setSelected] = useState<readonly ChoiceOption[]>([
    { value: 'saved', label: 'Previously saved entity' },
  ]);
  const [response, setResponse] = useState({
    query: '',
    options: [] as ChoiceOption[],
    count: 10,
  });
  const [loading, setLoading] = useState(true);
  const [loadingMore, setLoadingMore] = useState(false);
  useEffect(() => {
    const abort = new AbortController();
    // Demo transport: replace this timer with your query library or fetch using abort.signal.
    // Query updates immediately; the 250ms delay represents request debouncing.
    const timer = setTimeout(() => {
      if (abort.signal.aborted) return;
      const options = Array.from({ length: 30 }, (_, index) => ({
        value: String(index),
        label: `Meridian entity ${String(index + 1)}`,
      })).filter((option) =>
        option.label.toLowerCase().includes(query.toLowerCase())
      );
      setResponse({ query, options, count: 10 });
      setLoading(false);
    }, 250);

    return () => {
      abort.abort();
      clearTimeout(timer);
    };
  }, [query]);
  const listed = response.options.slice(0, response.count);

  return (
    <MultiSelect
      label="Entities"
      value={values}
      onValueChange={(next) => {
        setValues(next);
        const lookup = new Map(
          [...selected, ...listed].map((option) => [option.value, option])
        );
        setSelected(
          next.flatMap((value) => {
            const option = lookup.get(value);

            return option ? [option] : [];
          })
        );
      }}
      selectedOptions={selected}
      options={listed}
      searchConfig={{
        mode: 'remote',
        query,
        onQueryChange: (next) => {
          setQuery(next);
          setLoading(true);
        },
        resultsQuery: response.query,
        isLoading: loading,
      }}
      paginationConfig={{
        hasMore: response.count < response.options.length,
        isLoadingMore: loadingMore,
        onLoadMore: () => {
          setLoadingMore(true);
          setTimeout(() => {
            setResponse((current) =>
              current.query === query
                ? { ...current, count: current.count + 10 }
                : current
            );
            setLoadingMore(false);
          }, 250);
        },
      }}
      clearConfig={{}}
    />
  );
};
```


### Large [#large]


```tsx
import { MultiSelect } from '@commenda/pathfinder/multi-select';

export const MultiSelectLarge = () => (
  <MultiSelect
    label="Entities"
    options={Array.from({ length: 10000 }, (_, index) => ({
      value: String(index),
      label: `Entity ${String(index + 1)}`,
    }))}
    listConfig={{ virtualized: true, maxHeight: 280 }}
  />
);
```


Read [selection and search rules](/docs/components/multi-select#design-rules) for source provenance, API ownership, and constraints.

## Design rules [#design-rules]

Figma guidance and reviewed implementation decisions for this component. Rule IDs remain stable for source comments and AI references.

### Source rules [#source-rules]

| Rule            | Figma source | Contract                                                        |
| --------------- | ------------ | --------------------------------------------------------------- |
| MULTISELECT-001 | 865:59967    | Single-line tags with measured +N; removal does not open popup. |
| MULTISELECT-002 | 865:59988    | Keep popup open during selection; preserve option order.        |

Sources: [Search](https://www.figma.com/design/dn7BkGYEfIyP0o2zfesfVh/The-System?node-id=635-13518), [Select](https://www.figma.com/design/dn7BkGYEfIyP0o2zfesfVh/The-System?node-id=179-2), [MultiSelect](https://www.figma.com/design/dn7BkGYEfIyP0o2zfesfVh/The-System?node-id=865-3077). The supplied Select node 182:1182 is its dimensions frame; 180:1177 is the actual component set.

### Field geometry and states [#field-geometry-and-states]

The component sheets are [Select 182:1216](https://www.figma.com/design/dn7BkGYEfIyP0o2zfesfVh/The-System?node-id=182-1216) and [MultiSelect 865:3078](https://www.figma.com/design/dn7BkGYEfIyP0o2zfesfVh/The-System?node-id=865-3078).

- **SELECT-VISUAL-001:** 28px field, 6px radius, 8px inner padding, 4px content gaps, 16px flag and chevron. Clear precedes the chevron (180:593). Empty fields have no leading flag.
- **MULTISELECT-VISUAL-001:** 4px starting inset; selected chips and +N are 20px high with radius 4 and a 0.5px pearl 300 border. Chip icons/removal are 12px; the final chevron is 16px (180:620).
- **SELECTION-STATE-001:** Pearl 400 default border; pearl 500 hover. Open fields retain blue 700 border and a 2px blue 200 ring while focus is in the popup (180:152, 180:766). Invalid uses red 600 at rest and red 700/red 200 when open. Error text is red 900 with a 12px alert icon (180:1031).
- **SELECTION-STATE-002:** Disabled fields use pearl 100 background, pearl 300 border, pearl 600 values and pearl 800 labels. Disabled MultiSelect chips omit removal buttons (180:839).

The Figma 320px MultiSelect frame clips part of the second chip inside a 256px strip. The implementation deliberately truncates the final visible chip's label instead, preserving its removal button and full-label tooltip; a 96px minimum keeps partial chips readable before using +N. Placeholder text uses pearl 800 rather than source pearl 700 to meet text contrast on white. These are documented accessibility adjustments, not new design variants.

The entire field opens the list; removal and clear remain independent buttons. Simple and searchable paths share the same field rendering. Popups follow the embedded Dropdown instances 182:606 and 865:59996 (10px radius, 6px list inset, 2px row gap, 32px minimum rows, 36px flush search row with 12px horizontal inset); option roles remain Select/Combobox semantics. Multi-selection uses Pathfinder Checkbox in read-only presentation mode inside each option; the option owns selection and keyboard focus. Select-all is a functional Checkbox with mixed state. Single selection shows a trailing check. Search, clear and removal icons sit in explicit flex boxes so their centers align with the input and chip rows.

### API ownership [#api-ownership]

Exactly Select and MultiSelect are public. Both support local search, server search, groups, rich option content, pagination, and virtualization. Dropdown remains an action menu. Free-text query entry belongs to Search; typed text never silently becomes a selected option ID.

The application owns query requests, debounce, cancellation, cursors, deduplicated page merging, and cache invalidation. Pathfinder blocks stale-query selection using query/resultsQuery, retains server ranking, and displays initial loading, empty, error, load-more, and retry states. Do not pass yesterday's query as resultsQuery for today's response.

Use a query-keyed data hook or AbortController plus request identity protection. A late response for an earlier query must not overwrite the active query's results. Reset cursors when the query changes. The interactive remote example uses a cancellable simulated transport so it works without an application endpoint; replace it with your authenticated API adapter.

Selected IDs and selectedOptions are separate from fetched pages. selectedOptions resolves labels/icons for saved values missing from the current result set. It does not pin unmatched rows to the results. The component caches only currently selected metadata as a bridge; applications remain responsible for authoritative record updates and deleted-record decisions.

### Large lists and bulk actions [#large-lists-and-bulk-actions]

Enable listConfig.virtualized for large lists. This enables searchable interaction and measured-height rows; keyboard-highlighted options stay mounted. API pagination and virtualization solve different costs and can be combined. Option values must be globally unique and stable across groups and pages.

Select-all means currently listed, filtered, enabled results. Locked values survive all UI removal paths. A bulk selection that exceeds selectionLimitConfig.max is disabled; it never chooses an arbitrary subset. Selecting all matches across an entire backend requires a separate server-side selection model.

Custom renderers supply noninteractive content. Pathfinder owns trigger and option roles, events, refs, selection indicators, and focus. Add-item is a separate explicit action; application creation returns a real record and updates options/value.

### Accessibility and forms [#accessibility-and-forms]

Labels, descriptions, error reveal, and disable reasons follow the shared field contract. Single selection serializes one ID; multiple selection serializes repeated names. Trigger refs and form reset work for both primitive paths. Searchable popups provide an explicitly named query input. Load more and retry are keyboard-accessible buttons outside the option list.

Tag remove buttons are siblings of the trigger, never nested inside it. Long selected labels remain available through tooltips. Search result compositions must handle overlay Escape before field clear/collapse through preventDefault.

Flags, chevrons, and clear/remove icons sit in explicit square flex containers with both axes centered. Do not rely on inline SVG baseline alignment or compensate with pixel offsets. The selected value, tags, and trailing controls share the field’s vertical center. Row Checkbox instances are inert visual indicators; the option owns selection and accessibility semantics.

### Selection guidance [#selection-guidance]

- `DROPDOWN-FUTURE-01` — Figma selection guidance (the historical rule ID is retained): search beyond about eight choices; empty state names the query; footer has one link action outside options. Multiple selection keeps the popup open and preserves row order. Selected rows have checks; highlight remains independent. These selection behaviors belong to Select and MultiSelect, not the action-oriented Dropdown.
