Multi select
Multiple selected values with tags, limits, and API-backed search.
Installation
Import the component in your screen:
import { MultiSelect } from '@commenda/pathfinder/multi-select';Load the stylesheet once in your app root, after host styles:
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 for the workspace setup.
Empty
Clearable
States
Search
API reference
| Prop | Type | Behavior |
|---|---|---|
optionsConditional | readonly ChoiceOption[] | Same option contract as Select |
groupsConditional | readonly ChoiceGroup[] | Same option contract as Select |
valueConditional | readonly string[] | Controlled callback required; empty array clears |
defaultValue | readonly string[] | Controlled callback required; empty array clears |
searchConfigConditional | ObjectView fields · 2 shapes | Same contract as Select |
selectedOptions | readonly ChoiceOption[] | Remote selected metadata independent from current results |
paginationConfig | ChoicePaginationConfigView fields | Remote-only pagination |
selectionDisplayConfig | ObjectView fields · 2 shapes | tags; measured +N overflow |
selectionLimitConfig | ObjectView fields | Blocks additional selection, permits deselection |
selectAllConfig | ObjectView fields | Only listed eligible options; respects limits |
clearConfig | ObjectView fields | Clears unlocked values |
renderConfig | ObjectView fields | Shared customization, virtualization, creation action |
listConfig | ObjectView fields | Shared customization, virtualization, creation action |
addItemConfig | ObjectView fields | Shared customization, virtualization, creation action |
labelConditional | string | Visible field label. Supply an accessible name when it is omitted. |
hideLabel | boolean | Visually hide the label while preserving its accessible name. |
aria-labelConditional | string | Defines a string value that labels the current element. |
requiredConditional | boolean | Mark the field as required. See validation guidance for the component. |
labelConfig | ObjectView fields · 2 shapes | Choose either optionalLabel or help, never both. never excludes the other field. |
description | string | Supporting guidance associated with the field. |
errorConfig | ObjectView fields | Validation message and when it becomes visible. |
disableConfig | DisableConfigView fields | Disabled state with an optional explanation. |
id | string | Stable HTML identifier for the component. |
name | string | — |
form | string | — |
autoComplete | string | — |
className | string | Additional classes for the component root. |
placeholder | string | — |
onBlur | FocusEventHandler<HTMLButtonElement> | Called when focus leaves the component. |
onFocus | FocusEventHandler<HTMLButtonElement> | Called when focus enters the component. |
emptyLabel | string | — |
onValueChangeConditional | (
value: readonly string[]
) => void | — |
searchConfigObject fields
Choose one of these 2 shapes. Do not combine fields excluded by the selected shape.
Option 1
| Field | Type | Requirement |
|---|---|---|
mode | "local" | Required |
enabled | boolean | Optional |
placeholder | string | Optional |
Option 2
| Field | Type | Requirement |
|---|---|---|
mode | "remote" | Required |
query | string | Required |
onQueryChange | (query: string) => void | Required |
resultsQuery | string | Required |
isLoading | boolean | Required |
error | string | Optional |
onRetry | (() => void) | Optional |
placeholder | string | Optional |
paginationConfigObject fields
| Field | Type | Requirement |
|---|---|---|
hasMore | boolean | Required |
isLoadingMore | boolean | Required |
onLoadMore | () => void | Required |
error | string | Optional |
onRetry | (() => void) | Optional |
selectionDisplayConfigObject fields
Choose one of these 2 shapes. Do not combine fields excluded by the selected shape.
Option 1
| Field | Type | Requirement |
|---|---|---|
mode | "tags" | Required |
Option 2
| Field | Type | Requirement |
|---|---|---|
mode | "summary" | Required |
format | (count: number) => string | Required |
selectionLimitConfigObject fields
| Field | Type | Requirement |
|---|---|---|
max | number | Required |
message | string | Optional |
selectAllConfigObject fields
| Field | Type | Requirement |
|---|---|---|
label | string | Optional |
clearConfigObject fields
| Field | Type | Requirement |
|---|---|---|
label | string | Optional |
renderConfigObject fields
| Field | Type | Requirement |
|---|---|---|
optionContent | ((option: ChoiceOption, state: { isSelected: boolean; }) => ReactNode) | Optional |
selectedContent | ((option: ChoiceOption) => ReactNode) | Optional |
listConfigObject fields
| Field | Type | Requirement |
|---|---|---|
virtualized | boolean | Optional |
maxHeight | number | Optional |
addItemConfigObject fields
| Field | Type | Requirement |
|---|---|---|
label | string | Required |
onAddItem | (query: string) => void | Required |
isDisabled | boolean | Optional |
labelConfigObject fields
Choose one of these 2 shapes. Do not combine fields excluded by the selected shape.
Option 1
| Field | Type | Requirement |
|---|---|---|
optionalLabel | string | Required |
Do not supply: help.
Option 2
| Field | Type | Requirement |
|---|---|---|
help | string | Required |
Do not supply: optionalLabel.
errorConfigObject fields
| Field | Type | Requirement |
|---|---|---|
message | string | Required |
reveal | "after-blur" | "immediate" | Optional |
disableConfigObject fields
| Field | Type | Requirement |
|---|---|---|
isDisabled | boolean | Required |
reason | string | Optional |
Shared labels, descriptions, required annotations, errorConfig, disableConfig, IDs, form names, refs, and focus/blur callbacks follow Input. Selection controls do not expose readOnly.
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
Summary
Remote
Large
Read selection and search rules for source provenance, API ownership, and constraints.
Design rules
Figma guidance and reviewed implementation decisions for this component. Rule IDs remain stable for source comments and AI references.
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, Select, MultiSelect. The supplied Select node 182:1182 is its dimensions frame; 180:1177 is the actual component set.
Field geometry and states
The component sheets are Select 182:1216 and MultiSelect 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
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
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
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
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.